From 8f061ddc7c162a55f6fe3a393583f9ebead82484 Mon Sep 17 00:00:00 2001 From: Rob Ekl Date: Sun, 8 Mar 2026 11:51:21 -0600 Subject: [PATCH 001/360] Prevent duplicate GPS stream subscriptions on restart (#6) Clean fix, guarding startWatching() against stacked subscriptions. Placement at the top of the method is correct (before any early returns). Pattern matches existing subscription management elsewhere in the codebase. --- lib/services/gps_service.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 1535a629..1c302498 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -161,6 +161,14 @@ class GpsService { Future startWatching() async { debugLog('[GPS] startWatching() called, current status: $_status'); + // Ensure only one active position stream subscription exists. + // startWatching() can be called multiple times (e.g. after permission flow). + if (_positionSubscription != null) { + debugLog('[GPS] Existing position subscription found, restarting watcher'); + await _positionSubscription?.cancel(); + _positionSubscription = null; + } + // Check if location services are enabled first (system-level setting) final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); From c8f0d71a37833fbb98444a65bc6b2c5bc0eba3f9 Mon Sep 17 00:00:00 2001 From: Rob Ekl Date: Sun, 8 Mar 2026 11:51:36 -0600 Subject: [PATCH 002/360] Own and dispose app-level stream subscriptions (#7) Good catch. These three stream subscriptions were the last unowned listeners in AppStateProvider. The fix mirrors the existing pattern used for _adapterStateSubscription, _logRxDataSubscription, _noiseFloorSubscription, and _batterySubscription. Clean and consistent. --- lib/providers/app_state_provider.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 51c9854b..2049c765 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -104,6 +104,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; StreamSubscription? _adapterStateSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _gpsStatusSubscription; + StreamSubscription? _gpsPositionSubscription; // GPS state GpsStatus _gpsStatus = GpsStatus.permissionDenied; @@ -558,7 +561,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); - _bluetoothService.connectionStream.listen((status) async { + await _connectionSubscription?.cancel(); + _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -582,7 +586,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to GPS changes debugLog('[INIT] Setting up GPS status listener...'); - _gpsService.statusStream.listen((status) { + await _gpsStatusSubscription?.cancel(); + _gpsStatusSubscription = _gpsService.statusStream.listen((status) { final previousStatus = _gpsStatus; _gpsStatus = status; @@ -607,7 +612,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); - _gpsService.positionStream.listen((position) async { + await _gpsPositionSubscription?.cancel(); + _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -4345,6 +4351,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); + _connectionSubscription?.cancel(); + _gpsStatusSubscription?.cancel(); + _gpsPositionSubscription?.cancel(); _logRxDataSubscription?.cancel(); _noiseFloorSubscription?.cancel(); _batterySubscription?.cancel(); From 4d41d432b785f2d18caf35d7aa524cd5cfc211c2 Mon Sep 17 00:00:00 2001 From: Rob Ekl Date: Sun, 8 Mar 2026 11:51:56 -0600 Subject: [PATCH 003/360] Fix mobile BLE scan stream completion and cleanup (#8) Fixes a real issue: the scan stream had no termination path, which could leave callers hanging. The three cleanup paths (natural stop, manual stop, finally) cover all cases well. The identical() check on _scanController is a nice detail for rapid re-scan scenarios. Minor note: the unawaited watcher on isScanning could theoretically fire before scan results arrive if isScanning briefly emits false during startup, but the !controller.isClosed guards prevent any actual breakage. --- lib/services/bluetooth/mobile_bluetooth.dart | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 6131f832..d279477d 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -46,6 +46,7 @@ class MobileBluetoothService implements BluetoothService { StreamSubscription? _connectionStateSubscription; StreamSubscription? _notificationSubscription; StreamSubscription? _scanSubscription; + StreamController? _scanController; // Store scanned device info for use in connect() // This preserves the device name from scan results @@ -183,10 +184,15 @@ class MobileBluetoothService implements BluetoothService { @override Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); + _scanController = controller; _updateStatus(ConnectionStatus.scanning); try { + // Ensure any previous scan stream is closed before starting a new one + await _scanSubscription?.cancel(); + _scanSubscription = null; + // Start scanning with filter for MeshCore service UUID await fbp.FlutterBluePlus.startScan( withServices: [fbp.Guid(BleUuids.serviceUuid)], @@ -208,14 +214,30 @@ class MobileBluetoothService implements BluetoothService { ); // Store device info for use in connect() - preserves name from scan _scannedDevices[device.id] = device; - controller.add(device); + if (!controller.isClosed) { + controller.add(device); + } } }); + // Complete stream when scan naturally stops (timeout or platform stop) + unawaited(() async { + await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + if (!controller.isClosed) { + await controller.close(); + } + }()); + yield* controller.stream; } finally { await _scanSubscription?.cancel(); _scanSubscription = null; + if (!controller.isClosed) { + await controller.close(); + } + if (identical(_scanController, controller)) { + _scanController = null; + } } } @@ -224,6 +246,11 @@ class MobileBluetoothService implements BluetoothService { await fbp.FlutterBluePlus.stopScan(); await _scanSubscription?.cancel(); _scanSubscription = null; + final scanController = _scanController; + if (scanController != null && !scanController.isClosed) { + await scanController.close(); + } + _scanController = null; // NOTE: Do NOT fire 'disconnected' here. Stopping a scan is not a disconnection. // The status will be updated by connect() when a connection starts. // Firing 'disconnected' here causes a race condition where the queued event From 18330b529c169aa942fda9f8d59e9479ccc52dda Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 8 Mar 2026 21:30:20 -0400 Subject: [PATCH 004/360] Bump version to 1.2.0 and enhance authentication flow with staged public_key and contact_uri registration --- .build_version | 2 +- lib/providers/app_state_provider.dart | 106 +++++++++++++++++++++++--- lib/services/gps_service.dart | 6 ++ 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/.build_version b/.build_version index 524cb552..26aaba0e 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.1.1 +1.2.0 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2049c765..2d7a7680 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2700,33 +2700,115 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - debugLog('[APP] Requesting auth for online mode'); - final result = await _apiService.requestAuth( + // ============================================================ + // STAGE 1: Try existing public_key authentication + // ============================================================ + debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + model: modelString, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, ); - if (result == null) { - debugError('[APP] Auth request failed: no response'); - _modeSwitchError = 'Unable to connect to server'; + // Check for maintenance mode + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } - if (result['success'] != true) { - final reason = result['reason'] as String?; - final message = result['message'] as String?; - debugError('[APP] Auth request failed: $reason - $message'); - _modeSwitchError = message ?? reason ?? 'Authentication failed'; + // Check if Stage 1 succeeded + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + } else if (result == null) { + // API unreachable (null = network/timeout error) + debugError('[APP] API unreachable - network error'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; return (success: false, error: _modeSwitchError); + } else { + // Stage 1 failed — check if Stage 2 is worth attempting + debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + _modeSwitchError = result['message'] as String? ?? 'GPS error'; + return (success: false, error: _modeSwitchError); + } + + // ============================================================ + // STAGE 2: Auth failed, attempt registration via signed contact_uri + // ============================================================ + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + _modeSwitchError = 'Companion not found in backend and failed to register via API'; + return (success: false, error: _modeSwitchError); + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: modelString, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; + return (success: false, error: _modeSwitchError); + } + + if (registerResult['success'] != true) { + final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + _modeSwitchError = serverMessage ?? 'Registration rejected by server'; + return (success: false, error: _modeSwitchError); + } + + // Registration successful + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + + result = registerResult; } // 5. Auth successful - update state diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 1c302498..a6c7084e 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -202,6 +202,12 @@ class GpsService { } debugLog('[GPS] Starting position stream listener...'); + + // Cancel any existing subscription to prevent orphaned listeners + // (e.g. restartGpsAfterPermission() racing with _initialize()) + await _positionSubscription?.cancel(); + _positionSubscription = null; + _updateStatus(GpsStatus.searching); // Configure location settings for position stream From fb59acaf0a7ee8d6e3e38b7fec3345f321dd34fc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 8 Mar 2026 21:55:46 -0400 Subject: [PATCH 005/360] - **Hive corruption fallback for ping queue:** When the `api_queue` Hive box fails to open (e.g. corruption), `_safeWrite()` was silently returning false and dropping all enqueued pings. A full wardrive session could complete with zero ping data uploaded, as all POSTs would be heartbeats only. Fixed by adding a `_memoryQueue` in-memory fallback: failed writes go to memory instead of being dropped, `_uploadBatch` drains both Hive and memory queues, `queueSize` reflects both, and all clear/disconnect/init paths also flush `_memoryQueue`. --- lib/providers/app_state_provider.dart | 57 ++++++++++- lib/services/api_queue_service.dart | 110 ++++++++++++++++------ lib/services/offline_session_service.dart | 7 ++ 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2d7a7680..a7d50bfe 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -119,6 +119,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { DeviceModel? _deviceModel; String? _manufacturerString; String? _devicePublicKey; + String? _offlineContactUri; /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; @@ -1032,6 +1033,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } + + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } } notifyListeners(); }); @@ -2242,6 +2253,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _deviceModel = null; _manufacturerString = null; _devicePublicKey = null; + _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); @@ -2930,6 +2942,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { pings, devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, + contactUri: _offlineContactUri, ); debugLog('[APP] Saved offline session with ${pings.length} pings'); notifyListeners(); @@ -3023,13 +3036,49 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { offlineMode: true, ); - if (authResult == null || authResult['success'] != true) { - final reason = authResult?['reason'] as String? ?? 'unknown'; - debugError('[API] Offline upload auth failed: $reason'); + Map? effectiveAuth = authResult; + + if (authResult == null) { + debugError('[API] Offline upload auth failed: network error'); return OfflineUploadResult.authFailed; } - debugLog('[APP] Offline upload authenticated, session: ${authResult['session_id']}'); + if (authResult['success'] != true) { + final reason = authResult['reason'] as String? ?? 'unknown'; + debugLog('[API] Offline upload Stage 1 failed: $reason'); + + // Stage 2: If unknown_device and we have a stored contactUri, attempt registration + if (reason == 'unknown_device' && session.contactUri != null) { + debugLog('[APP] Stage 2: Attempting registration via stored contact URI...'); + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: session.contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: 'Offline Upload', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + ); + + if (registerResult == null || registerResult['success'] != true) { + final regReason = registerResult?['reason'] as String? ?? 'unknown'; + debugError('[API] Offline upload Stage 2 registration failed: $regReason'); + return OfflineUploadResult.authFailed; + } + + debugLog('[APP] Stage 2 succeeded: device registered for offline upload'); + effectiveAuth = registerResult; + } else { + debugError('[API] Offline upload auth failed: $reason'); + return OfflineUploadResult.authFailed; + } + } + + debugLog('[APP] Offline upload authenticated, session: ${effectiveAuth!['session_id']}'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 89b040fd..17b598f9 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -29,6 +29,9 @@ class ApiQueueService { bool _isUploading = false; bool _isRecovering = false; + // In-memory fallback when Hive is corrupted/unavailable + final List _memoryQueue = []; + // Offline mode bool offlineMode = false; final List> _offlinePings = []; @@ -79,6 +82,7 @@ class ApiQueueService { debugError('[API QUEUE] Failed to clear stale items: $e - recovering'); await _recoverBox(); } + _memoryQueue.clear(); _rxBuffer.clear(); _offlinePings.clear(); @@ -207,8 +211,8 @@ class ApiQueueService { } } - /// Get current queue size - int get queueSize => _safeRead((box) => box.length, 0); + /// Get current queue size (Hive + in-memory fallback) + int get queueSize => _safeRead((box) => box.length, 0) + _memoryQueue.length; /// Enqueue a TX ping /// heardRepeats format: "4e(12.25),77(12.25)" or "None" @@ -236,8 +240,13 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); _pingFlushTimer = Timer(const Duration(seconds: 5), () { @@ -322,8 +331,13 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); _pingFlushTimer = Timer(const Duration(seconds: 5), () { @@ -356,8 +370,13 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); _pingFlushTimer = Timer(const Duration(seconds: 5), () { @@ -386,10 +405,12 @@ class ApiQueueService { final bufferSize = _rxBuffer.length; _rxBuffer.clear(); - // Now add items to the box + // Now add items to the box (or memory fallback) for (final item in itemsToFlush) { final ok = await _safeWrite((box) => box.add(item)); - if (!ok) break; + if (!ok) { + _memoryQueue.add(item); + } } debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); @@ -424,13 +445,17 @@ class ApiQueueService { await _uploadBatch(); } - /// Upload batch of queued items + /// Upload batch of queued items (from Hive box or in-memory fallback) Future _uploadBatch() async { if (_isUploading) { debugLog('[API QUEUE] Upload skipped: already uploading'); return; } - if (_safeRead((box) => box.isEmpty, true)) { + + final hiveEmpty = _safeRead((box) => box.isEmpty, true); + final memoryEmpty = _memoryQueue.isEmpty; + + if (hiveEmpty && memoryEmpty) { debugLog('[API QUEUE] Upload skipped: queue empty'); return; } @@ -438,15 +463,8 @@ class ApiQueueService { _isUploading = true; try { - // Log if TX items are waiting in hold period - final pendingTx = _safeRead((box) => box.values.where((item) => - item.type == 'TX' && !item.isUploadEligible).length, 0); - if (pendingTx > 0) { - debugLog('[API QUEUE] $pendingTx TX items still in hold period'); - } - - // Get items ready for upload (must pass retry, retry delay, AND upload eligibility checks) - final items = _safeRead((box) => box.values + // Collect items from both Hive and memory queue + final hiveItems = _safeRead((box) => box.values .where((item) => item.retryCount < _maxRetries && item.isReadyForRetry && @@ -454,6 +472,16 @@ class ApiQueueService { .take(_batchSize) .toList(), []); + final memoryItems = _memoryQueue + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize - hiveItems.length) + .toList(); + + final items = [...hiveItems, ...memoryItems]; + if (items.isEmpty) { debugLog('[API QUEUE] Upload skipped: no items ready for upload'); _isUploading = false; @@ -469,30 +497,47 @@ class ApiQueueService { debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } - debugLog('[API QUEUE] Uploading ${items.length} items...'); + final memoryCount = memoryItems.length; + if (memoryCount > 0) { + debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + } else { + debugLog('[API QUEUE] Uploading ${items.length} items...'); + } // Attempt upload final result = await _apiService.uploadBatch(pings); if (result == UploadResult.success) { final uploadedCount = items.length; - // Remove successful items - for (final item in items) { - await item.delete(); + // Remove successful Hive items + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + // Remove successful memory items + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); onUploadSuccess?.call(uploadedCount); } else if (result == UploadResult.nonRetryable) { - // Data is permanently invalid (bad GPS, invalid request, etc.) — discard - for (final item in items) { - await item.delete(); + // Data is permanently invalid — discard + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried - for (final item in items) { + for (final item in hiveItems) { item.markRetried(); } + // Memory items: update retry fields directly (no Hive save) + for (final item in memoryItems) { + item.retryCount++; + item.lastRetryAt = DateTime.now(); + } debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } @@ -522,6 +567,7 @@ class ApiQueueService { /// Clear all queued items Future clear() async { await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -542,6 +588,7 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -555,6 +602,7 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count stale items before connect'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); @@ -567,10 +615,12 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { - return _safeRead( + final hiveItems = _safeRead( (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); + final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + return [...hiveItems, ...memoryItems]; } /// Get accumulated offline pings and clear the accumulator diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 282d911d..3df1b90f 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -12,6 +12,7 @@ class OfflineSession { final Map data; final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload final bool uploaded; // Track upload status OfflineSession({ @@ -21,6 +22,7 @@ class OfflineSession { required this.data, this.devicePublicKey, this.deviceName, + this.contactUri, this.uploaded = false, }); @@ -33,6 +35,7 @@ class OfflineSession { data: json['data'] as Map, devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, + contactUri: json['contactUri'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -46,6 +49,7 @@ class OfflineSession { 'data': data, 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, + 'contactUri': contactUri, 'uploaded': uploaded, }; } @@ -59,6 +63,7 @@ class OfflineSession { data: data, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, uploaded: uploaded ?? this.uploaded, ); } @@ -141,6 +146,7 @@ class OfflineSessionService { List> pings, { String? devicePublicKey, String? deviceName, + String? contactUri, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -167,6 +173,7 @@ class OfflineSessionService { data: sessionData, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, ); _sessions.insert(0, session); // Add at beginning (newest first) From ae7fb0300e222a05ad6bfc24bd03a78a2cb4f3a8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 11 Mar 2026 23:01:08 -0400 Subject: [PATCH 006/360] Add heartbeat retry, local session expiry tracking, and offline queue preservation - Heartbeat retries on network failure with exponential backoff (5 attempts, 30s-120s) - Local session deadline timer fires at expires_at, triggers session error if backend is unreachable - On session expiry, queued wardrive data is saved as an offline session instead of discarded --- lib/providers/app_state_provider.dart | 23 ++++++++ lib/services/api_queue_service.dart | 18 ++++++ lib/services/api_service.dart | 84 +++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 11 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a7d50bfe..d859ecb7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3410,6 +3410,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { authErrors.contains(reason) || zoneErrors.contains(reason)) { debugLog('[API] Session error requires disconnect: $reason'); + + // Preserve queued wardrive data to offline storage before disconnect clears it + if (sessionErrors.contains(reason)) { + try { + final queuedPings = await _apiQueueService.extractAllAsJson(); + if (queuedPings.isNotEmpty) { + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + await _offlineSessionService.saveSession( + queuedPings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + } + } catch (e) { + debugError('[APP] Failed to preserve queue to offline storage: $e'); + } + } + // Don't call requestAuth disconnect - session is already invalid on server // Just cleanup locally and disconnect await disconnect(); diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 17b598f9..b03ab9ff 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -636,6 +636,24 @@ class ApiQueueService { _offlinePings.clear(); } + /// Extract all queued items as API JSON without clearing the queue. + /// Used to preserve data before session-expiry disconnect. + Future>> extractAllAsJson() async { + // Flush RX buffer first so all items are in the main queue + await _flushRxBuffer(); + + final hiveItems = _safeRead( + (box) => box.values.toList(), + [], + ); + + final allItems = [...hiveItems, ..._memoryQueue]; + + if (allItems.isEmpty) return []; + + return allItems.map((item) => item.toApiJson()).toList(); + } + /// Dispose of resources void dispose() { _batchTimer?.cancel(); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 72d8300c..aed7fb7e 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:http/http.dart' as http; @@ -38,6 +39,10 @@ class ApiService { bool _rxAllowed = false; int? _sessionExpiresAt; Timer? _heartbeatTimer; + Timer? _heartbeatRetryTimer; + Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; + static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; List _channels = []; List _scopes = []; @@ -590,6 +595,11 @@ class ApiService { _gpsProvider = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -597,9 +607,12 @@ class ApiService { /// Matches scheduleHeartbeat() in wardrive.js /// @param expiresAt Unix timestamp when session expires void scheduleHeartbeat(int expiresAt) { - // Cancel any existing heartbeat timer + // Cancel any existing heartbeat timer and reset retry state _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; if (!_heartbeatEnabled) return; @@ -612,15 +625,17 @@ class ApiService { // Session is about to expire or already expired - send heartbeat immediately debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); - return; - } + } else { + debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); + } - _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { - debugLog('[HEARTBEAT] Timer fired, sending keepalive'); - _sendScheduledHeartbeat(); - }); + // Schedule session deadline timer at exact expiry + _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -631,10 +646,22 @@ class ApiService { if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); + // Reset retry state on success + _heartbeatRetryCount = 0; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; // Next heartbeat will be scheduled when we get new expires_at } else if (result == null) { - // Network error — transient, trigger session expiring - debugWarn('[HEARTBEAT] Heartbeat failed: network error'); + // Network error — schedule retry with exponential backoff + if (_heartbeatRetryCount < _maxHeartbeatRetries) { + final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); + _heartbeatRetryCount++; + debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + } else { + debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + } _onSessionExpiring?.call(); } else { // Server returned an error — check if critical @@ -657,7 +684,33 @@ class ApiService { } } - /// Clear session data and cancel heartbeat timer + /// Schedule a hard deadline timer at the exact session expiry time. + /// If the server is unreachable and all heartbeat retries fail, this fires + /// and triggers the same disconnect flow as a server-returned session_expired. + void _scheduleSessionDeadline(int expiresAt) { + _sessionDeadlineTimer?.cancel(); + if (!_heartbeatEnabled) return; + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final secondsUntilExpiry = expiresAt - now; + + if (secondsUntilExpiry <= 0) { + _onSessionDeadlineReached(); + return; + } + + debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); + _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); + } + + /// Called when the session deadline timer fires — server was unreachable + void _onSessionDeadlineReached() { + debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); + _clearSession(); + onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); + } + + /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; _txAllowed = false; @@ -671,6 +724,11 @@ class ApiService { _apiHopBytes = 1; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -807,6 +865,10 @@ class ApiService { void dispose() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; _client.close(); } } From 40b3da910c32f4466af14c88321935e836ef20b6 Mon Sep 17 00:00:00 2001 From: Rob Ekl Date: Sat, 14 Mar 2026 20:19:34 -0500 Subject: [PATCH 007/360] Cancel stale auto-ping restore timers (#10) LGTM. Right fix, matches the pattern used by _reconnectTimer and _reconnectTimeoutTimer. The generation counter is slightly defensive given Dart's single-threaded model but adds no complexity so no complaints. --- lib/providers/app_state_provider.dart | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d859ecb7..81a3b3c2 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -225,8 +225,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; + Timer? _restoreAutoPingTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; + int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); @@ -1916,6 +1918,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } Future _fullDisconnectCleanup() async { + _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; // Stop heartbeat immediately on BLE disconnect @@ -1977,6 +1980,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { + _cancelPendingAutoPingRestore(); _isAutoReconnecting = true; _reconnectAttempt = 0; _connectionStep = ConnectionStep.reconnecting; @@ -2098,9 +2102,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Restore auto-ping if it was active if (wasAutoPing) { + final restoreGeneration = _reconnectRestoreGeneration; // Use a short delay to ensure connection is fully set up - Timer(const Duration(milliseconds: 500), () { - if (_connectionStep == ConnectionStep.connected) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + restoreGeneration != _reconnectRestoreGeneration || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + return; + } + + if (!_autoPingEnabled) { toggleAutoPing(previousMode); debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } @@ -2123,6 +2139,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); // Clear reconnect state _isAutoReconnecting = false; @@ -2153,6 +2170,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; @@ -4494,6 +4512,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Cleanup // ============================================ + void _cancelPendingAutoPingRestore() { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = null; + _reconnectRestoreGeneration++; + } + @override @override void notifyListeners() { @@ -4516,6 +4540,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneCheckCountdownTimer?.cancel(); _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); + _restoreAutoPingTimer?.cancel(); _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); From 8d53b49401acaf849eb4dbcccb66237f4954b787 Mon Sep 17 00:00:00 2001 From: Rob Ekl Date: Sat, 14 Mar 2026 20:20:19 -0500 Subject: [PATCH 008/360] Recheck location permission after disclosure (#11) Clean separation of disclosure (show once) from permission (always recheck), plus proper permanently-denied handling --- lib/screens/main_scaffold.dart | 54 +++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index cd28c2c5..87c2c435 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -26,6 +26,7 @@ class MainScaffold extends StatefulWidget { class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; + bool _hasShownLocationSettingsPrompt = false; final List _screens = [ const HomeScreen(), @@ -59,18 +60,15 @@ class _MainScaffoldState extends State { // Check if disclosure was already shown final hasShown = await PermissionDisclosureService.hasShownDisclosure(); - if (hasShown) { - debugLog('[DISCLOSURE] Already shown, skipping'); - return; + if (!hasShown) { + // Show the disclosure dialog + if (!mounted) return; + debugLog('[DISCLOSURE] Showing location disclosure dialog'); + await PermissionDisclosureService.showLocationDisclosure(context); } - // Show the disclosure dialog - if (!mounted) return; - debugLog('[DISCLOSURE] Showing location disclosure dialog'); - await PermissionDisclosureService.showLocationDisclosure(context); - - debugLog('[DISCLOSURE] User acknowledged, requesting permissions'); - await _requestPermissionsAfterDisclosure(); + debugLog('[DISCLOSURE] Ensuring location permission after disclosure'); + await _ensureLocationPermission(); } /// Request GPS permission on web (triggers browser's native prompt) @@ -93,8 +91,10 @@ class _MainScaffoldState extends State { } } - /// Request permissions after user accepts disclosure - Future _requestPermissionsAfterDisclosure() async { + /// Ensure location permission after disclosure has been shown. + /// Requests when possible, restarts GPS when granted, and surfaces a settings CTA + /// when the permission has been permanently denied. + Future _ensureLocationPermission() async { bool granted = false; if (Platform.isIOS) { @@ -104,12 +104,23 @@ class _MainScaffoldState extends State { permission = await Geolocator.requestPermission(); } debugLog('[DISCLOSURE] iOS location permission: $permission'); + if (permission == LocationPermission.deniedForever) { + _showLocationSettingsPrompt(); + return; + } granted = permission == LocationPermission.always || permission == LocationPermission.whileInUse; } else { - // Android: Request location via permission_handler - final status = await Permission.locationWhenInUse.request(); + // Android: only request if needed so previously granted permission just restarts GPS. + var status = await Permission.locationWhenInUse.status; + if (status.isDenied) { + status = await Permission.locationWhenInUse.request(); + } debugLog('[DISCLOSURE] Android location permission: $status'); + if (status.isPermanentlyDenied) { + _showLocationSettingsPrompt(); + return; + } granted = status.isGranted; } @@ -121,6 +132,21 @@ class _MainScaffoldState extends State { } } + void _showLocationSettingsPrompt() { + if (!mounted || _hasShownLocationSettingsPrompt) return; + _hasShownLocationSettingsPrompt = true; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission is disabled in system settings.'), + action: SnackBarAction( + label: 'Settings', + onPressed: Geolocator.openAppSettings, + ), + ), + ); + } + @override Widget build(BuildContext context) { final appState = context.watch(); From bfb9a85233fdc667a35f7c98ef4fd4b99f442691 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 21:44:01 -0400 Subject: [PATCH 009/360] - Discovery pings now extract repeater IDs using the region's hop byte count instead of always assuming 1 byte. In multi-byte regions, TX/RX logs showed full IDs like "4E7A" while discovery logs still showed the truncated "4E" --- lib/models/log_entry.dart | 2 +- lib/providers/app_state_provider.dart | 7 ++----- lib/screens/settings_screen.dart | 20 ++++++++++---------- lib/services/meshcore/disc_tracker.dart | 11 +++++++---- lib/services/meshcore/packet_validator.dart | 12 ++++++++++++ lib/services/meshcore/rx_logger.dart | 2 +- lib/services/meshcore/tx_tracker.dart | 2 +- lib/services/ping_service.dart | 8 +++++++- 8 files changed, 41 insertions(+), 23 deletions(-) diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 399da9b2..b4b81a09 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -201,7 +201,7 @@ class DiscLogEntry { /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final String nodeType; // "REPEATER" or "ROOM" final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 81a3b3c2..c295f04b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1168,14 +1168,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { txTracker: _txTracker, audioService: _audioService, disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, shouldIgnoreRepeater: (String repeaterId) { - // Same filter as RxLogger - check user preferences for ignored repeater ID - // Uses startsWith() for prefix matching across different hop byte sizes final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current.startsWith(ignored) || ignored.startsWith(current); + return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); } return false; }, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 77e6378b..7e00f46b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1201,9 +1201,8 @@ class _SettingsScreenState extends State { } void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { - final effectiveBytes = appState.effectiveHopBytes; - final maxHexChars = effectiveBytes * 2; - final hintText = 'F' * maxHexChars; + const maxHexChars = 6; + const hintText = 'FFFFFF'; final controller = TextEditingController( text: appState.preferences.ignoreRepeaterId ?? '', @@ -1217,15 +1216,15 @@ class _SettingsScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Enter the repeater ID of your CARpeater ($maxHexChars hex digits):'), + const Text('Enter the full 3-byte repeater ID (6 hex digits):'), const SizedBox(height: 16), TextField( controller: controller, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: 'CARpeater ID', hintText: hintText, prefixText: '0x', - border: const OutlineInputBorder(), + border: OutlineInputBorder(), ), maxLength: maxHexChars, textCapitalization: TextCapitalization.characters, @@ -1242,7 +1241,9 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater with null signal data. Single-hop CARpeater packets are still dropped.', + 'Enter all 6 hex digits of your CARpeater\'s ID. ' + 'The app will automatically truncate to match your region\'s hop byte size (1, 2, or 3 bytes). ' + 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater.', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1258,9 +1259,8 @@ class _SettingsScreenState extends State { TextButton( onPressed: () { final value = controller.text.trim().toUpperCase(); - // Accept hex IDs of any valid even length (2, 4, or 6 chars) final isValidHex = value.isEmpty || - (value.length % 2 == 0 && value.length <= 6 && + (value.length == maxHexChars && RegExp(r'^[0-9A-F]+$').hasMatch(value)); if (isValidHex) { @@ -1274,7 +1274,7 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Invalid hex value. Use $maxHexChars hex digits.'); + AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 30130139..5796d6f1 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -31,7 +31,10 @@ class DiscTracker { /// Parameters: (node, isNew) - isNew is true for first time seeing this node void Function(DiscoveredNode node, bool isNew)? onNodeDiscovered; - DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false}); + /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. + final int hopBytes; + + DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -134,8 +137,8 @@ class DiscTracker { final pubkey = rawBytes.sublist(7, 39); final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - // Get repeater ID (first 2 hex chars = first byte) - final repeaterId = pubkeyHex.substring(0, 2); + // Get repeater ID (first N hex chars based on hopBytes setting) + final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { @@ -209,7 +212,7 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index 8cfd5d0b..e9cec944 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -172,6 +172,18 @@ class PacketValidator { return rssi >= maxRssiThreshold; } + /// Check if a hop hex string matches a stored CARpeater ID using prefix truncation. + /// The stored ID is always 3-byte (6 hex chars). Incoming hop IDs vary by the + /// packet's path hash size (1, 2, or 3 bytes). Compares the shorter prefix. + /// Also handles legacy shorter stored IDs from before the 3-byte requirement. + static bool isCarpeaterIdMatch(String hopHex, String storedId) { + final hop = hopHex.toUpperCase(); + final stored = storedId.toUpperCase(); + final compareLen = hop.length < stored.length ? hop.length : stored.length; + if (compareLen == 0) return false; + return hop.substring(0, compareLen) == stored.substring(0, compareLen); + } + /// Calculate ratio of printable ASCII characters (32-126) static double getPrintableRatio(String text) { if (text.isEmpty) return 0.0; diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 9c464b3a..0451fffc 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -88,7 +88,7 @@ class RxLogger { // CARpeater check: the carpeater is co-located with us, so it only // appears as the last hop (the delivery repeater) on RX packets - if (carpeaterPrefix != null && lastHopHex == carpeaterPrefix!.toUpperCase()) { + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); return false; diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 21e63a45..50b1d2ca 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -125,7 +125,7 @@ class TxTracker { double? reportedSnr = metadata.snr; int? reportedRssi = metadata.rssi; - if (carpeaterPrefix != null && pathHex.toUpperCase() == carpeaterPrefix!.toUpperCase()) { + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); return false; diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 54513f4e..cb9f10d5 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -61,6 +61,9 @@ class PingService { final AudioService? _audioService; final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. + final int _hopBytes; + /// When true, skip RSSI carpeater check in DiscTracker (user setting) bool disableRssiFilter; @@ -166,6 +169,7 @@ class PingService { AudioService? audioService, this.shouldIgnoreRepeater, this.disableRssiFilter = false, + int hopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -176,7 +180,8 @@ class PingService { _discoveryWindowCountdown = discoveryWindowTimer, _deviceId = deviceId, _txTracker = txTracker, - _audioService = audioService; + _audioService = audioService, + _hopBytes = hopBytes; /// Get current ping statistics PingStats get stats => _stats; @@ -943,6 +948,7 @@ class PingService { final tracker = DiscTracker( shouldIgnoreRepeater: shouldIgnoreRepeater, disableRssiFilter: disableRssiFilter, + hopBytes: _hopBytes, ); _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; From e3d907433334d60e4962c49c2b77a9c6fe1a5f3a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 21:55:11 -0400 Subject: [PATCH 010/360] fixed overflow in disc request byte count fix --- lib/widgets/map_widget.dart | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7448452a..70abdab7 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1711,6 +1711,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Compute node column width based on hop byte count. + /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). + double _nodeColumnWidth({double extraPadding = 0}) { + final appState = context.read(); + final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + switch (hopBytes) { + case 2: + return 70 + extraPadding; + case 3: + return 80 + extraPadding; + default: + return 60 + extraPadding; + } + } + /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing @@ -1834,7 +1849,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: 60, + width: _nodeColumnWidth(), child: Text( 'Node', style: TextStyle( @@ -1903,7 +1918,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: 60), + RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -2072,7 +2087,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: 60, + width: _nodeColumnWidth(), child: Text( 'Node', style: TextStyle( @@ -2116,7 +2131,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: 60), + RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -2270,7 +2285,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: 60, + width: _nodeColumnWidth(extraPadding: 20), child: Text( 'Node', style: TextStyle( @@ -2355,7 +2370,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ // Node ID with type SizedBox( - width: 60, + width: _nodeColumnWidth(extraPadding: 20), child: Row( children: [ RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), From 35e28e943c074a634e0621dbec6f9f753d8678b9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 22:03:32 -0400 Subject: [PATCH 011/360] - Added option to keep the #wardriving channel after exiting a session (#149, @Skye) --- lib/models/user_preferences.dart | 12 +++++++++++- lib/providers/app_state_provider.dart | 6 +++++- lib/screens/settings_screen.dart | 11 +++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index a6537994..483d2219 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -73,6 +73,9 @@ class UserPreferences { /// Discovery drop: count failed discoveries as failed pings and report to API final bool discDropEnabled; + /// Delete wardriving channel from radio on disconnect + final bool deleteChannelOnDisconnect; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -98,6 +101,7 @@ class UserPreferences { this.disableRssiFilter = false, this.anonymousMode = false, this.discDropEnabled = false, + this.deleteChannelOnDisconnect = true, }); /// Create from JSON (for persistence) @@ -127,6 +131,7 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, ); } @@ -157,6 +162,7 @@ class UserPreferences { 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, 'discDropEnabled': discDropEnabled, + 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, }; } @@ -186,6 +192,7 @@ class UserPreferences { bool? disableRssiFilter, bool? anonymousMode, bool? discDropEnabled, + bool? deleteChannelOnDisconnect, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -212,6 +219,7 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, + deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, ); } @@ -263,7 +271,8 @@ class UserPreferences { other.mapRotationLocked == mapRotationLocked && other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && - other.discDropEnabled == discDropEnabled; + other.discDropEnabled == discDropEnabled && + other.deleteChannelOnDisconnect == deleteChannelOnDisconnect; } @override @@ -292,6 +301,7 @@ class UserPreferences { disableRssiFilter, anonymousMode, discDropEnabled, + deleteChannelOnDisconnect, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c295f04b..f8c8100d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2240,7 +2240,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Delete wardriving channel FIRST, while BLE connection is still active // This prevents "GATT Server is disconnected" errors - await _meshCoreConnection?.deleteWardrivingChannelEarly(); + if (_preferences.deleteChannelOnDisconnect) { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } else { + debugLog('[CHANNEL] Skipping channel deletion (user preference)'); + } // Cleanup unified RX handler and TX tracker _logRxDataSubscription?.cancel(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7e00f46b..bedbb206 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -257,6 +257,17 @@ class _SettingsScreenState extends State { }, ), + // Delete Channel on Disconnect Toggle + SwitchListTile( + secondary: const Icon(Icons.delete_sweep), + title: const Text('Delete Channel on Disconnect'), + subtitle: const Text('Remove #wardriving channel from radio when disconnecting'), + value: prefs.deleteChannelOnDisconnect, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + }, + ), + // Disable RSSI Filter Toggle SwitchListTile( secondary: const Icon(Icons.shield_outlined), From 2645d73941bc9ddd71ed570657d24ac0e3277075 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 22:14:29 -0400 Subject: [PATCH 012/360] - Minimum ping distance is now configurable for TX and discovery pings. The default 25m floor stays the same, but users can increase it to reduce unnecessary pings in dense city driving or stop-and-go traffic. RX batch flushing distance is unaffected. --- lib/models/user_preferences.dart | 22 +++++++++- lib/providers/app_state_provider.dart | 8 ++++ lib/screens/settings_screen.dart | 59 +++++++++++++++++++++++++++ lib/services/gps_service.dart | 16 +++++++- lib/services/ping_service.dart | 9 ++-- 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 483d2219..706c440c 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -76,6 +76,9 @@ class UserPreferences { /// Delete wardriving channel from radio on disconnect final bool deleteChannelOnDisconnect; + /// Minimum ping distance in meters (25m floor, user can increase) + final int minPingDistanceMeters; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -102,6 +105,7 @@ class UserPreferences { this.anonymousMode = false, this.discDropEnabled = false, this.deleteChannelOnDisconnect = true, + this.minPingDistanceMeters = 25, }); /// Create from JSON (for persistence) @@ -132,6 +136,7 @@ class UserPreferences { anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, ); } @@ -163,6 +168,7 @@ class UserPreferences { 'anonymousMode': anonymousMode, 'discDropEnabled': discDropEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, + 'minPingDistanceMeters': minPingDistanceMeters, }; } @@ -193,6 +199,7 @@ class UserPreferences { bool? anonymousMode, bool? discDropEnabled, bool? deleteChannelOnDisconnect, + int? minPingDistanceMeters, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -220,6 +227,7 @@ class UserPreferences { anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, ); } @@ -245,6 +253,9 @@ class UserPreferences { return '$autoPingInterval seconds'; } + /// Get min ping distance display string + String get minPingDistanceDisplay => '${minPingDistanceMeters}m'; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -272,7 +283,8 @@ class UserPreferences { other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && other.discDropEnabled == discDropEnabled && - other.deleteChannelOnDisconnect == deleteChannelOnDisconnect; + other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && + other.minPingDistanceMeters == minPingDistanceMeters; } @override @@ -302,6 +314,7 @@ class UserPreferences { anonymousMode, discDropEnabled, deleteChannelOnDisconnect, + minPingDistanceMeters, ]); } @@ -336,3 +349,10 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } + +/// Minimum ping distance options (meters) +class MinPingDistance { + static const int min = 25; + + static const List values = [25, 50, 75, 150]; +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f8c8100d..72ec57d0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3182,6 +3182,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Propagate CARpeater prefix to live trackers _syncCarpeaterPrefix(); + // Propagate min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = preferences.minPingDistanceMeters; + notifyListeners(); _savePreferences(); } @@ -4198,6 +4202,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); + + // Apply saved min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = _preferences.minPingDistanceMeters; } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bedbb206..314af8cd 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -147,6 +147,16 @@ class _SettingsScreenState extends State { onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), ), + // Min Ping Distance Selector + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Min Ping Distance'), + subtitle: Text(prefs.minPingDistanceDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + ), + // Hybrid Mode Toggle SwitchListTile( secondary: const Icon(Icons.compare_arrows), @@ -1211,6 +1221,55 @@ class _SettingsScreenState extends State { ); } + void _showDistanceSelector(BuildContext context, AppStateProvider appState) { + final currentDistance = appState.preferences.minPingDistanceMeters; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Min Ping Distance'), + content: RadioGroup( + groupValue: currentDistance, + onChanged: (value) { + if (value != null) { + appState.updatePreferences( + appState.preferences.copyWith(minPingDistanceMeters: value), + ); + Navigator.pop(context); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: MinPingDistance.values.map((distance) { + String description; + if (distance == 25) { + description = 'Default'; + } else if (distance == 50) { + description = 'City driving'; + } else if (distance == 75) { + description = 'Highway'; + } else { + description = 'Long range'; + } + + return RadioListTile( + title: Text('${distance}m'), + subtitle: Text(description), + value: distance, + ); + }).toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + } + void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { const maxHexChars = 6; const hintText = 'FFFFFF'; diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index a6c7084e..753c67c2 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -28,6 +28,18 @@ class GpsService { /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; + /// Configured minimum ping distance (user-adjustable, clamped to minDistanceMeters floor) + double _configuredMinDistance = minDistanceMeters; + + /// Get the configured minimum ping distance + double get configuredMinDistance => _configuredMinDistance; + + /// Set the minimum ping distance (clamped to 25m floor) + void setMinPingDistance(double meters) { + _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + } + final _statusController = StreamController.broadcast(); final _positionController = StreamController.broadcast(); @@ -274,10 +286,10 @@ class GpsService { ); } - /// Check if current position is far enough from last ping (25m minimum) + /// Check if current position is far enough from last ping bool canPingAtPosition(Position position) { if (_lastPingPosition == null) return true; - return distanceFromLastPing(position) >= minDistanceMeters; + return distanceFromLastPing(position) >= _configuredMinDistance; } /// Mark current position as ping location diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index cb9f10d5..cee2547e 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -48,6 +48,9 @@ class PingService { /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); + /// Current configured min ping distance (for validation messages) + static int currentMinDistance = 25; + final GpsService _gpsService; final MeshCoreConnection _connection; final ApiQueueService _apiQueue; @@ -1029,8 +1032,8 @@ class PingService { position.latitude, position.longitude, ); - if (distance < GpsService.minDistanceMeters) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < 25m), skipping'); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1314,7 +1317,7 @@ extension PingValidationExtension on PingValidation { case PingValidation.outsideGeofence: return 'Outside service area'; case PingValidation.tooCloseToLastPing: - return 'Move 25m before next ping'; + return 'Move ${PingService.currentMinDistance}m before next ping'; case PingValidation.cooldownActive: return 'Wait 5 seconds between pings'; case PingValidation.manualCooldownActive: From d451beeb3cbb13a467f4a3d4e1f3591d684a23dd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 22:21:22 -0400 Subject: [PATCH 013/360] - GPS-related zone check errors (inaccurate/stale) no longer retry every 5 seconds or auto-switch to the error tab. The next zone check waits until the user moves 100m instead of hammering retries while stationary. The zone status bar now shows a GPS icon with "GPS Unavailable" in orange, and the full-screen error view displays a specific title and message with a manual "Retry Zone Check" button. --- lib/providers/app_state_provider.dart | 12 ++++--- lib/screens/connection_screen.dart | 37 ++++++++++++++++++- lib/screens/settings_screen.dart | 52 +++++++++++---------------- 3 files changed, 65 insertions(+), 36 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 72ec57d0..e6753820 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3702,11 +3702,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { - logError('GPS Accuracy Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_inaccurate'); + logError('GPS Accuracy Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_inaccurate'; + notifyListeners(); } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_stale'); + logError('GPS Stale Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_stale'; + notifyListeners(); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index bfa16f99..2610f908 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -998,6 +998,12 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.wifi_off; locationText = 'No Internet'; locationColor = Colors.red; + // GPS error: show GPS issue indicator + } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + locationIcon = Icons.gps_off; + locationText = 'GPS Unavailable'; + locationColor = Colors.orange; // Show "Checking Zone..." whenever a zone check is in progress // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { @@ -1393,7 +1399,36 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Non-network errors — show simple error with countdown + // GPS errors — no auto-retry, show manual retry button + if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: appState.zoneCheckErrorReason == 'gps_inaccurate' + ? 'GPS Accuracy Error' + : 'GPS Stale Error', + message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + action: FilledButton.icon( + onPressed: () => appState.checkZoneStatus(), + icon: const Icon(Icons.refresh), + label: const Text('Retry Zone Check'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ), + ), + ], + ); + } + + // Other non-network errors — show simple error with countdown return Column( children: [ _buildZoneStatusBar(context, appState), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 314af8cd..85de123d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1223,41 +1223,19 @@ class _SettingsScreenState extends State { void _showDistanceSelector(BuildContext context, AppStateProvider appState) { final currentDistance = appState.preferences.minPingDistanceMeters; + final controller = TextEditingController(text: currentDistance.toString()); showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Select Min Ping Distance'), - content: RadioGroup( - groupValue: currentDistance, - onChanged: (value) { - if (value != null) { - appState.updatePreferences( - appState.preferences.copyWith(minPingDistanceMeters: value), - ); - Navigator.pop(context); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: MinPingDistance.values.map((distance) { - String description; - if (distance == 25) { - description = 'Default'; - } else if (distance == 50) { - description = 'City driving'; - } else if (distance == 75) { - description = 'Highway'; - } else { - description = 'Long range'; - } - - return RadioListTile( - title: Text('${distance}m'), - subtitle: Text(description), - value: distance, - ); - }).toList(), + title: const Text('Min Ping Distance'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + suffixText: 'meters', + helperText: 'Minimum ${MinPingDistance.min}m', ), ), actions: [ @@ -1265,6 +1243,18 @@ class _SettingsScreenState extends State { onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), + FilledButton( + onPressed: () { + final value = int.tryParse(controller.text); + if (value != null && value >= MinPingDistance.min) { + appState.updatePreferences( + appState.preferences.copyWith(minPingDistanceMeters: value), + ); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), ], ), ); From fb2d4c353a381e4ec9b4490b5217d8e2420378c2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 22:21:58 -0400 Subject: [PATCH 014/360] =?UTF-8?q?=20=20lib/screens/settings=5Fscreen.dar?= =?UTF-8?q?t=20=E2=80=94=20Replaced=20the=20radio=20button=20dialog=20with?= =?UTF-8?q?=20a=20text=20field.=20Users=20type=20any=20number=20they=20wan?= =?UTF-8?q?t=20(minimum=2025m=20enforced).=20The=20field=20shows=20=20=20"?= =?UTF-8?q?meters"=20as=20a=20suffix=20and=20"Minimum=2025m"=20as=20helper?= =?UTF-8?q?=20text.=20Save=20button=20only=20accepts=20valid=20integers=20?= =?UTF-8?q?>=3D=2025.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/user_preferences.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 706c440c..c5640203 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -350,9 +350,7 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } -/// Minimum ping distance options (meters) +/// Minimum ping distance (meters) class MinPingDistance { static const int min = 25; - - static const List values = [25, 50, 75, 150]; } From d2fddb599efb06fa5a35450387e0a0f555c1648c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 14 Mar 2026 22:24:12 -0400 Subject: [PATCH 015/360] - Hybrid mode now defaults to enabled for new users and existing users who haven't explicitly toggled it off. Saved preferences are preserved. --- lib/models/user_preferences.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index c5640203..b37b57ac 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -97,7 +97,7 @@ class UserPreferences { this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', - this.hybridModeEnabled = false, + this.hybridModeEnabled = true, this.mapAutoFollow = false, this.mapAlwaysNorth = true, this.mapRotationLocked = false, @@ -128,7 +128,7 @@ class UserPreferences { closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', - hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? false, + hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, mapAutoFollow: (json['mapAutoFollow'] as bool?) ?? false, mapAlwaysNorth: (json['mapAlwaysNorth'] as bool?) ?? true, mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, From ee06b4f896484d57792fa3b1f8d25d44dccf39ae Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 13:43:00 -0400 Subject: [PATCH 016/360] Implmented Trace Mode --- lib/models/api_queue_item.dart | 44 ++ lib/models/log_entry.dart | 55 ++ lib/models/noise_floor_session.dart | 11 + lib/models/ping_data.dart | 4 + lib/providers/app_state_provider.dart | 101 ++- lib/screens/connection_screen.dart | 49 ++ lib/screens/home_screen.dart | 18 +- lib/screens/log_screen.dart | 362 +++++++++- lib/screens/settings_screen.dart | 9 + lib/services/api_queue_service.dart | 47 ++ lib/services/background_service.dart | 2 + lib/services/meshcore/connection.dart | 41 ++ lib/services/meshcore/packet_metadata.dart | 6 + lib/services/meshcore/trace_tracker.dart | 212 ++++++ lib/services/meshcore/unified_rx_handler.dart | 19 +- lib/services/ping_service.dart | 285 +++++++- lib/widgets/map_widget.dart | 297 +++++++++ lib/widgets/noise_floor_chart.dart | 7 +- lib/widgets/offline_mode_toggle.dart | 198 ++++++ lib/widgets/ping_controls.dart | 623 ++++++++---------- 20 files changed, 1990 insertions(+), 400 deletions(-) create mode 100644 lib/services/meshcore/trace_tracker.dart create mode 100644 lib/widgets/offline_mode_toggle.dart diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 57145e96..337cab1e 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -138,6 +138,32 @@ class ApiQueueItem extends HiveObject { ); } + /// Create from a successful TRACE ping (targeted zero-hop trace) + /// heardRepeats format: "repeaterId:localSnr:localRssi:remoteSnr" + factory ApiQueueItem.fromTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + return ApiQueueItem( + type: 'TRACE', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: heardRepeats, + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + /// Create from a failed DISC discovery (no nodes responded) factory ApiQueueItem.fromDiscDrop({ required double latitude, @@ -160,6 +186,24 @@ class ApiQueueItem extends HiveObject { /// Convert to API JSON format (matches WebClient exactly) Map toApiJson() { + // For TRACE type, parse the heardRepeats field to extract individual values + if (type == 'TRACE') { + // Format: "repeaterId:localSnr:localRssi:remoteSnr" + final parts = heardRepeats.split(':'); + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': parts.isNotEmpty ? parts[0] : '', + 'local_snr': parts.length > 1 ? double.tryParse(parts[1]) : null, + 'local_rssi': parts.length > 2 ? int.tryParse(parts[2]) : null, + 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // For DISC type, parse the heardRepeats field to extract individual values if (type == 'DISC') { // Failed discovery (no nodes responded) diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index b4b81a09..f0a468da 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -119,6 +119,61 @@ class RxLogEntry { } } +/// Trace Log Entry (targeted zero-hop trace result) +class TraceLogEntry { + final DateTime timestamp; + final double latitude; + final double longitude; + final String targetRepeaterId; + final int? noiseFloor; + final double? localSnr; + final double? remoteSnr; + final int? localRssi; + final bool success; + + TraceLogEntry({ + required this.timestamp, + required this.latitude, + required this.longitude, + required this.targetRepeaterId, + this.noiseFloor, + this.localSnr, + this.remoteSnr, + this.localRssi, + required this.success, + }); + + /// Get formatted timestamp (HH:MM:SS) + String get timeString { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + } + + /// Get formatted location (5 decimal places) + String get locationString { + return '${latitude.toStringAsFixed(5)},${longitude.toStringAsFixed(5)}'; + } + + /// Get SNR color severity based on local SNR + SnrSeverity? get severity { + if (localSnr == null) return null; + if (localSnr! <= -1) { + return SnrSeverity.poor; + } else if (localSnr! <= 5) { + return SnrSeverity.fair; + } else { + return SnrSeverity.good; + } + } + + /// Get CSV row + String toCsv() { + return '${timestamp.toIso8601String()},$targetRepeaterId,${localSnr ?? 'null'},${localRssi ?? 'null'},' + '${remoteSnr ?? 'null'},$latitude,$longitude,${noiseFloor ?? ''},$success'; + } +} + /// SNR Severity levels for color coding enum SnrSeverity { poor, // Red: SNR ≤ -1 dB diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index a9212162..a1f1fe92 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -35,6 +35,12 @@ enum PingEventType { @HiveField(4) discFail, // Grey: Discovery no response + + @HiveField(5) + traceSuccess, // Cyan: Trace got response + + @HiveField(6) + traceFail, // Red: Trace no response } /// Repeater info for graph markers @@ -99,6 +105,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => Colors.blue, PingEventType.discSuccess => Colors.purple, PingEventType.discFail => Colors.grey, + PingEventType.traceSuccess => Colors.cyan, + PingEventType.traceFail => Colors.red, }; /// Get a display label for this event type @@ -108,6 +116,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => 'RX', PingEventType.discSuccess => 'DISC Success', PingEventType.discFail => 'DISC Fail', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Fail', }; } @@ -154,6 +164,7 @@ class NoiseFloorSession extends HiveObject { String get modeDisplay => switch (mode) { 'active' => 'Active Mode', 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', _ => 'Passive Mode', }; diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index d9d52c8a..0e42d087 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -139,6 +139,7 @@ class PingStats { final int txCount; final int rxCount; final int discCount; // Discovery count (Passive Mode) + final int traceCount; // Trace count (Targeted Mode) final int successfulUploads; final int failedUploads; final int queuedCount; @@ -147,6 +148,7 @@ class PingStats { this.txCount = 0, this.rxCount = 0, this.discCount = 0, + this.traceCount = 0, this.successfulUploads = 0, this.failedUploads = 0, this.queuedCount = 0, @@ -156,6 +158,7 @@ class PingStats { int? txCount, int? rxCount, int? discCount, + int? traceCount, int? successfulUploads, int? failedUploads, int? queuedCount, @@ -164,6 +167,7 @@ class PingStats { txCount: txCount ?? this.txCount, rxCount: rxCount ?? this.rxCount, discCount: discCount ?? this.discCount, + traceCount: traceCount ?? this.traceCount, successfulUploads: successfulUploads ?? this.successfulUploads, failedUploads: failedUploads ?? this.failedUploads, queuedCount: queuedCount ?? this.queuedCount, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e6753820..af7c317c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -48,6 +48,8 @@ enum AutoMode { passive, /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater + targeted, } /// Result of uploading an offline session @@ -158,6 +160,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _txLogEntries = []; final List _rxLogEntries = []; final List _discLogEntries = []; + final List _traceLogEntries = []; + + // Targeted mode state + String? _targetRepeaterId; // User error log entries final List _errorLogEntries = []; @@ -308,6 +314,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isPendingDisable => _pingService?.pendingDisable ?? false; /// True when running any mode that does TX (Active or Hybrid) bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) + bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; int? get currentBatteryPercent => _currentBatteryPercent; @@ -318,6 +327,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); + List get traceLogEntries => List.unmodifiable(_traceLogEntries); List get errorLogEntries => List.unmodifiable(_errorLogEntries); ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; @@ -493,7 +503,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1178,6 +1189,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }, ); + // Wire UnifiedRxHandler so trace payloads route to TraceTracker + _pingService!.unifiedRxHandler = _unifiedRxHandler; + // Set validation callbacks _pingService!.checkExternalAntennaConfigured = () { // External antenna must be explicitly set (yes or no) before pinging @@ -1251,7 +1265,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1404,6 +1419,53 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); }; + // Wire up trace ping callback (for log entry creation) + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; + + // Wire up trace window complete callback for noise floor graph + _pingService!.onTraceWindowComplete = (result) { + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + )]; + // Update the log entry with success data + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + notifyListeners(); + } + } + + recordPingEvent( + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; + // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); @@ -2368,14 +2430,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Toggle auto-ping mode (Active, Passive, or Hybrid) - /// Returns false if blocked by cooldown (Active/Hybrid Mode only - Passive Mode ignores cooldown) + /// Set the target repeater ID for targeted mode + void setTargetRepeaterId(String? id) { + _targetRepeaterId = id; + notifyListeners(); + } + + /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) + /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) Future toggleAutoPing(AutoMode mode) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; final isHybrid = mode == AutoMode.hybrid; - final isTxMode = !isPassive; // Active and Hybrid both do TX + final isTargeted = mode == AutoMode.targeted; + final isTxMode = !isPassive; // Active, Hybrid, and Targeted all do TX // If currently running the same mode, stop it (always allow stopping) if (_autoPingEnabled && _autoMode == mode) { @@ -2470,7 +2539,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.setAutoPingInterval(intervalMs); debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); - final started = await _pingService!.enableAutoPing(passiveMode: isPassive, hybridMode: isHybrid); + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { @@ -2486,7 +2560,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingEnabled = true; // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active'; + final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -2506,7 +2580,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : 'Active Mode'; + final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -2532,6 +2606,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txLogEntries.clear(); _rxLogEntries.clear(); _discLogEntries.clear(); + _traceLogEntries.clear(); _errorLogEntries.clear(); notifyListeners(); } @@ -2546,6 +2621,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Add a trace log entry (from Trace Mode) + void _addTraceLogEntry(TraceLogEntry entry) { + _traceLogEntries.insert(0, entry); + if (_traceLogEntries.length > _maxLogEntries) { + _traceLogEntries.removeLast(); + } + debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + notifyListeners(); + } + /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 2610f908..c0581d68 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,7 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; /// BLE device selection and connection screen @@ -394,6 +395,28 @@ class _ConnectionScreenState extends State with WidgetsBinding // Portrait: vertical layout return Column( children: [ + _buildZoneStatusBar(context, appState), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 4), + IconButton( + icon: Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: 'About Offline Mode', + onPressed: () => _showOfflineModeInfo(context), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ), Expanded( child: ListView( padding: const EdgeInsets.all(16), @@ -564,6 +587,32 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } + void _showOfflineModeInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.cloud_off, color: Colors.orange, size: 22), + SizedBox(width: 8), + Text('Offline Mode'), + ], + ), + content: const Text( + 'Save pings locally instead of uploading immediately. Useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'Data is stored on your device and can be uploaded later from the Settings tab when connectivity is restored.', + style: TextStyle(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showAuthMethodInfo(BuildContext context, String currentType) { showModalBottomSheet( context: context, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 07711e64..6050b5cc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -880,20 +880,12 @@ class _HomeScreenState extends State { description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), - // Offline mode toggle + // Trace Mode _buildHelpItem( - icon: Icons.cloud_off, - color: Colors.orange, - title: 'Offline Mode', - description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.', - ), - - // Sound toggle - _buildHelpItem( - icon: Icons.volume_up, - color: Colors.blue, - title: 'Sound', - description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.', + icon: Icons.gps_fixed, + color: Colors.cyan, + title: 'Trace Mode', + description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 9519772f..c8ab642b 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -20,7 +20,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 5, vsync: this); } @override @@ -36,8 +36,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 3) { - _tabController.animateTo(3); // Switch to Error tab + if (mounted && _tabController.index != 4) { + _tabController.animateTo(4); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); @@ -78,12 +78,14 @@ class _LogScreenState extends State with SingleTickerProviderStateMix child: Row( children: [ Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), - const SizedBox(width: 8), + const SizedBox(width: 6), Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), - const SizedBox(width: 8), + const SizedBox(width: 6), Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(3, 'Errors', appState.errorLogEntries.length, isError: true)), + const SizedBox(width: 6), + Expanded(child: _buildTabChip(3, 'TRC', appState.traceLogEntries.length, isTrace: true)), + const SizedBox(width: 6), + Expanded(child: _buildTabChip(4, 'Err', appState.errorLogEntries.length, isError: true)), ], ), ), @@ -95,6 +97,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix _TxLogTab(entries: appState.txLogEntries), _RxLogTab(entries: appState.rxLogEntries), _DiscLogTab(entries: appState.discLogEntries), + _TraceLogTab(entries: appState.traceLogEntries), _ErrorLogTab(entries: appState.errorLogEntries), ], ), @@ -105,12 +108,13 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } /// Build a tab chip that matches StatusBar chip styling - Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false}) { + Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false, bool isTrace = false}) { final theme = Theme.of(context); // Colors matching status bar chips const discColor = Color(0xFF7B68EE); // DISC purple const txColor = Colors.green; // TX green (matches status bar) const rxColor = Colors.blue; // RX blue (matches status bar) + const traceColor = Colors.cyan; // TRC cyan (matches noise floor chart) return GestureDetector( onTap: () { @@ -127,6 +131,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix currentColor = Colors.red; } else if (isDisc) { currentColor = discColor; + } else if (isTrace) { + currentColor = traceColor; } else if (isTx) { currentColor = txColor; } else if (isRx) { @@ -150,12 +156,16 @@ class _LogScreenState extends State with SingleTickerProviderStateMix child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, - color: currentColor, + Flexible( + child: Text( + label, + overflow: TextOverflow.clip, + softWrap: false, + style: TextStyle( + fontSize: 12, + fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, + color: currentColor, + ), ), ), if (count > 0) ...[ @@ -197,7 +207,10 @@ class _LogScreenState extends State with SingleTickerProviderStateMix case 2: // DISC Log _copyDiscLogToCsv(context, appState.discLogEntries); break; - case 3: // Error Log + case 3: // TRC Log + _copyTraceLogToCsv(context, appState.traceLogEntries); + break; + case 4: // Error Log _copyErrorLogToCsv(context, appState.errorLogEntries); break; } @@ -260,6 +273,25 @@ class _LogScreenState extends State with SingleTickerProviderStateMix ); } + void _copyTraceLogToCsv(BuildContext context, List entries) { + if (entries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No trace log entries to copy'), duration: Duration(seconds: 2)), + ); + return; + } + + final buffer = StringBuffer(); + buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + for (final entry in entries) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Trace log copied to clipboard'), duration: Duration(seconds: 2)), + ); + } + void _copyErrorLogToCsv(BuildContext context, List entries) { if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -284,7 +316,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, and error logs.'), + content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -1063,6 +1095,304 @@ class _DiscLogTab extends StatelessWidget { } } +/// Trace Log Tab (Trace Mode results) +class _TraceLogTab extends StatelessWidget { + final List entries; + + const _TraceLogTab({required this.entries}); + + @override + Widget build(BuildContext context) { + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.gps_fixed, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3)), + const SizedBox(height: 16), + Text( + 'No trace results yet', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Enter a repeater ID and start Trace Mode', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: entries.length, + itemBuilder: (context, index) { + return _buildTraceEntry(context, entries[index]); + }, + ); + } + + Widget _buildTraceEntry(BuildContext context, TraceLogEntry entry) { + final colorScheme = Theme.of(context).colorScheme; + final appState = context.read(); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () { + appState.navigateToMapCoordinates(entry.latitude, entry.longitude); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Time and repeater ID (matching disc style) + Row( + children: [ + // Time badge (neutral, matching disc) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + entry.timeString, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: colorScheme.onSurface, + ), + ), + ), + const Spacer(), + // Repeater ID + success/fail text + Text( + entry.targetRepeaterId, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Text( + entry.success ? 'responded' : 'no response', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], + ), + const SizedBox(height: 8), + + // Location + Row( + children: [ + Icon(Icons.location_on, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + entry.locationString, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], + ), + + // Results table (matching disc style) + if (entry.success) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 50, + child: Text( + 'Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + _buildTraceNodeRow(context, entry), + ], + ), + ), + ] else ...[ + const SizedBox(height: 8), + Text( + 'No response', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + ), + ); + } + + /// Build the data row for a trace result (matching disc node row style) + Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + // RX SNR color + final rxSnr = entry.localSnr; + Color rxSnrColor; + if (rxSnr == null) { + rxSnrColor = Colors.grey; + } else if (rxSnr <= -1) { + rxSnrColor = Colors.red; + } else if (rxSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + // RSSI color + final rssi = entry.localRssi; + Color rssiColor; + if (rssi == null) { + rssiColor = Colors.grey; + } else if (rssi >= -70) { + rssiColor = Colors.green; + } else if (rssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + // TX SNR color (remote) + final txSnr = entry.remoteSnr; + Color txSnrColor; + if (txSnr == null) { + txSnrColor = Colors.grey; + } else if (txSnr <= -1) { + txSnrColor = Colors.red; + } else if (txSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + SizedBox( + width: 50, + child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 11), + ), + Expanded( + child: Center( + child: _buildChip(rxSnr?.toStringAsFixed(1) ?? '-', rxSnrColor), + ), + ), + Expanded( + child: Center( + child: _buildChip(rssi != null ? '$rssi' : '-', rssiColor), + ), + ), + Expanded( + child: Center( + child: _buildChip(txSnr?.toStringAsFixed(1) ?? '-', txSnrColor), + ), + ), + ], + ), + ); + } + + /// Build a small colored chip for table cells (matching disc style) + Widget _buildChip(String value, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + value, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + fontFamily: 'monospace', + ), + ), + ); + } +} + /// Error Log Tab class _ErrorLogTab extends StatelessWidget { final List entries; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 85de123d..8913478e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -137,6 +137,15 @@ class _SettingsScreenState extends State { // Wardriving Settings section _buildSectionHeader(context, 'Wardriving Settings'), + // Sound Notifications Toggle + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + // Auto-Ping Interval Selector ListTile( leading: const Icon(Icons.timer), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index b03ab9ff..613fc5b3 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -347,6 +347,53 @@ class ApiQueueService { }); } + /// Enqueue a TRACE ping result (targeted zero-hop trace) + Future enqueueTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromTrace( + latitude: latitude, + longitude: longitude, + repeaterId: repeaterId, + localSnr: localSnr, + localRssi: localRssi, + remoteSnr: remoteSnr, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] TRACE enqueued (offline): $repeaterId'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + /// Enqueue a failed DISC discovery (no nodes responded) Future enqueueDiscDrop({ required double latitude, diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 49a110c0..3ff0d174 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -249,6 +249,8 @@ class BackgroundServiceManager { String body; if (mode == 'Passive Mode') { body = 'RX: $rxCount | Queue: $queueSize'; + } else if (mode == 'Trace Mode') { + body = 'Trace: $txCount | RX: $rxCount | Queue: $queueSize'; } else { body = 'TX: $txCount | RX: $rxCount | Queue: $queueSize'; } diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 5594b2f9..f3fdacaf 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -68,6 +68,7 @@ class MeshCoreConnection { final _rawDataController = StreamController>.broadcast(); final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -122,6 +123,10 @@ class MeshCoreConnection { /// Stream of ControlData packets (for discovery responses) Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + /// Stream of TraceData packets (for trace path responses) + /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly + Stream get traceDataStream => _traceDataController.stream; + /// Stream of noise floor updates (dBm) Stream get noiseFloorStream => _noiseFloorController.stream; @@ -401,6 +406,9 @@ class MeshCoreConnection { case PushCodes.controlData: _onControlDataPush(reader); break; + case PushCodes.traceData: + _onTraceDataPush(reader); + break; case ResponseCodes.stats: _onStatsResponse(reader); break; @@ -609,6 +617,17 @@ class MeshCoreConnection { _controlDataController.add((raw: raw, snr: snr, rssi: rssi)); } + void _onTraceDataPush(BufferReader reader) { + // 0x89 TraceData has NO snr/rssi prefix (unlike 0x88 LogRxData). + // The entire remaining payload is the trace response: + // [reserved][path_len][flags][tag:4][auth:4][path_hashes][path_snrs] + final raw = reader.readRemainingBytes(); + + debugLog('[CONN] Received trace data: ${raw.length} bytes'); + + _traceDataController.add(raw); + } + void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // @@ -955,6 +974,27 @@ class MeshCoreConnection { return tag; } + /// Send trace path to a specific repeater (targeted ping / zero-hop trace) + /// Returns the 4-byte tag used for matching the response + Future sendTracePath(Uint8List repeaterIdBytes) async { + final random = Random.secure(); + final tag = Uint8List.fromList([ + random.nextInt(256), random.nextInt(256), + random.nextInt(256), random.nextInt(256), + ]); + + debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")}'); + + final data = BufferWriter(); + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(0); // flags = 0 + data.writeBytes(repeaterIdBytes); // target repeater ID + await _sendToRadio(data); + return tag; + } + /// Get battery voltage Future getBatteryVoltage() async { final data = BufferWriter(); @@ -1108,6 +1148,7 @@ class MeshCoreConnection { _rawDataController.close(); _logRxDataController.close(); _controlDataController.close(); + _traceDataController.close(); _noiseFloorController.close(); _batteryController.close(); } diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index a6dcc0a5..c65a6ba1 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -162,6 +162,12 @@ class PacketMetadata { return payloadType == PayloadType.advert; } + /// Check if packet is TRACE (trace path response, header 0x26) + bool get isTrace { + final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + return payloadType == PayloadType.trace; + } + /// Get first hop as hex string (for TX tracking keys) /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get firstHopHex { diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart new file mode 100644 index 00000000..09c13582 --- /dev/null +++ b/lib/services/meshcore/trace_tracker.dart @@ -0,0 +1,212 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Result of a trace path probe to a specific repeater +class TraceResult { + final String targetRepeaterId; + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final bool success; + + const TraceResult({ + required this.targetRepeaterId, + required this.localSnr, + required this.localRssi, + required this.remoteSnr, + required this.success, + }); +} + +/// Trace path tracker for targeted ping (zero-hop trace) +/// Sends CMD_SEND_TRACE_PATH (0x24) to a specific repeater and listens +/// for the trace response (PUSH_CODE_TRACE_DATA, 0x89). +/// +/// Follows the DiscTracker pattern but simpler: expects exactly 1 response. +class TraceTracker { + bool isListening = false; + Uint8List? _expectedTag; + String _targetRepeaterId = ''; + TraceResult? _result; + Timer? _windowTimer; + + /// BLE metadata from the 0x88 LogRxData event that arrives before the 0x89 TraceData. + /// Set by UnifiedRxHandler when it sees a trace packet in the LogRxData stream. + double pendingBleSnr = 0.0; + int pendingBleRssi = 0; + + /// Fired when a trace response is received during the window + void Function(TraceResult)? onTraceReceived; + + /// Fired when the listening window ends (result is null if no response) + void Function(TraceResult?)? onWindowComplete; + + TraceTracker(); + + /// Start tracking trace responses + void startTracking({ + required Uint8List tag, + required String targetRepeaterId, + int hopBytes = 1, + Duration windowDuration = const Duration(seconds: 7), + }) { + debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); + debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + + isListening = true; + _expectedTag = tag; + _targetRepeaterId = targetRepeaterId; + _result = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + // Start window timer + _windowTimer?.cancel(); + _windowTimer = Timer(windowDuration, _endWindow); + + debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + } + + /// Handle incoming trace data packet (0x89) + /// Returns true if the packet was a valid trace response + /// + /// Trace response format (per meshcore_py reference): + /// Byte 0: reserved (skip) + /// Byte 1: path_len (raw byte count of path hashes, NOT LogRxData encoding) + /// Byte 2: flags → hashSize = 1 << (flags & 3) + /// Bytes 3-6: tag → match against _expectedTag + /// Bytes 7-10: auth_code (skip) + /// Bytes 11 to 11+path_len: path_hashes → extract repeater ID + /// Next hopCount+1 bytes: path_snrs → each is signedInt8 / 4.0 for dB + /// For zero-hop (hopCount=1): remoteSnr = snrs[0], localSnr = snrs[1] + bool handlePacket(Uint8List rawBytes, double bleSnr, int bleRssi) { + if (!isListening) return false; + + try { + // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes + if (rawBytes.length < 11) { + debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + return false; + } + + // Skip byte 0 (reserved) + final pathLen = rawBytes[1]; // Raw byte count of path hashes + final flags = rawBytes[2]; + + // Decode per 0x89 trace format (meshcore_py reference): + // hash size from flags, hop count from path_len / hash_size + final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop + final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; + + debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + + // Extract tag (bytes 3-6) + final tag = rawBytes.sublist(3, 7); + + // Match tag against expected + final expectedTag = _expectedTag; + if (expectedTag != null) { + bool tagMatch = true; + for (int i = 0; i < 4; i++) { + if (tag[i] != expectedTag[i]) { + tagMatch = false; + break; + } + } + if (!tagMatch) { + debugLog('[TRACE] Tag mismatch, ignoring packet'); + return false; + } + } + + // Skip auth_code (bytes 7-10) + + // Extract path hashes (bytes 11 to 11 + hopCount*hashSize) + const pathStart = 11; + final pathEnd = pathStart + (hopCount * hashSize); + + if (rawBytes.length < pathEnd) { + debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + return false; + } + + // Extract repeater ID from first hop in path + String repeaterId = ''; + if (hopCount > 0) { + final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); + repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + // Extract path SNRs (hopCount+1 bytes after path hashes) + final snrStart = pathEnd; + final snrEnd = snrStart + hopCount + 1; + + double remoteSnr = 0.0; + double localSnr = bleSnr; // Default to BLE metadata SNR + + if (rawBytes.length >= snrEnd && hopCount >= 1) { + // Each SNR byte is signed int8, divide by 4.0 for dB + remoteSnr = rawBytes[snrStart].toSigned(8) / 4.0; + if (hopCount + 1 >= 2) { + localSnr = rawBytes[snrStart + 1].toSigned(8) / 4.0; + } + } + + debugLog('[TRACE] Trace response from $repeaterId: ' + 'localSnr=${localSnr.toStringAsFixed(2)}, ' + 'remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' + 'bleRssi=$bleRssi'); + + _result = TraceResult( + targetRepeaterId: _targetRepeaterId, + localSnr: localSnr, + localRssi: bleRssi, + remoteSnr: remoteSnr, + success: true, + ); + + // Notify callback + onTraceReceived?.call(_result!); + + return true; + } catch (e, stackTrace) { + debugError('[TRACE] Error processing trace response: $e'); + debugError('[TRACE] Stack trace: $stackTrace'); + return false; + } + } + + /// Stop tracking and return result + TraceResult? stopTracking() { + debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + + final result = _result; + isListening = false; + _windowTimer?.cancel(); + _windowTimer = null; + _expectedTag = null; + + return result; + } + + /// Handle trace window completion + void _endWindow() { + debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + + final result = _result; + isListening = false; + _windowTimer = null; + _expectedTag = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + onWindowComplete?.call(result); + } + + /// Dispose of resources + void dispose() { + stopTracking(); + } +} diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 6e92b74d..1c95dd2c 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -4,6 +4,7 @@ import '../../utils/debug_logger_io.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; import 'rx_logger.dart'; +import 'trace_tracker.dart'; import 'tx_tracker.dart'; /// Unified RX handler - orchestrates TX echo tracking and passive RX logging @@ -18,6 +19,9 @@ class UnifiedRxHandler { /// Get current validator PacketValidator get validator => _validator; + /// Trace tracker for targeted ping mode (set by PingService when active) + TraceTracker? traceTracker; + /// Channel key for message decryption (injected for TX validation) Uint8List? channelKey; @@ -72,7 +76,20 @@ class UnifiedRxHandler { debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); - + + // Store BLE metadata from 0x88 LogRxData for trace packets. + // The actual trace payload arrives separately via 0x89 TraceData stream. + // We only store RSSI/SNR here — the 0x89 handler combines them. + if (metadata.isTrace) { + final tt = traceTracker; + if (tt != null && tt.isListening) { + debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + tt.pendingBleSnr = metadata.snr; + tt.pendingBleRssi = metadata.rssi; + } + return; // Trace packets don't go to TX tracker or RX logger + } + // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index cee2547e..58544685 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:geolocator/geolocator.dart'; @@ -12,7 +13,9 @@ import 'countdown_timer_service.dart'; import 'gps_service.dart'; import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; +import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; /// Ping service for TX/RX ping orchestration @@ -70,6 +73,9 @@ class PingService { /// When true, skip RSSI carpeater check in DiscTracker (user setting) bool disableRssiFilter; + /// Unified RX handler reference for routing trace packets + UnifiedRxHandler? unifiedRxHandler; + PingStats _stats = const PingStats(); DateTime? _lastTxTime; Timer? _rxWindowTimer; @@ -86,9 +92,17 @@ class PingService { bool _autoPingEnabled = false; bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; + bool _targetedModeEnabled = false; bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; + // Targeted mode tracking + TraceTracker? _traceTracker; + StreamSubscription? _traceDataSubscription; + Timer? _targetedTimer; + String? _targetRepeaterId; + Position? _lastTargetedPosition; + // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; @@ -144,6 +158,13 @@ class PingService { /// Parameters: (bool success) - true if any nodes discovered, false if none void Function(bool success)? onDiscoveryWindowComplete; + /// Callback when trace window ends (for noise floor graph + log) + /// Parameters: (TraceResult? result) - null if no response + void Function(TraceResult? result)? onTraceWindowComplete; + + /// Callback when trace ping is sent (for log entry creation) + void Function(TraceLogEntry)? onTracePing; + /// Callback when pingInProgress changes (for immediate UI refresh) void Function()? onPingProgressChanged; @@ -201,6 +222,9 @@ class PingService { /// Check if Hybrid Mode is active (alternates discovery + TX) bool get isHybridMode => _hybridModeEnabled; + /// Check if Targeted Mode is active (zero-hop trace to specific repeater) + bool get isTargetedMode => _targetedModeEnabled; + /// Check if discovery tracker is currently listening (for Passive Mode UI) bool get isDiscoveryListening => _discTracker?.isListening ?? false; @@ -707,9 +731,11 @@ class PingService { debugLog('[PING] Executing pending disable after RX window'); _pendingDisable = false; final wasHybrid = _hybridModeEnabled; + final wasTargeted = _targetedModeEnabled; _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _autoTimer?.cancel(); _autoTimer = null; @@ -717,6 +743,10 @@ class PingService { if (wasHybrid) { _stopDiscoveryMode(); } + // Clean up targeted infrastructure if targeted was enabled + if (wasTargeted) { + _stopTargetedMode(); + } // Start cooldown immediately _cooldownTimer.start(_autoPingCooldown.inMilliseconds); debugLog('[PING] Pending disable complete, cooldown started'); @@ -809,20 +839,34 @@ class PingService { } } - /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), or Hybrid Mode + /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), + /// Hybrid Mode, or Targeted Mode (zero-hop trace) /// Reference: startAutoPing() in wardrive.js /// @param passiveMode - If true, only listens for RX (no TX pings) - this is Passive Mode /// @param hybridMode - If true, alternates discovery + TX pings each interval - Future enableAutoPing({bool passiveMode = false, bool hybridMode = false}) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode)'); + /// @param targetedMode - If true, sends trace path to specific repeater + /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) + Future enableAutoPing({ + bool passiveMode = false, + bool hybridMode = false, + bool targetedMode = false, + String? targetRepeaterId, + }) async { + debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); return false; } + // Targeted mode requires a repeater ID + if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + debugLog('[AUTO] Targeted mode requires a repeater ID'); + return false; + } + // Check if we're in cooldown (can't start during cooldown) - // Hybrid and Active modes are blocked by cooldown, Passive is not + // Hybrid, Active, and Targeted modes are blocked by cooldown, Passive is not // Reference: isInCooldown() check in startAutoPing() in wardrive.js if (!passiveMode && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); @@ -840,14 +884,23 @@ class PingService { _autoPingEnabled = true; _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; + _targetedModeEnabled = targetedMode; _nextPingIsDiscovery = true; // Always start hybrid with discovery + if (targetedMode) { + _targetRepeaterId = targetRepeaterId; + } + // Enable wake lock to keep screen on during auto mode // Reference: acquireWakeLock() in wardrive.js debugLog('[AUTO] Acquiring wake lock for auto mode'); await _wakelockService.enable(); - if (hybridMode) { + if (targetedMode) { + // Targeted Mode: send trace path to specific repeater + debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + await _startTargetedMode(); + } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); @@ -905,9 +958,15 @@ class PingService { _stopDiscoveryMode(); } + // Clean up targeted mode infrastructure + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; // Disable wake lock when auto mode stops @@ -928,8 +987,10 @@ class PingService { _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _stopDiscoveryMode(); + _stopTargetedMode(); await _wakelockService.disable(); } @@ -1231,6 +1292,219 @@ class PingService { }); } + // ============================================ + // Targeted Mode (Zero-Hop Trace) + // ============================================ + + /// Start targeted mode - subscribes to trace data and sends first trace + Future _startTargetedMode() async { + debugLog('[TRACE] Starting targeted mode for repeater $_targetRepeaterId'); + + // Create trace tracker + final tracker = TraceTracker(); + _traceTracker = tracker; + tracker.onTraceReceived = (result) { + debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + }; + tracker.onWindowComplete = (result) { + debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + _handleTraceWindowComplete(result); + }; + + // Wire trace tracker into UnifiedRxHandler so 0x88 BLE metadata + // gets stored for the 0x89 handler + unifiedRxHandler?.traceTracker = tracker; + + // Subscribe to 0x89 TraceData stream for actual trace payloads + _traceDataSubscription = _connection.traceDataStream.listen((raw) { + final tt = _traceTracker; + if (tt != null && tt.isListening) { + tt.handlePacket(raw, tt.pendingBleSnr, tt.pendingBleRssi); + } + }); + + // Send first trace immediately + await _sendTargetedPing(); + } + + /// Stop targeted mode - cleans up tracker and subscription + void _stopTargetedMode() { + debugLog('[TRACE] Stopping targeted mode'); + _targetedTimer?.cancel(); + _targetedTimer = null; + _traceDataSubscription?.cancel(); + _traceDataSubscription = null; + unifiedRxHandler?.traceTracker = null; + _traceTracker?.dispose(); + _traceTracker = null; + _lastTargetedPosition = null; + } + + /// Send a targeted ping (trace path) and start listening window + Future _sendTargetedPing() async { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, skipping trace'); + return; + } + + final targetId = _targetRepeaterId; + if (targetId == null || targetId.isEmpty) { + debugLog('[TRACE] No target repeater ID, skipping trace'); + _scheduleNextTargetedPing(); + return; + } + + // Check GPS + final position = _gpsService.lastPosition; + if (position == null) { + debugLog('[TRACE] No GPS position, skipping trace'); + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + + // Check minimum distance from last trace (25m) + final lastPos = _lastTargetedPosition; + if (lastPos != null) { + final distance = Geolocator.distanceBetween( + lastPos.latitude, lastPos.longitude, + position.latitude, position.longitude, + ); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + _skipReason = 'too close'; + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + } + + // Clear skip reason since we're proceeding + _skipReason = null; + + // Signal "Sending..." to UI + _pingInProgress = true; + onPingProgressChanged?.call(); + + // Capture noise floor + final noiseFloor = _connection.lastNoiseFloor; + _pendingTxNoiseFloor = noiseFloor; + + // Create trace log entry immediately + final traceEntry = TraceLogEntry( + timestamp: DateTime.now(), + latitude: position.latitude, + longitude: position.longitude, + targetRepeaterId: targetId, + noiseFloor: noiseFloor, + success: false, // Will be updated after window completes + ); + onTracePing?.call(traceEntry); + + debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + + try { + // Play transmit sound + _audioService?.playTransmitSound(); + + // Convert hex repeater ID to bytes + final repeaterIdBytes = Uint8List(_hopBytes); + for (int i = 0; i < _hopBytes && i * 2 + 2 <= targetId.length; i++) { + repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + } + + // Send trace path and get tag + final tag = await _connection.sendTracePath(repeaterIdBytes); + + // Start tracking with the tag + _traceTracker?.startTracking( + tag: tag, + targetRepeaterId: targetId, + hopBytes: _hopBytes, + windowDuration: _rxListeningWindow, + ); + + // Start listening window countdown display + _discoveryWindowCountdown.start(_rxListeningWindow.inMilliseconds); + + // Clear pingInProgress now that trace window is active + _pingInProgress = false; + + // Update last targeted position for 25m check + _lastTargetedPosition = position; + + } catch (e) { + _pingInProgress = false; + debugError('[TRACE] Failed to send trace: $e'); + _scheduleNextTargetedPing(); + } + } + + /// Handle trace window completion + void _handleTraceWindowComplete(TraceResult? result) { + _discoveryWindowCountdown.stop(); + final position = _lastTargetedPosition; + final targetId = _targetRepeaterId ?? ''; + + if (result != null && result.success && position != null) { + debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + + // Queue to API (only successful traces) + _apiQueue.enqueueTrace( + latitude: position.latitude, + longitude: position.longitude, + repeaterId: targetId, + localSnr: result.localSnr, + localRssi: result.localRssi, + remoteSnr: result.remoteSnr, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + + // Update stats + _stats = _stats.copyWith(traceCount: _stats.traceCount + 1); + onStatsUpdated?.call(_stats); + + // Play receive sound for successful trace + _audioService?.playReceiveSound(); + } else { + debugLog('[TRACE] Trace failed: no response from $targetId'); + // Failed traces are NOT posted to API (local visual only) + } + + // Notify for noise floor graph and log updates + onTraceWindowComplete?.call(result); + + _scheduleNextTargetedPing(); + } + + /// Schedule next targeted ping after interval + void _scheduleNextTargetedPing() { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, not scheduling next trace'); + return; + } + + _targetedTimer?.cancel(); + _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { + debugLog('[TRACE] Targeted ping timer fired'); + if (_autoPingEnabled && _targetedModeEnabled) { + if (_pingInProgress) { + debugLog('[TRACE] Ping already in progress, skipping'); + return; + } + _skipReason = null; + _sendTargetedPing(); + } + }); + + // Notify callback for countdown display + onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + + debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + } + /// Stop any active TX echo tracking window /// Called when disabling auto mode to prevent late timer callbacks from /// triggering pings during cooldown (race condition fix) @@ -1253,6 +1527,7 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; _stopDiscoveryMode(); + _stopTargetedMode(); _wakelockService.dispose(); } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 70abdab7..213f6625 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -686,6 +686,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), ), + // Trace markers (cyan/red circles for targeted ping results) + MarkerLayer( + markers: _buildTraceMarkers(appState.traceLogEntries), + ), + // Repeater markers (magenta with ID, rotate with map) MarkerLayer( rotate: true, @@ -1126,6 +1131,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: Colors.cyan, + label: 'TRC', + description: 'Location where a trace reached the repeater', + ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: Colors.red, + label: 'TRC', + description: 'Location where a trace got no response', + ), ], ), ), @@ -1599,6 +1618,284 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } + List _buildTraceMarkers(List entries) { + return entries.map((entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: Container( + decoration: BoxDecoration( + color: entry.success ? Colors.cyan : Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + ), + ); + }).toList(); + } + + void _showTraceDetails(TraceLogEntry entry) { + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.cyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Target repeater section header + Text( + entry.success + ? 'Target Repeater' + : 'No response from target repeater', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (entry.success) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + Builder(builder: (context) { + final localSnr = entry.localSnr ?? 0; + final localRssi = entry.localRssi ?? 0; + final remoteSnr = entry.remoteSnr ?? 0; + + Color rxSnrColor; + if (localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (localRssi >= -70) { + rssiColor = Colors.green; + } else if (localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RX RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '$localRssi', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + /// DISC marker color (#7B68EE - medium slate blue/purple) static const Color _discMarkerColor = Color(0xFF7B68EE); diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 31ba9332..d88357d4 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -221,6 +221,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Failed', }; final eventDescription = switch (marker.type) { @@ -229,6 +231,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', + PingEventType.traceSuccess => 'Trace got response from target', + PingEventType.traceFail => 'Trace got no response from target', }; final hasLocation = marker.latitude != null && marker.longitude != null; @@ -845,10 +849,11 @@ class InteractiveNoiseFloorChartState extends State alignment: WrapAlignment.center, children: [ _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX Fail'), + _legendItem(context, Colors.red, 'TX/Trace Fail'), _legendItem(context, Colors.blue, 'RX'), _legendItem(context, Colors.purple, 'DISC Success'), _legendItem(context, Colors.grey, 'DISC Fail'), + _legendItem(context, Colors.cyan, 'Trace Success'), ], ); } diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart new file mode 100644 index 00000000..c088ab4c --- /dev/null +++ b/lib/widgets/offline_mode_toggle.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; + +/// Shared offline mode toggle widget +/// Extracted from ping_controls.dart for use on both connection screen and ping controls +class OfflineModeToggle extends StatelessWidget { + const OfflineModeToggle({super.key}); + + /// Handle offline mode toggle with progress dialog when connected + static Future handleOfflineModeToggle( + BuildContext context, + AppStateProvider appState, + bool currentOfflineMode, + bool isConnected, + ) async { + final newMode = !currentOfflineMode; + + // If connected, show progress dialog during mode switch + if (isConnected) { + final statusText = newMode + ? 'Switching to offline mode...' + : 'Switching to online mode...'; + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + + // Perform the mode switch + final result = await appState.setOfflineMode(newMode); + + // Close the progress dialog (check if context is still valid) + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show error dialog if switch failed + if (!result.success && context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Mode Switch Failed'), + content: Text( + result.error ?? 'An unknown error occurred', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } else { + // Not connected - simple toggle without dialog + await appState.setOfflineMode(newMode); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final appState = context.watch(); + final offlineMode = appState.offlineMode; + final offlinePingCount = appState.offlinePingCount; + final isConnected = appState.isConnected; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + // Icon + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: offlineMode + ? Colors.orange.withValues(alpha: 0.2) + : colorScheme.onSurface.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + // Label and count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Offline Mode', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: offlineMode + ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) + : colorScheme.onSurface, + ), + ), + if (offlineMode && offlinePingCount > 0) + Text( + '$offlinePingCount pings saved locally', + style: TextStyle( + fontSize: 11, + color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, + ), + ) + else + Text( + offlineMode + ? 'Data saved locally' + : 'Uploads immediately', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Toggle indicator + Container( + width: 44, + height: 26, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(13), + color: offlineMode + ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) + : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: offlineMode + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + width: 22, + height: 22, + margin: const EdgeInsets.all(2), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index ce7c6709..436610fa 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; +import 'offline_mode_toggle.dart'; /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -22,6 +23,7 @@ class PingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode @@ -108,7 +110,7 @@ class PingControls extends StatelessWidget { ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow onPressed: () => _sendPing(context, appState), @@ -157,7 +159,7 @@ class PingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), showCooldown: false, @@ -191,7 +193,7 @@ class PingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases @@ -224,15 +226,11 @@ class PingControls extends StatelessWidget { else const SizedBox(height: 8), - // Offline Mode and Sound toggles row - const Row( - children: [ - // Offline Mode toggle (expanded) - Expanded(child: _OfflineModeToggle()), - SizedBox(width: 8), - // Sound toggle (compact, right side) - _SoundToggle(), - ], + // Targeted Ping controls + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, ), ], ); @@ -445,370 +443,199 @@ class _ActionButtonState extends State<_ActionButton> } } -/// Compact sound notification toggle - matches height of _OfflineModeToggle -class _SoundToggle extends StatelessWidget { - const _SoundToggle(); +/// Targeted Ping controls - hex text field + start/stop button +class _TargetedPingSection extends StatefulWidget { + final bool isAnyModeRunning; + final bool cooldownActive; + final int cooldownRemaining; - @override - Widget build(BuildContext context) { - final appState = context.watch(); - final soundEnabled = appState.isSoundEnabled; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; + const _TargetedPingSection({ + required this.isAnyModeRunning, + required this.cooldownActive, + required this.cooldownRemaining, + }); - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => appState.toggleSoundEnabled(), - borderRadius: BorderRadius.circular(10), - child: Container( - // Match _OfflineModeToggle: padding 10v + icon container (6+18+6) + text adds more height - // _OfflineModeToggle content: icon 30px, text column ~34px, toggle 26px - // Use same padding and let IntrinsicHeight from Row handle matching - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - soundEnabled ? Icons.volume_up : Icons.volume_off, - size: 18, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - // Add text column to match offline mode toggle height - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sound', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: soundEnabled - ? (isDark ? Colors.blue.shade300 : Colors.blue.shade800) - : colorScheme.onSurface, - ), - ), - Text( - soundEnabled ? 'On' : 'Off', - style: TextStyle( - fontSize: 11, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade600) - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } + @override + State<_TargetedPingSection> createState() => _TargetedPingSectionState(); } -/// Compact offline mode toggle matching app design language -class _OfflineModeToggle extends StatelessWidget { - const _OfflineModeToggle(); - - // Offline mode is now enabled - static const bool _isEnabled = true; - - /// Handle offline mode toggle with progress dialog when connected - static Future _handleOfflineModeToggle( - BuildContext context, - AppStateProvider appState, - bool currentOfflineMode, - bool isConnected, - ) async { - final newMode = !currentOfflineMode; - - // If connected, show progress dialog during mode switch - if (isConnected) { - final statusText = newMode - ? 'Switching to offline mode...' - : 'Switching to online mode...'; - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ); +class _TargetedPingSectionState extends State<_TargetedPingSection> { + final _controller = TextEditingController(); + bool _isStarting = false; - // Perform the mode switch - final result = await appState.setOfflineMode(newMode); - - // Close the progress dialog (check if context is still valid) - if (context.mounted) { - Navigator.of(context).pop(); + @override + void initState() { + super.initState(); + // Restore any previously set target ID + WidgetsBinding.instance.addPostFrameCallback((_) { + final appState = context.read(); + final existing = appState.targetRepeaterId; + if (existing != null && existing.isNotEmpty && _controller.text != existing) { + _controller.text = existing; } + }); + } - // Show error dialog if switch failed - if (!result.success && context.mounted) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Mode Switch Failed'), - content: Text( - result.error ?? 'An unknown error occurred', - style: const TextStyle(fontSize: 14), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } else { - // Not connected - simple toggle without dialog - await appState.setOfflineMode(newMode); - } + @override + void dispose() { + _controller.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + final appState = context.watch(); + final isTargetedRunning = appState.isTargetedModeRunning; + final maxLen = appState.effectiveHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - // When disabled, always show as "off" state - if (!_isEnabled) { - return Opacity( - opacity: 0.5, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.cloud_queue, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and "Coming soon" - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - 'Coming soon', - style: TextStyle( - fontSize: 11, - fontStyle: FontStyle.italic, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // Toggle indicator (always off) - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1), // slate-600/300 - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); + // Determine if the start button should be enabled + final hexText = _controller.text.trim(); + final isValidHex = hexText.isNotEmpty && + hexText.length == maxLen && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); + final canStart = isValidHex && + !widget.isAnyModeRunning && + !isTargetedRunning && + !widget.cooldownActive && + appState.isConnected; + + // Status text for when targeted mode is running + String? statusText; + if (isTargetedRunning) { + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + + if (discoveryWindowActive) { + statusText = 'Listening ${discoveryRemaining}s'; + } else if (autoPingWaiting) { + statusText = 'Next in ${autoPingRemaining}s'; + } } - // Original implementation when enabled - final appState = context.watch(); - final offlineMode = appState.offlineMode; - final offlinePingCount = appState.offlinePingCount; - final isConnected = appState.isConnected; + final isEnabled = (canStart || isTargetedRunning) && !_isStarting; + final buttonColor = (isTargetedRunning || _isStarting) + ? const Color(0xFF22C55E) // green-500 when running/starting + : Colors.cyan; + final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _handleOfflineModeToggle(context, appState, offlineMode, isConnected), + return Container( + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', + border: Border.all( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + width: isTargetedRunning ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Targeted button + Expanded( + child: GestureDetector( + onTap: isEnabled + ? () async { + HapticFeedback.lightImpact(); + if (!isTargetedRunning) { + setState(() => _isStarting = true); + appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + } + await appState.toggleAutoPing(AutoMode.targeted); + if (mounted) setState(() => _isStarting = false); + } + : null, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Icon( + Icons.gps_fixed, + size: 18, + color: effectiveColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', style: TextStyle( fontSize: 13, - fontWeight: FontWeight.w500, - color: offlineMode - ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) - : colorScheme.onSurface, + fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, + color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), + overflow: TextOverflow.ellipsis, ), - if (offlineMode && offlinePingCount > 0) - Text( - '$offlinePingCount pings saved locally', - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, - ), - ) - else - Text( - offlineMode - ? 'Data saved locally' - : 'Uploads immediately', - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), - // Toggle indicator - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: offlineMode - ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) - : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), // slate-600/300 + ), + ), + const SizedBox(width: 8), + // Hex text field + SizedBox( + width: 80, + child: TextField( + controller: _controller, + enabled: !isTargetedRunning, + maxLength: maxLen, + textCapitalization: TextCapitalization.characters, + style: TextStyle( + fontSize: 14, + fontFamily: 'monospace', + color: isTargetedRunning + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : '4E7A3B'}', + hintStyle: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: offlineMode - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), + counterText: '', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), ), - ], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), + _UpperCaseTextFormatter(), + ], + onChanged: (value) { + appState.setTargetRepeaterId(value.trim().toUpperCase()); + setState(() {}); + }, + ), ), - ), + ], ), ); } } +/// Text formatter that converts input to uppercase +class _UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return TextEditingValue( + text: newValue.text.toUpperCase(), + selection: newValue.selection, + ); + } +} + /// Compact ping controls for minimized panel view /// Shows 3 small horizontal pill buttons in a row /// Active button expands to show context (e.g., "Listening 5s") @@ -820,7 +647,7 @@ class CompactPingControls extends StatefulWidget { } /// Tracks which button should stay expanded during cooldown -enum _LastActiveButton { none, sendPing, activeMode, passiveMode } +enum _LastActiveButton { none, sendPing, activeMode, passiveMode, targeted } class _CompactPingControlsState extends State { // Static so it persists across widget rebuilds (e.g., expand/minimize panel) @@ -837,6 +664,7 @@ class _CompactPingControlsState extends State { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -873,9 +701,11 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.activeMode; } else if (passiveModeCurrentlyActive) { _lastActiveButton = _LastActiveButton.passiveMode; + } else if (isTargetedRunning) { + _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive) { + if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } @@ -890,25 +720,36 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet); final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; + // Trace Mode (only relevant when a repeater ID has been entered) + final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final targetedCurrentlyActive = isTargetedRunning; + final traceModeExpanded = targetedCurrentlyActive || + (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); + final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && + !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && + !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeActive = isTargetedRunning; + final traceModeShowColor = traceModeEnabled || traceModeActive; + // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded; + final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor; + final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -1003,6 +844,39 @@ class _CompactPingControlsState extends State { onPressed: () => _toggleRxAuto(context, appState), ); + // Build trace mode button (only used when hasTargetRepeaterId) + final traceModeButton = _CompactActionButton( + icon: Icons.gps_fixed, + label: _getTraceModeLabel( + isTargetedRunning: isTargetedRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: traceModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + ), + color: isTargetedRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF06B6D4), // cyan-500 + enabled: traceModeEnabled || isTargetedRunning, + isActive: traceModeActive, + isExpanded: traceModeExpanded, + progress: discoveryWindowActive && isTargetedRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isTargetedRunning + ? appState.autoPingTimer.progress + : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + ? appState.cooldownTimer.progress + : null, + onPressed: () { + HapticFeedback.lightImpact(); + appState.toggleAutoPing(AutoMode.targeted); + }, + ); + // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally @@ -1034,6 +908,17 @@ class _CompactPingControlsState extends State { Expanded(child: passiveModeButton) else passiveModeButton, + + // Trace Mode (only shown when a repeater ID has been entered) + if (hasTargetRepeaterId) ...[ + const SizedBox(width: 6), + if (traceModeExpanded) + Expanded(child: traceModeButton) + else if (!anyExpanded && (traceModeShowColor || allDisabled)) + Expanded(child: traceModeButton) + else + traceModeButton, + ], ], ); } @@ -1119,6 +1004,31 @@ class _CompactPingControlsState extends State { return null; } + /// Get label for Trace Mode button + /// When showFullText is true: "Listening 5s", when false: "5s" + String? _getTraceModeLabel({ + required bool isTargetedRunning, + required bool discoveryWindowActive, + required int discoveryWindowRemaining, + required bool autoPingWaiting, + required int autoPingRemaining, + required bool showFullText, + required bool cooldownActive, + required int cooldownRemaining, + required bool isExpandedDuringCooldown, + }) { + if (isTargetedRunning) { + if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) return showFullText ? 'Next in ${autoPingRemaining}s' : '${autoPingRemaining}s'; + return showFullText ? 'Stop' : null; + } + // Show cooldown if this button caused it + if (cooldownActive && isExpandedDuringCooldown) { + return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + } + return null; + } + Future _sendPing(BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); @@ -1163,6 +1073,7 @@ class LandscapePingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -1213,7 +1124,7 @@ class LandscapePingControls extends StatelessWidget { icon: Icons.cell_tower, tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, countdown: isPingSending @@ -1242,7 +1153,7 @@ class LandscapePingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, countdown: isTxModeRunning ? (discoveryWindowActive @@ -1268,7 +1179,7 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), @@ -1295,7 +1206,7 @@ class LandscapePingControls extends StatelessWidget { label: 'Offline', isOn: offlineMode, color: Colors.orange, - onTap: () => _OfflineModeToggle._handleOfflineModeToggle( + onTap: () => OfflineModeToggle.handleOfflineModeToggle( context, appState, offlineMode, From 60f41eac718006560c4fd0db50bf992a08fda978 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 13:54:01 -0400 Subject: [PATCH 017/360] - Fixed a regression where device firmware version was no longer being parsed correctly --- lib/providers/app_state_provider.dart | 4 +++ lib/screens/connection_screen.dart | 36 +++++++++++++++++---------- lib/services/meshcore/connection.dart | 9 +++++-- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index af7c317c..7be9c97c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -120,6 +120,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Device info DeviceModel? _deviceModel; String? _manufacturerString; + String? _firmwareVersionString; String? _devicePublicKey; String? _offlineContactUri; @@ -303,6 +304,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; + String? get firmwareVersionString => _firmwareVersionString; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -1030,6 +1032,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); @@ -2333,6 +2336,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnected; _deviceModel = null; _manufacturerString = null; + _firmwareVersionString = null; _devicePublicKey = null; _offlineContactUri = null; _displayDeviceName = null; diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index c0581d68..37aea5b3 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -258,24 +258,34 @@ class _ConnectionScreenState extends State with WidgetsBinding // Get device name - uses displayDeviceName which prefers SelfInfo name over BLE advertisement name final deviceName = appState.displayDeviceName ?? 'Unknown'; - // Parse version from manufacturer string and use shortName from device model for hardware - // Format examples: - // - "MeshCore (Heltec V3) v1.10.0" - // - "Ikoka Stick-E22-30dBm (Xiao_nrf52) nightly-e31c46f" + // Extract version from firmware version string (v7+), fall back to manufacturer string + // Supports: "v1.14.0-9f1a3ea" → "1.14.0", "nightly-9f1a3ea" → "nightly-9f1a3ea" String? version; - final manufacturerString = appState.manufacturerString; - if (manufacturerString != null) { - // Use regex to find version pattern directly instead of splitting - // Match: v followed by digits/dots, OR nightly- followed by hex, OR just digits.digits - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); - final match = versionRegex.firstMatch(manufacturerString); - if (match != null) { - version = match.group(1); + final fwString = appState.firmwareVersionString; + if (fwString != null && fwString.isNotEmpty) { + final semverMatch = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(fwString); + if (semverMatch != null) { + version = semverMatch.group(1); + } else { + final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + if (nightlyMatch != null) { + version = nightlyMatch.group(1); + } + } + } + if (version == null) { + final manufacturerString = appState.manufacturerString; + if (manufacturerString != null) { + final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final match = versionRegex.firstMatch(manufacturerString); + if (match != null) { + version = match.group(1); + } } } // Use shortName from device model if available, otherwise fall back to manufacturer string - final hardware = appState.deviceModel?.shortName ?? manufacturerString ?? 'Unknown'; + final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index f3fdacaf..fa97a164 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,12 +17,14 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 + final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, required this.manufacturer, this.firmwareBuildDate, + this.firmwareVersionString, this.pathHashMode, }); } @@ -454,10 +456,12 @@ class MeshCoreConnection { // Parse additional fields from v9+ firmware int? pathHashMode; + String? firmwareVersionString; if (reader.remainingBytesCount > 0) { - // FIRMWARE_VERSION: 20 bytes (skip) + // FIRMWARE_VERSION: 20-byte null-terminated C-string if (reader.remainingBytesCount >= 20) { - reader.readBytes(20); // firmware version string + firmwareVersionString = reader.readCString(20); + debugLog('[CONN] Firmware version string: $firmwareVersionString'); } // client_repeat: 1 byte (v9+, skip) @@ -479,6 +483,7 @@ class MeshCoreConnection { protocolVersion: firmwareVer, manufacturer: manufacturerModel, firmwareBuildDate: buildDate, + firmwareVersionString: firmwareVersionString, pathHashMode: pathHashMode, ); From 30216910cace3b04977df727b4ad0aab40972434 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 14:48:34 -0400 Subject: [PATCH 018/360] - Redesigned the Connection tab for clarity. The previous layout was getting cluttered and hard to navigate. - Offline data session management is now always visible under Settings. --- lib/screens/connection_screen.dart | 988 ++++++++++++-------------- lib/screens/settings_screen.dart | 17 +- lib/widgets/offline_mode_toggle.dart | 165 ++--- lib/widgets/regional_config_card.dart | 102 ++- 4 files changed, 640 insertions(+), 632 deletions(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 37aea5b3..edc35d4b 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -75,58 +75,6 @@ class _ConnectionScreenState extends State with WidgetsBinding @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; - - // Build FAB for scanning - show Cancel during scan, Scan when idle - // Hide FAB entirely during maintenance mode (maintenance UI has its own buttons) - // Hide FAB when Bluetooth is off (shows full-screen message instead) - // Hide FAB during auto-reconnect - Widget? fab; - if (appState.isScanning) { - // Show Cancel FAB during active scan - fab = isLandscape - ? FloatingActionButton.small( - onPressed: () => appState.stopScan(), - backgroundColor: Colors.red, - child: const Icon(Icons.close), - ) - : FloatingActionButton.extended( - onPressed: () => appState.stopScan(), - icon: const Icon(Icons.close), - label: const Text('Cancel'), - backgroundColor: Colors.red, - ); - } else if (appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff) { - // Offline mode bypasses both zone and maintenance checks - final canScan = appState.offlineMode || appState.inZone == true; - fab = isLandscape - ? FloatingActionButton.small( - onPressed: canScan ? () => appState.startScan() : null, - backgroundColor: canScan ? null : Colors.grey, - child: const Icon(Icons.bluetooth_searching), - ) - : FloatingActionButton.extended( - onPressed: canScan ? () => appState.startScan() : null, - icon: const Icon(Icons.bluetooth_searching), - label: Text(appState.offlineMode - ? 'Scan' - : appState.gpsStatus == GpsStatus.disabled - ? 'GPS Disabled' - : appState.gpsStatus == GpsStatus.permissionDenied - ? 'GPS Required' - : appState.isCheckingZone - ? 'Checking Zone...' - : appState.inZone == true - ? 'Scan' - : appState.inZone == false - ? 'Outside Zone' - : 'Checking Zone...'), - backgroundColor: canScan ? null : Colors.grey, - ); - } return Scaffold( appBar: AppBar( @@ -134,26 +82,34 @@ class _ConnectionScreenState extends State with WidgetsBinding automaticallyImplyLeading: false, ), body: _buildBody(context, appState), - floatingActionButton: fab, ); } Widget _buildBody(BuildContext context, AppStateProvider appState) { - // Show reconnecting UI + // Reconnecting and connection progress don't show the zone bar or bottom bar if (appState.connectionStep == ConnectionStep.reconnecting) { return _buildReconnectingView(context, appState); } - - // Show connection progress if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } + // All other states: zone bar top, content middle, action bar bottom + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildStateContent(context, appState)), + _buildBottomBar(context, appState), + ], + ); + } + + /// Routes to the correct sub-view (zone bar is already rendered above) + Widget _buildStateContent(BuildContext context, AppStateProvider appState) { // Show connected state if (appState.isConnected) { - // Show path hash mode warning popup if pending final pathWarning = appState.pendingPathHashWarning; if (pathWarning != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -174,6 +130,99 @@ class _ConnectionScreenState extends State with WidgetsBinding return _buildDeviceList(context, appState); } + /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + ); + } + + /// The right-side action button: Scan, Cancel, or Disconnect + Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.isConnected) { + // Disconnect + return _buildBottomButton( + icon: Icons.bluetooth_disabled, + label: 'Disconnect', + color: Colors.red, + onPressed: () async => await appState.disconnect(), + ); + } + + if (appState.isScanning) { + // Cancel scan + return _buildBottomButton( + icon: Icons.close, + label: 'Cancel', + color: Colors.red, + onPressed: () => appState.stopScan(), + ); + } + + // Scan — disabled when can't scan + final canScan = appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + !appState.isBluetoothOff && + (appState.offlineMode || appState.inZone == true); + + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canScan ? () => appState.startScan() : null, + ); + } + + /// Styled button matching OfflineModeToggle shape + Widget _buildBottomButton({ + required IconData icon, + required String label, + required Color color, + VoidCallback? onPressed, + }) { + final enabled = onPressed != null; + final effectiveColor = enabled ? color : Colors.grey; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: effectiveColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: effectiveColor), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveColor, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; @@ -259,7 +308,6 @@ class _ConnectionScreenState extends State with WidgetsBinding final deviceName = appState.displayDeviceName ?? 'Unknown'; // Extract version from firmware version string (v7+), fall back to manufacturer string - // Supports: "v1.14.0-9f1a3ea" → "1.14.0", "nightly-9f1a3ea" → "nightly-9f1a3ea" String? version; final fwString = appState.firmwareVersionString; if (fwString != null && fwString.isNotEmpty) { @@ -284,117 +332,104 @@ class _ConnectionScreenState extends State with WidgetsBinding } } - // Use shortName from device model if available, otherwise fall back to manufacturer string final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; - + final platform = appState.deviceModel?.platform; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final prefs = appState.preferences; + final isAutoMode = appState.autoPingEnabled; + final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Build device info card - final deviceInfoCard = Card( + // Compact device summary card + final deviceSummaryCard = Card( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Header: BT icon + name/status Row( children: [ - const Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 28, - ), - const SizedBox(width: 12), + const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Connected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - ), + deviceName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, ), Text( - deviceName, - style: Theme.of(context).textTheme.bodyLarge, + 'Connected', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), ), ], ), ), ], ), - const Divider(height: 20), - _buildInfoRow('Hardware', hardware), - _buildInfoRow('Version', version ?? 'Unknown'), - if (appState.deviceModel != null) - _buildInfoRow('Platform', appState.deviceModel!.platform), - if (appState.devicePublicKey != null) + const SizedBox(height: 8), + + // Detail chips: hardware, version, platform + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _buildDetailChip(context, Icons.memory, hardware), + if (version != null) _buildDetailChip(context, Icons.code, version), + if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + ], + ), + + // Power level row + const SizedBox(height: 4), + _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + + // Public key row + if (appState.devicePublicKey != null) ...[ + const SizedBox(height: 8), _buildPublicKeyRow(context, appState.devicePublicKey!), - if (appState.authType != null && !appState.offlineMode) + ], + + // Registered via row + if (appState.authType != null && !appState.offlineMode) ...[ + const SizedBox(height: 4), _buildAuthTypeRow(context, appState.authType!), + ], ], ), ), ); - // Build disconnect button - final disconnectButton = ElevatedButton.icon( - onPressed: () async { - await appState.disconnect(); - }, - icon: const Icon(Icons.bluetooth_disabled), - label: const Text('Disconnect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), + // Compact channels card + final channelsCard = RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + scope: appState.offlineMode ? null : appState.scope, + isOfflineMode: appState.offlineMode, + compact: true, ); if (isLandscape) { - // Landscape: two-column layout return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left column: device info + disconnect Expanded( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: deviceInfoCard, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ], - ), + child: SingleChildScrollView(child: deviceSummaryCard), ), const SizedBox(width: 12), - // Right column: power + regional config Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildPowerLevelCard(context, appState), - const SizedBox(height: 12), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - scope: appState.offlineMode ? null : appState.scope, - isOfflineMode: appState.offlineMode, - ), - ], - ), - ), + child: SingleChildScrollView(child: channelsCard), ), ], ), @@ -402,78 +437,110 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Portrait: vertical layout - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + // Portrait: compact vertical layout (bottom bar provided by _buildBody) + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + deviceSummaryCard, + const SizedBox(height: 12), + channelsCard, + ], + ), + ); + } + + /// Power level row matching Registered via / Public Key format + Widget _buildPowerRow( + BuildContext context, + AppStateProvider appState, + bool isPowerSet, + bool isAutoMode, + UserPreferences prefs, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: InkWell( + onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ - const Expanded(child: OfflineModeToggle()), - const SizedBox(width: 4), - IconButton( - icon: Icon( - Icons.info_outline, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, + const SizedBox( + width: 120, + child: Text( + 'Power Level', + style: TextStyle(fontWeight: FontWeight.w500), ), - tooltip: 'About Offline Mode', - onPressed: () => _showOfflineModeInfo(context), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ), - ], - ), - ), - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - deviceInfoCard, - const SizedBox(height: 16), - _buildPowerLevelCard(context, appState), - const SizedBox(height: 16), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - scope: appState.offlineMode ? null : appState.scope, - isOfflineMode: appState.offlineMode, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + const SizedBox(width: 4), + Text( + isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + style: TextStyle( + fontWeight: FontWeight.w500, + color: isPowerSet ? null : Colors.orange, + ), + ), + if (prefs.autoPowerSet) ...[ + const SizedBox(width: 4), + const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const SizedBox(width: 2), + const Text( + 'Auto', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + if (!isAutoMode) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + ], + ], ), ], ), ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ), - ], + ), ); } - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + /// Small detail chip with icon + text + Widget _buildDetailChip(BuildContext context, IconData icon, String text) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), + Icon(icon, size: 12, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, ), ), - Expanded(child: Text(value)), ], ), ); } + + Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -597,32 +664,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - void _showOfflineModeInfo(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.cloud_off, color: Colors.orange, size: 22), - SizedBox(width: 8), - Text('Offline Mode'), - ], - ), - content: const Text( - 'Save pings locally instead of uploading immediately. Useful when you have poor cell connectivity or the API is in maintenance.\n\n' - 'Data is stored on your device and can be uploaded later from the Settings tab when connectivity is restored.', - style: TextStyle(fontSize: 14, height: 1.5), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Got it'), - ), - ], - ), - ); - } - void _showAuthMethodInfo(BuildContext context, String currentType) { showModalBottomSheet( context: context, @@ -791,50 +832,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildPowerLevelCard(BuildContext context, AppStateProvider appState) { - final prefs = appState.preferences; - final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - return Card( - child: ListTile( - leading: const Icon(Icons.power), - title: const Text('Power Level'), - subtitle: Builder( - builder: (context) { - if (!isPowerSet) { - return Text( - 'Unknown hardware - select power', - style: TextStyle(color: Colors.orange.shade700), - ); - } - return Row( - children: [ - Text(prefs.powerLevelDisplay), - if (prefs.autoPowerSet) ...[ - const SizedBox(width: 8), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), - const SizedBox(width: 4), - const Text( - 'Auto', - style: TextStyle( - fontSize: 12, - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ); - }, - ), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), - ), - ); - } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; @@ -1233,103 +1230,89 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.engineering, + size: 64, + color: Colors.orange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Maintenance Mode', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 32), + // Primary action: Enable Offline Mode + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + if (appState.maintenanceUrl != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('More Info'), + ), + ], + const SizedBox(height: 32), + // Info note at bottom + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.engineering, - size: 64, - color: Colors.orange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'Maintenance Mode', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 32), - // Primary action: Enable Offline Mode - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - if (appState.maintenanceUrl != null) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('More Info'), - ), - ], - const SizedBox(height: 32), - // Info note at bottom - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } // Show Bluetooth off message (takes priority over zone checks) if (appState.isBluetoothOff) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_disabled, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Bluetooth is Off', - message: 'Please enable Bluetooth to scan for MeshCore devices.', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_disabled, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Bluetooth is Off', + message: 'Please enable Bluetooth to scan for MeshCore devices.', ); } @@ -1347,24 +1330,17 @@ class _ConnectionScreenState extends State with WidgetsBinding message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.public_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Region Not Available', - message: message, - action: OutlinedButton.icon( - onPressed: () => _launchOnboardingUrl(), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Request Region Onboarding'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.public_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Region Not Available', + message: message, + action: OutlinedButton.icon( + onPressed: () => _launchOnboardingUrl(), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Request Region Onboarding'), + ), ); } @@ -1375,158 +1351,129 @@ class _ConnectionScreenState extends State with WidgetsBinding // Network error — show offline mode option (matches maintenance pattern) if (appState.zoneCheckErrorReason == 'network') { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: Colors.deepOrange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'No Internet Connection', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + countdown > 0 + ? 'Retrying in ${countdown}s...' + : 'Retrying...', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.wifi_off, - size: 64, - color: Colors.deepOrange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'No Internet Connection', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 8), - Text( - countdown > 0 - ? 'Retrying in ${countdown}s...' - : 'Retrying...', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade500, - ), - ), - const SizedBox(height: 32), - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - const SizedBox(height: 32), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } // GPS errors — no auto-retry, show manual retry button if (appState.zoneCheckErrorReason == 'gps_inaccurate' || appState.zoneCheckErrorReason == 'gps_stale') { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: appState.zoneCheckErrorReason == 'gps_inaccurate' - ? 'GPS Accuracy Error' - : 'GPS Stale Error', - message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', - action: FilledButton.icon( - onPressed: () => appState.checkZoneStatus(), - icon: const Icon(Icons.refresh), - label: const Text('Retry Zone Check'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - ), + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: appState.zoneCheckErrorReason == 'gps_inaccurate' + ? 'GPS Accuracy Error' + : 'GPS Stale Error', + message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + action: FilledButton.icon( + onPressed: () => appState.checkZoneStatus(), + icon: const Icon(Icons.refresh), + label: const Text('Retry Zone Check'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), - ], + ), ); } // Other non-network errors — show simple error with countdown - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.cloud_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Zone Check Failed', - message: countdown > 0 - ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' - : '${appState.zoneCheckError}\n\nRetrying...', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.cloud_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Zone Check Failed', + message: countdown > 0 + ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' + : '${appState.zoneCheckError}\n\nRetrying...', ); } // Zone check in progress or waiting for GPS - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_searching, - iconColor: Colors.blue.withValues(alpha: 0.7), - title: 'Checking MeshMapper Zone...', - message: 'Verifying your location with MeshMapper', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_searching, + iconColor: Colors.blue.withValues(alpha: 0.7), + title: 'Checking MeshMapper Zone...', + message: 'Verifying your location with MeshMapper', ); } if (appState.isScanning) { return Column( children: [ - _buildZoneStatusBar(context, appState), const LinearProgressIndicator(), Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), ], @@ -1537,12 +1484,7 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect)), - ], - ); + return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1550,74 +1492,48 @@ class _ConnectionScreenState extends State with WidgetsBinding // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', - action: isIOS - ? null - : ElevatedButton.icon( - onPressed: () => Geolocator.openLocationSettings(), - icon: const Icon(Icons.settings), - label: const Text('Open Location Settings'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Location Services Disabled', + message: 'Please enable Location Services to verify you\'re in an allowed zone.', + action: isIOS + ? null + : ElevatedButton.icon( + onPressed: () => Geolocator.openLocationSettings(), + icon: const Icon(Icons.settings), + label: const Text('Open Location Settings'), + ), ); } // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', - action: ElevatedButton.icon( - onPressed: () => _requestLocationPermission(appState), - icon: const Icon(Icons.location_on), - label: const Text('Enable Location'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'GPS Permission Required', + message: 'Location access is needed to verify you\'re in an allowed zone.', + action: ElevatedButton.icon( + onPressed: () => _requestLocationPermission(appState), + icon: const Icon(Icons.location_on), + label: const Text('Enable Location'), + ), ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_searching, - iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - title: 'No devices found', - message: 'Tap Scan to search for MeshCore devices', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_searching, + iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + title: 'No devices found', + message: 'Tap Scan to search for MeshCore devices', ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), - ], - ); + return _buildDeviceListView(context, appState, canConnect: canConnect); } void _launchOnboardingUrl() async { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8913478e..9a5b390f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -423,15 +423,26 @@ class _SettingsScreenState extends State { ), // Offline Sessions - if (appState.offlineSessions.isNotEmpty) ...[ - _buildSectionHeader(context, 'Offline Sessions'), + _buildSectionHeader(context, 'Offline Sessions'), + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here for upload', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else ...appState.offlineSessions.map((session) => _OfflineSessionTile( session: session, onUpload: () => _uploadOfflineSession(context, appState, session.filename), onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), onDownload: () => _downloadOfflineSession(context, appState, session.filename), )), - ], const Divider(), diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart index c088ab4c..cfdb9cb2 100644 --- a/lib/widgets/offline_mode_toggle.dart +++ b/lib/widgets/offline_mode_toggle.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; /// Shared offline mode toggle widget -/// Extracted from ping_controls.dart for use on both connection screen and ping controls +/// Compact button style — icon + label, tappable to toggle with confirmation class OfflineModeToggle extends StatelessWidget { const OfflineModeToggle({super.key}); @@ -17,6 +17,10 @@ class OfflineModeToggle extends StatelessWidget { ) async { final newMode = !currentOfflineMode; + // Always show confirmation dialog + final confirmed = await _showConfirmDialog(context, newMode); + if (confirmed != true || !context.mounted) return; + // If connected, show progress dialog during mode switch if (isConnected) { final statusText = newMode @@ -79,114 +83,95 @@ class OfflineModeToggle extends StatelessWidget { } } + /// Show confirmation dialog explaining what the mode does + static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { + final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; + final iconColor = switchingToOffline ? Colors.orange : Colors.green; + final description = switchingToOffline + ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' + : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' + 'This requires an active internet connection.'; + final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 8), + Flexible(child: Text(title)), + ], + ), + content: Text( + description, + style: const TextStyle(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: switchingToOffline + ? FilledButton.styleFrom(backgroundColor: Colors.orange) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final appState = context.watch(); final offlineMode = appState.offlineMode; - final offlinePingCount = appState.offlinePingCount; final isConnected = appState.isConnected; + final color = offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : (isDark ? Colors.green.shade400 : Colors.green.shade700); + final bgColor = offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15); + final borderColor = offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : Colors.green.withValues(alpha: 0.4); + return Material( color: Colors.transparent, child: InkWell( onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), borderRadius: BorderRadius.circular(10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), + color: bgColor, borderRadius: BorderRadius.circular(10), - border: Border.all( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), + border: Border.all(color: borderColor), ), child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: offlineMode - ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) - : colorScheme.onSurface, - ), - ), - if (offlineMode && offlinePingCount > 0) - Text( - '$offlinePingCount pings saved locally', - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, - ), - ) - else - Text( - offlineMode - ? 'Data saved locally' - : 'Uploads immediately', - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: color, ), - // Toggle indicator - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: offlineMode - ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) - : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: offlineMode - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), + const SizedBox(width: 8), + Text( + offlineMode ? 'Offline' : 'Online', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, ), ), ], diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index bdc9b312..10a909e1 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -8,6 +8,7 @@ class RegionalConfigCard extends StatelessWidget { final List channels; final String? scope; final bool isOfflineMode; + final bool compact; const RegionalConfigCard({ super.key, @@ -16,10 +17,15 @@ class RegionalConfigCard extends StatelessWidget { this.channels = const [], this.scope, this.isOfflineMode = false, + this.compact = false, }); @override Widget build(BuildContext context) { + if (compact) { + return _buildCompact(context); + } + // When offline mode is enabled, show "-" for zone fields final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; @@ -108,6 +114,93 @@ class RegionalConfigCard extends StatelessWidget { ); } + /// Compact mode: "Regional Settings" header, scope row, channel chips + Widget _buildCompact(BuildContext context) { + final displayZone = isOfflineMode ? null : zoneName; + final displayScope = isOfflineMode ? '-' : (scope ?? 'Global'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.public, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Regional Settings', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (displayZone != null) + Text( + displayZone, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Scope row + _buildCompactRow(context, 'Scope', [ + _buildChannelChip(context, displayScope, isDefault: true), + ]), + const SizedBox(height: 8), + + // Channels row + _buildCompactRow(context, 'Channels', [ + _buildChannelChip(context, 'Public', isDefault: true), + _buildChannelChip(context, '#wardriving', isDefault: true), + if (!isOfflineMode) + ...channels.map((c) => _buildChannelChip(context, c)), + ]), + ], + ), + ), + ); + } + + /// Compact labeled row: small label on left, chips on right + Widget _buildCompactRow(BuildContext context, String label, List chips) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 70, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: chips, + ), + ), + ], + ); + } + Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( @@ -130,8 +223,12 @@ class RegionalConfigCard extends StatelessWidget { } Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { - // Public channel doesn't use # prefix + // Public channel doesn't use # prefix; scope/plain values pass through as-is final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + // If it doesn't look like a channel name, show raw value (e.g. scope "Global") + final isChannel = name.startsWith('#') || name == 'Public'; + final label = isChannel ? displayName : name; + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -146,11 +243,10 @@ class RegionalConfigCard extends StatelessWidget { ), ), child: Text( - displayName, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - // Use onPrimaryContainer for proper contrast on primaryContainer background color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, ), ), From d5243a1323fa32399b1388c0b61d28fe440ce92c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 18:55:05 -0400 Subject: [PATCH 019/360] ### Bug Fixes - Fixed offline wardrive pings being lost when the app was killed by the OS, backgrounded, or disconnected mid-session - Offline pings now auto-save periodically, so at most ~60 seconds of data is lost in a worst-case app kill - Disconnecting while in offline mode now saves accumulated pings before cleanup - App backgrounding/suspension now triggers an immediate save of offline ping data --- lib/providers/app_state_provider.dart | 56 +++++++++++++++++++ lib/services/api_queue_service.dart | 6 ++ lib/services/offline_session_service.dart | 68 +++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7be9c97c..0a8a30be 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -233,6 +233,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; Timer? _restoreAutoPingTimer; + Timer? _offlineAutoSaveTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; int _reconnectRestoreGeneration = 0; @@ -283,6 +284,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + } else if (state == AppLifecycleState.paused) { + debugLog('[APP] App paused (backgrounded)'); + // Save offline pings immediately on pause to prevent data loss if OS kills app + if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { + _autoSaveOfflinePings(); + } } } @@ -2260,6 +2267,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop RX wardriving if active (flushes batches to queue) _rxLogger?.stopWardriving(trigger: 'disconnect'); + // Save offline pings before clearing queue (no-op if not in offline mode or no pings) + await _saveOfflineSession(); + // ALWAYS START FRESH - clear any queued data on disconnect // Pings without a valid session cannot be uploaded later await _apiQueueService.clearOnDisconnect(); @@ -2703,6 +2713,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode _inZone = null; _currentZone = null; @@ -2710,6 +2722,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _lastZoneCheckPosition = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { + // Stop auto-save timer when leaving offline mode + _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); @@ -2762,6 +2776,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(offlineMode: true); _apiQueueService.offlineMode = true; + // 5b. Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -3052,10 +3069,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, ); + _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); + _stopOfflineAutoSaveTimer(); notifyListeners(); } + /// Periodically auto-save offline pings to prevent data loss from app kill. + /// Uses a non-destructive snapshot so in-memory accumulation continues. + void _autoSaveOfflinePings() { + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + + final pings = _apiQueueService.getOfflinePingsSnapshot(); + if (pings.isEmpty) return; + + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + + _offlineSessionService.updateCurrentSession( + pings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + } + + void _startOfflineAutoSaveTimer() { + _offlineAutoSaveTimer?.cancel(); + _offlineAutoSaveTimer = Timer.periodic(const Duration(seconds: 60), (_) { + _autoSaveOfflinePings(); + }); + debugLog('[OFFLINE] Auto-save timer started (60s interval)'); + } + + void _stopOfflineAutoSaveTimer() { + if (_offlineAutoSaveTimer != null) { + _offlineAutoSaveTimer!.cancel(); + _offlineAutoSaveTimer = null; + debugLog('[OFFLINE] Auto-save timer stopped'); + } + } + /// Upload a stored offline session Future uploadOfflineSession(String filename) async { final sessionData = _offlineSessionService.getSessionData(filename); @@ -4643,6 +4698,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); _restoreAutoPingTimer?.cancel(); + _offlineAutoSaveTimer?.cancel(); _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 613fc5b3..2cb45faf 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -670,6 +670,12 @@ class ApiQueueService { return [...hiveItems, ...memoryItems]; } + /// Get a snapshot of accumulated offline pings without clearing. + /// Used for periodic auto-saves to persist data without losing the in-memory accumulator. + List> getOfflinePingsSnapshot() { + return List>.from(_offlinePings); + } + /// Get accumulated offline pings and clear the accumulator /// Returns the list of ping JSON objects collected during offline session List> getAndClearOfflinePings() { diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 3df1b90f..d37cd8db 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -83,6 +83,10 @@ class OfflineSessionService { SharedPreferences? _prefs; List _sessions = []; + /// Tracks the filename of the session currently being accumulated via periodic auto-save. + /// When set, `updateCurrentSession()` updates this session in-place instead of creating a new one. + String? _currentSessionFilename; + /// Callback when sessions list changes void Function(List sessions)? onSessionsUpdated; @@ -182,6 +186,70 @@ class OfflineSessionService { debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } + /// Update the current in-progress session with the latest pings snapshot. + /// If no current session exists, creates a new one and tracks it. + /// This allows periodic saves to update the same file instead of creating duplicates. + Future updateCurrentSession( + List> pings, { + String? devicePublicKey, + String? deviceName, + String? contactUri, + }) async { + if (pings.isEmpty) { + debugLog('[OFFLINE] No pings to auto-save, skipping'); + return; + } + + // If we have a tracked session, update it in-place + if (_currentSessionFilename != null) { + final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + if (index != -1) { + final existing = _sessions[index]; + final updatedData = Map.from(existing.data); + updatedData['pings'] = pings; + updatedData['ping_count'] = pings.length; + + _sessions[index] = OfflineSession( + filename: existing.filename, + createdAt: existing.createdAt, + pingCount: pings.length, + data: updatedData, + devicePublicKey: devicePublicKey ?? existing.devicePublicKey, + deviceName: deviceName ?? existing.deviceName, + contactUri: contactUri ?? existing.contactUri, + ); + await _saveSessions(); + debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + return; + } + // Session was deleted externally — fall through to create new + debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + _currentSessionFilename = null; + } + + // No current session — create a new one and track it + await saveSession( + pings, + devicePublicKey: devicePublicKey, + deviceName: deviceName, + contactUri: contactUri, + ); + // saveSession inserts at index 0 (newest first) + if (_sessions.isNotEmpty) { + _currentSessionFilename = _sessions.first.filename; + debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + } + } + + /// Clear the current session tracker so the next save creates a fresh session file. + /// Called after final saves (mode switch, disconnect) to create a clean break. + void finalizeCurrentSession() { + if (_currentSessionFilename != null) { + debugLog('[OFFLINE] Finalized session: $_currentSessionFilename'); + _currentSessionFilename = null; + } + } + /// Mark a session as uploaded without deleting it Future markAsUploaded(String filename) async { final index = _sessions.indexWhere((s) => s.filename == filename); From 5d58025286906b101e45c2ded112ce72a1a635b9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 19:09:08 -0400 Subject: [PATCH 020/360] =?UTF-8?q?=20=20Bug=201=20=E2=80=94=20Trace=20Mod?= =?UTF-8?q?e=20fails=20after=20path=20byte=20mode=20switch:=20=20=20-=20li?= =?UTF-8?q?b/services/ping=5Fservice.dart:71:=20Changed=20final=20int=20?= =?UTF-8?q?=5FhopBytes=20to=20int=20=20=20=5FhopBytes=20and=20added=20a=20?= =?UTF-8?q?set=20hopBytes=20setter=20=20=20-=20lib/providers/app=5Fstate?= =?UTF-8?q?=5Fprovider.dart:1961:=20Added=20=5FpingService=3F.hopBytes=20?= =?UTF-8?q?=3D=20=20=20newHopBytes;=20in=20=5FapplyLivePathHashMode()=20so?= =?UTF-8?q?=20PingService=20stays=20in=20sync=20when=20the=20=20=20=20user?= =?UTF-8?q?=20changes=20path=20mode=20at=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2 — "No log files available" when only active log exists: - lib/widgets/upload_logs_dialog.dart:67: Changed DebugFileLogger.listUploadableLogFiles() to widget.appState.prepareDebugLogsForUpload() — this rotates the active log first (same pattern as BugReportSheet), so the rotated file appears in the list --- lib/providers/app_state_provider.dart | 1 + lib/services/ping_service.dart | 5 ++++- lib/widgets/upload_logs_dialog.dart | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0a8a30be..bf8b5e52 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1958,6 +1958,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = newHopBytes; _userChangedPathMode = true; + _pingService?.hopBytes = newHopBytes; final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); debugLog('[PATH] User changed path mode to $newHopBytes-byte (sent to radio)'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 58544685..e2353920 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -68,7 +68,10 @@ class PingService { final bool Function(String repeaterId)? shouldIgnoreRepeater; /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. - final int _hopBytes; + int _hopBytes; + + /// Update hop bytes at runtime (e.g. when user changes path mode while connected) + set hopBytes(int value) => _hopBytes = value; /// When true, skip RSSI carpeater check in DiscTracker (user setting) bool disableRssiFilter; diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart index f97ddae1..4ba980ea 100644 --- a/lib/widgets/upload_logs_dialog.dart +++ b/lib/widgets/upload_logs_dialog.dart @@ -64,7 +64,7 @@ class _UploadLogsSheetState extends State { Future _loadUploadableFiles() async { try { - final files = await DebugFileLogger.listUploadableLogFiles(); + final files = await widget.appState.prepareDebugLogsForUpload(); if (mounted) { setState(() { _availableLogFiles = files; From dc52c76cf8f0b9bef33e7cf6f874611153f7a59d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 19:22:02 -0400 Subject: [PATCH 021/360] ### Improvements - Auto-ping now shows "Skipped Xs" when a ping is skipped due to insufficient movement (25m minimum), replacing the generic countdown label across all UI layouts --- lib/widgets/ping_controls.dart | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 436610fa..d0046dac 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -36,6 +36,7 @@ class PingControls extends StatelessWidget { final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -147,7 +148,7 @@ class PingControls extends StatelessWidget { : rxWindowActive ? 'Listening ${rxWindowRemaining}s' // TX RX window : autoPingWaiting - ? 'Next ping ${autoPingRemaining}s' + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') : rxWindowActive ? 'Cooldown ${rxWindowRemaining}s' @@ -181,7 +182,7 @@ class PingControls extends StatelessWidget { ? (discoveryWindowActive ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? 'Next Disc ${autoPingRemaining}s' // Waiting for next discovery + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping @@ -511,7 +512,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { if (discoveryWindowActive) { statusText = 'Listening ${discoveryRemaining}s'; } else if (autoPingWaiting) { - statusText = 'Next in ${autoPingRemaining}s'; + statusText = appState.autoPingTimer.skipReason != null + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s'; } } @@ -677,6 +680,7 @@ class _CompactPingControlsState extends State { final isPingInProgress = appState.isPingInProgress; final autoPingWaiting = appState.autoPingTimer.isRunning; final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -794,6 +798,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: activeModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, discoveryWindowActive: discoveryWindowActive, discoveryWindowRemaining: discoveryWindowRemaining, ), @@ -827,6 +832,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, ), color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 @@ -857,6 +863,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, ), color: isTargetedRunning ? const Color(0xFF22C55E) // green-500 @@ -959,6 +966,7 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, bool discoveryWindowActive = false, int discoveryWindowRemaining = 0, }) { @@ -971,7 +979,7 @@ class _CompactPingControlsState extends State { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -992,10 +1000,11 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, }) { if (isPassiveModeRunning) { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1016,10 +1025,11 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, }) { if (isTargetedRunning) { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Next in ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it From 8261c0292e7210a26b027a06eed7b729adf5cfaa Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 15 Mar 2026 20:33:32 -0400 Subject: [PATCH 022/360] ### New Features - Auto-stop after idle: auto-ping now automatically stops after 30 minutes of continuous skipped pings (no movement), saving battery and radio activity - New "Auto-Stop After Idle" toggle in Settings (default: ON), located under Min Ping Distance --- lib/models/user_preferences.dart | 12 ++++++++- lib/providers/app_state_provider.dart | 35 +++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 11 +++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index b37b57ac..331519b2 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -79,6 +79,9 @@ class UserPreferences { /// Minimum ping distance in meters (25m floor, user can increase) final int minPingDistanceMeters; + /// Auto-stop auto-ping after 30 minutes of idle (no movement) + final bool autoStopAfterIdle; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -106,6 +109,7 @@ class UserPreferences { this.discDropEnabled = false, this.deleteChannelOnDisconnect = true, this.minPingDistanceMeters = 25, + this.autoStopAfterIdle = true, }); /// Create from JSON (for persistence) @@ -137,6 +141,7 @@ class UserPreferences { discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, + autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, ); } @@ -169,6 +174,7 @@ class UserPreferences { 'discDropEnabled': discDropEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, 'minPingDistanceMeters': minPingDistanceMeters, + 'autoStopAfterIdle': autoStopAfterIdle, }; } @@ -200,6 +206,7 @@ class UserPreferences { bool? discDropEnabled, bool? deleteChannelOnDisconnect, int? minPingDistanceMeters, + bool? autoStopAfterIdle, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -228,6 +235,7 @@ class UserPreferences { discDropEnabled: discDropEnabled ?? this.discDropEnabled, deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, ); } @@ -284,7 +292,8 @@ class UserPreferences { other.anonymousMode == anonymousMode && other.discDropEnabled == discDropEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && - other.minPingDistanceMeters == minPingDistanceMeters; + other.minPingDistanceMeters == minPingDistanceMeters && + other.autoStopAfterIdle == autoStopAfterIdle; } @override @@ -315,6 +324,7 @@ class UserPreferences { discDropEnabled, deleteChannelOnDisconnect, minPingDistanceMeters, + autoStopAfterIdle, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index bf8b5e52..15d04a8d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -139,6 +139,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { PingStats _pingStats = const PingStats(); bool _autoPingEnabled = false; AutoMode _autoMode = AutoMode.active; + DateTime? _idleAutoStopReference; + static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked int _queueSize = 0; int? _currentNoiseFloor; @@ -1346,6 +1348,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up auto ping scheduled callback for countdown display _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { _autoPingTimer.startWithSkipReason(intervalMs, skipReason); + + // Track idle time for auto-stop + if (skipReason != null) { + // Ping was skipped — check if idle too long + if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + final elapsed = DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); + } + } + } else { + // Successful ping — reset idle reference + _idleAutoStopReference = DateTime.now(); + } }; // Wire up discovery ping callback - fires immediately (like onTxPing) @@ -1513,6 +1529,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update local state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -2001,6 +2018,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); } @@ -2064,6 +2082,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; + _idleAutoStopReference = null; // Stop heartbeat _apiService.disableHeartbeat(); @@ -2252,6 +2271,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; + _idleAutoStopReference = null; } // End noise floor session on disconnect @@ -2451,6 +2471,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Auto-stop auto-ping after prolonged idle (no movement) + void _triggerIdleAutoStop() { + if (!_autoPingEnabled) return; + final elapsed = _idleAutoStopReference != null + ? DateTime.now().difference(_idleAutoStopReference!).inMinutes + : 30; + debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); + logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + _idleAutoStopReference = null; + toggleAutoPing(_autoMode); + } + /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) Future toggleAutoPing(AutoMode mode) async { @@ -2504,6 +2536,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); _autoPingEnabled = false; + _idleAutoStopReference = null; // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed @@ -2573,6 +2606,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reference: state.rxTracking.isWardriving = true in wardrive.js _rxLogger?.startWardriving(); _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; @@ -3020,6 +3054,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9a5b390f..ea3b01cf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -166,6 +166,17 @@ class _SettingsScreenState extends State { onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), ), + // Auto-Stop After Idle Toggle + SwitchListTile( + secondary: const Icon(Icons.timer_off), + title: const Text('Auto-Stop After Idle'), + subtitle: const Text('Stops auto-ping after 30 min without movement'), + value: prefs.autoStopAfterIdle, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); + }, + ), + // Hybrid Mode Toggle SwitchListTile( secondary: const Icon(Icons.compare_arrows), From 980d1fb26f3ffe03cfdec77bce57af5d542c6753 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 16 Mar 2026 07:24:19 -0400 Subject: [PATCH 023/360] fix trace bytes --- lib/providers/app_state_provider.dart | 29 ++++++- lib/screens/log_screen.dart | 20 +++-- lib/screens/settings_screen.dart | 104 +++++++++++++++++++++-- lib/services/meshcore/connection.dart | 17 +++- lib/services/meshcore/trace_tracker.dart | 1 - lib/services/ping_service.dart | 20 +++-- lib/widgets/ping_controls.dart | 4 +- lib/widgets/repeater_id_chip.dart | 2 + 8 files changed, 168 insertions(+), 29 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 15d04a8d..fc7f2a1c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -264,6 +264,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; @@ -429,6 +430,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; // Offline mode @@ -1192,6 +1194,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { audioService: _audioService, disableRssiFilter: _preferences.disableRssiFilter, hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, shouldIgnoreRepeater: (String repeaterId) { final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { @@ -1893,9 +1896,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode != null) { final deviceHopBytes = _originalPathHashMode! + 1; _hopBytes = deviceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte'); + // Map TX bytes to trace bytes (3-byte traces not possible, use 4) + _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; + _traceHopBytes = 1; } final effective = effectiveHopBytes; @@ -1907,7 +1914,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await _meshCoreConnection!.setPathHashMode(effective - 1); _hopBytes = effective; // Update runtime state to reflect new mode - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte'); + _traceHopBytes = effective == 3 ? 4 : effective; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -1976,9 +1985,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = newHopBytes; _userChangedPathMode = true; _pingService?.hopBytes = newHopBytes; + // Auto-map trace bytes when TX bytes change (3→4, others stay same) + _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (sent to radio)'); + debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -1995,6 +2007,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Set trace hop bytes (called from settings UI). Valid values: 1, 2, 4. + void setTraceHopBytes(int value) { + if (value != 1 && value != 2 && value != 4) return; + if (value == _traceHopBytes) return; + _traceHopBytes = value; + _pingService?.traceHopBytes = value; + debugLog('[TRACE] User changed trace bytes to $value'); + notifyListeners(); + } + /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; @@ -2380,6 +2402,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _originalPathHashMode = null; _userChangedPathMode = false; _hopBytes = 1; + _traceHopBytes = 1; // Clear regional channels (keeps only Public) and scope ChannelService.clearRegionalChannels(); diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index c8ab642b..e1723400 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -451,7 +451,7 @@ class _TxLogTab extends StatelessWidget { child: Row( children: [ SizedBox( - width: 50, + width: 60, child: Text( 'Node', style: TextStyle( @@ -543,7 +543,7 @@ class _TxLogTab extends StatelessWidget { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 60), // SNR Expanded( child: Center( @@ -713,7 +713,7 @@ class _RxLogTab extends StatelessWidget { child: Row( children: [ SizedBox( - width: 50, + width: 60, child: Text( 'Node', style: TextStyle( @@ -757,7 +757,7 @@ class _RxLogTab extends StatelessWidget { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 60), // SNR Expanded( child: Center( @@ -923,7 +923,7 @@ class _DiscLogTab extends StatelessWidget { child: Row( children: [ SizedBox( - width: 50, + width: 70, child: Text( 'Node', style: TextStyle( @@ -1034,10 +1034,12 @@ class _DiscLogTab extends StatelessWidget { children: [ // Node ID with type SizedBox( - width: 50, + width: 70, child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), + Flexible( + child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), + ), Text( node.nodeTypeLabel, style: const TextStyle( @@ -1232,7 +1234,7 @@ class _TraceLogTab extends StatelessWidget { child: Row( children: [ SizedBox( - width: 50, + width: 70, child: Text( 'Node', style: TextStyle( @@ -1348,7 +1350,7 @@ class _TraceLogTab extends StatelessWidget { child: Row( children: [ SizedBox( - width: 50, + width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 11), ), Expanded( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ea3b01cf..873ad1db 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -340,12 +340,12 @@ class _SettingsScreenState extends State { const Divider(), _buildSectionHeader(context, 'Radio Settings'), - // Path Bytes Setting + // TX Bytes Setting ListTile( leading: const Icon(Icons.linear_scale), title: Row( children: [ - const Flexible(child: Text('Path Bytes', overflow: TextOverflow.ellipsis)), + const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), GestureDetector( onTap: () => _showHopBytesInfo(context), @@ -372,7 +372,7 @@ class _SettingsScreenState extends State { 'Connect to radio to configure', style: TextStyle(color: Colors.amber), ) - : const Text('Repeater ID size in path hops'), + : const Text('Repeater ID size in TX/RX path hops'), trailing: DropdownButton( value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, underline: const SizedBox(), @@ -389,6 +389,50 @@ class _SettingsScreenState extends State { ), ), + // Trace Bytes Setting + ListTile( + leading: const Icon(Icons.gps_fixed), + title: Row( + children: [ + const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showTraceBytesInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + subtitle: !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in trace path'), + trailing: DropdownButton( + value: appState.traceHopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 4, child: Text('4')), + ], + onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setTraceHopBytes(value); + }, + ), + ), + // Background Mode - for "Always" location permission (iOS and Android) if (!kIsWeb) ...[ const Divider(), @@ -1142,7 +1186,7 @@ class _SettingsScreenState extends State { children: [ Icon(Icons.linear_scale, size: 24), SizedBox(width: 8), - Text('Path Bytes'), + Text('TX Bytes'), ], ), content: const Column( @@ -1150,7 +1194,7 @@ class _SettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Controls how many bytes are used to identify each repeater in the packet path. ' + 'Controls how many bytes are used to identify each repeater in TX/RX packet paths. ' 'More bytes = more unique IDs, reducing collisions in large networks.', style: TextStyle(fontSize: 14), ), @@ -1179,6 +1223,56 @@ class _SettingsScreenState extends State { ); } + void _showTraceBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.gps_fixed, size: 24), + SizedBox(width: 8), + Text('Trace Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used for the repeater ID in trace path requests. ' + 'This is separate from TX Bytes because traces use a different encoding.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'TX/RX uses a simple counter:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 3 bytes\n\n' + 'Trace uses bitshift encoding:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 4 bytes', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + '3-byte traces are not supported by the MeshCore protocol. ' + 'When your region uses 3-byte TX paths, set Trace Bytes to 4.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showIntervalSelector(BuildContext context, AppStateProvider appState) { final minInterval = appState.minModeInterval; var currentInterval = appState.preferences.autoPingInterval; diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index fa97a164..07c08100 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -981,20 +981,31 @@ class MeshCoreConnection { /// Send trace path to a specific repeater (targeted ping / zero-hop trace) /// Returns the 4-byte tag used for matching the response - Future sendTracePath(Uint8List repeaterIdBytes) async { + /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) + Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { final random = Random.secure(); final tag = Uint8List.fromList([ random.nextInt(256), random.nextInt(256), random.nextInt(256), random.nextInt(256), ]); - debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")}'); + // Trace uses bitshift encoding: actual_bytes = 1 << path_sz + // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 + final int pathSz; + switch (hopBytes) { + case 4: pathSz = 2; break; + case 2: pathSz = 1; break; + default: pathSz = 0; break; + } + final int flags = pathSz & 0x03; + + debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); final data = BufferWriter(); data.writeByte(CommandCodes.sendTracePath); // 0x24 data.writeBytes(tag); // 4-byte tag data.writeUInt32LE(0); // auth_code = 0 - data.writeByte(0); // flags = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 data.writeBytes(repeaterIdBytes); // target repeater ID await _sendToRadio(data); return tag; diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart index 09c13582..ad7b5297 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -49,7 +49,6 @@ class TraceTracker { void startTracking({ required Uint8List tag, required String targetRepeaterId, - int hopBytes = 1, Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index e2353920..d7822824 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -70,9 +70,15 @@ class PingService { /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. int _hopBytes; + /// Number of bytes for trace path IDs (1, 2, or 4). Uses bitshift encoding, separate from TX. + int _traceHopBytes; + /// Update hop bytes at runtime (e.g. when user changes path mode while connected) set hopBytes(int value) => _hopBytes = value; + /// Update trace hop bytes at runtime (e.g. when user changes trace byte setting) + set traceHopBytes(int value) => _traceHopBytes = value; + /// When true, skip RSSI carpeater check in DiscTracker (user setting) bool disableRssiFilter; @@ -197,6 +203,7 @@ class PingService { this.shouldIgnoreRepeater, this.disableRssiFilter = false, int hopBytes = 1, + int traceHopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -208,7 +215,8 @@ class PingService { _deviceId = deviceId, _txTracker = txTracker, _audioService = audioService, - _hopBytes = hopBytes; + _hopBytes = hopBytes, + _traceHopBytes = traceHopBytes; /// Get current ping statistics PingStats get stats => _stats; @@ -1410,20 +1418,20 @@ class PingService { // Play transmit sound _audioService?.playTransmitSound(); - // Convert hex repeater ID to bytes - final repeaterIdBytes = Uint8List(_hopBytes); - for (int i = 0; i < _hopBytes && i * 2 + 2 <= targetId.length; i++) { + // Convert hex repeater ID to bytes (trace uses separate byte size: 1, 2, or 4) + final traceBytes = _traceHopBytes; + final repeaterIdBytes = Uint8List(traceBytes); + for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); } // Send trace path and get tag - final tag = await _connection.sendTracePath(repeaterIdBytes); + final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); // Start tracking with the tag _traceTracker?.startTracking( tag: tag, targetRepeaterId: targetId, - hopBytes: _hopBytes, windowDuration: _rxListeningWindow, ); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index d0046dac..42915f27 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -487,7 +487,7 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { Widget build(BuildContext context) { final appState = context.watch(); final isTargetedRunning = appState.isTargetedModeRunning; - final maxLen = appState.effectiveHopBytes * 2; + final maxLen = appState.traceHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; // Determine if the start button should be enabled @@ -597,7 +597,7 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : colorScheme.onSurface, ), decoration: InputDecoration( - hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : '4E7A3B'}', + hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', hintStyle: TextStyle( fontSize: 12, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 6d25d4d5..0088da57 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -44,6 +44,8 @@ class RepeaterIdChip extends StatelessWidget { children: [ Text( repeaterId, + softWrap: false, + overflow: TextOverflow.clip, style: TextStyle( fontSize: effectiveFontSize, fontWeight: FontWeight.w600, From 9e301b5ed02389a3308dc5fd0cd42d00535d1a83 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 16 Mar 2026 09:41:05 -0400 Subject: [PATCH 024/360] Added Repeater Picker dropdown for traces --- lib/models/noise_floor_session.dart | 2 +- lib/providers/app_state_provider.dart | 90 +++++++ lib/widgets/ping_controls.dart | 41 +++ lib/widgets/repeater_picker_sheet.dart | 341 +++++++++++++++++++++++++ 4 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/repeater_picker_sheet.dart diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index a1f1fe92..47f26276 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -106,7 +106,7 @@ class PingEventMarker extends HiveObject { PingEventType.discSuccess => Colors.purple, PingEventType.discFail => Colors.grey, PingEventType.traceSuccess => Colors.cyan, - PingEventType.traceFail => Colors.red, + PingEventType.traceFail => Colors.grey, }; /// Get a display label for this event type diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index fc7f2a1c..98b51706 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -236,6 +236,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Timer? _reconnectTimeoutTimer; Timer? _restoreAutoPingTimer; Timer? _offlineAutoSaveTimer; + Timer? _zoneRefreshTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; int _reconnectRestoreGeneration = 0; @@ -940,6 +941,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(result); + return result; } @@ -1029,6 +1033,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(registerResult); + return registerResult; }; } else { @@ -1585,6 +1592,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else { debugLog('[CONN] Connected with limited access'); } + + // Start periodic zone refresh to keep slot counts current + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); + } } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1986,8 +1998,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userChangedPathMode = true; _pingService?.hopBytes = newHopBytes; // Auto-map trace bytes when TX bytes change (3→4, others stay same) + final oldTraceHopBytes = _traceHopBytes; _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; _pingService?.traceHopBytes = _traceHopBytes; + // Clear target repeater if trace bytes changed — old hex ID has wrong byte length + if (_traceHopBytes != oldTraceHopBytes) { + _targetRepeaterId = null; + } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); @@ -2013,6 +2030,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (value == _traceHopBytes) return; _traceHopBytes = value; _pingService?.traceHopBytes = value; + // Clear target repeater — old hex ID has wrong byte length + _targetRepeaterId = null; debugLog('[TRACE] User changed trace bytes to $value'); notifyListeners(); } @@ -2034,6 +2053,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping timers _autoPingTimer.stop(); _rxWindowTimer.stop(); @@ -2289,6 +2311,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping if running (before releasing session) if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); @@ -2936,6 +2961,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Auth type: $_authType'); notifyListeners(); } + _syncZoneCapacityFromAuth(result); } else if (result == null) { // API unreachable (null = network/timeout error) debugError('[APP] API unreachable - network error'); @@ -3002,6 +3028,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Auth type: $_authType'); notifyListeners(); } + _syncZoneCapacityFromAuth(registerResult); result = registerResult; } @@ -3965,6 +3992,68 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Sync zone capacity display with auth result. + /// The /status API (pre-connection) and /auth API (during connection) can + /// return different capacity views. This keeps the connection screen's slot + /// display consistent with the map tab's txAllowed flag. + void _syncZoneCapacityFromAuth(Map authResult) { + if (_currentZone == null) return; + + // If auth response includes slot data, use it directly (forward-compatible) + if (authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = authResult['slots_available']; + debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + } + if (authResult.containsKey('slots_max')) { + _currentZone!['slots_max'] = authResult['slots_max']; + debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + } + + // Sync at_capacity with tx_allowed + final authTxAllowed = authResult['tx_allowed'] == true; + _currentZone!['at_capacity'] = !authTxAllowed; + + // If auth says TX not allowed and server didn't provide slot data, set slots to 0 + if (!authTxAllowed && !authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = 0; + debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + } + + // If auth says TX allowed and we have slot data but server didn't provide updated count, + // decrement by 1 (we just took a slot) + if (authTxAllowed && !authResult.containsKey('slots_available')) { + final available = _currentZone!['slots_available'] as int?; + if (available != null && available > 0) { + _currentZone!['slots_available'] = available - 1; + debugLog('[CAPACITY] Took a slot, slots_available=${available - 1}'); + } + } + + notifyListeners(); + } + + /// Start periodic zone status refresh while connected. + /// Keeps slot counts and capacity status fresh during a session. + void _startZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) async { + if (!isConnected || _preferences.offlineMode) { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + return; + } + debugLog('[CAPACITY] Periodic zone refresh'); + await checkZoneStatus(); + }); + debugLog('[CAPACITY] Started 60s zone refresh timer'); + } + + /// Stop zone status refresh timer. + void _stopZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -4758,6 +4847,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimeoutTimer?.cancel(); _restoreAutoPingTimer?.cancel(); _offlineAutoSaveTimer?.cancel(); + _zoneRefreshTimer?.cancel(); _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 42915f27..d1e6695f 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -6,6 +6,7 @@ import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; import 'offline_mode_toggle.dart'; +import 'repeater_picker_sheet.dart'; /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -483,6 +484,20 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { super.dispose(); } + Future _showRepeaterPicker() async { + final appState = context.read(); + final repeater = await showRepeaterPicker(context); + if (repeater == null || !mounted) return; + + final maxLen = appState.traceHopBytes * 2; + final trimmed = repeater.hexId.length >= maxLen + ? repeater.hexId.substring(0, maxLen).toUpperCase() + : repeater.hexId.toUpperCase(); + _controller.text = trimmed; + appState.setTargetRepeaterId(trimmed); + setState(() {}); + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -490,6 +505,11 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { final maxLen = appState.traceHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; + // Sync controller when provider clears target (e.g. trace bytes changed) + if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { + _controller.clear(); + } + // Determine if the start button should be enabled final hexText = _controller.text.trim(); final isValidHex = hexText.isNotEmpty && @@ -619,6 +639,27 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { }, ), ), + const SizedBox(width: 6), + // Choose repeater button + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: Icon( + Icons.list, + size: 18, + color: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + onPressed: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? _showRepeaterPicker + : null, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + tooltip: 'Choose repeater', + ), + ), ], ), ); diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart new file mode 100644 index 00000000..a10fa2da --- /dev/null +++ b/lib/widgets/repeater_picker_sheet.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; + +import '../models/repeater.dart'; +import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; +import '../utils/distance_formatter.dart'; + +/// Show a bottom sheet repeater picker and return the selected repeater. +Future showRepeaterPicker(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.95, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _RepeaterPickerBody( + scrollController: scrollController, + ), + ), + ); +} + +class _RepeaterPickerBody extends StatefulWidget { + final ScrollController scrollController; + + const _RepeaterPickerBody({required this.scrollController}); + + @override + State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); +} + +class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterAndSort(List repeaters, Position? position) { + List filtered; + if (_query.isNotEmpty) { + final q = _query.toLowerCase(); + filtered = repeaters + .where((r) => + r.name.toLowerCase().contains(q) || + r.hexId.toLowerCase().startsWith(q)) + .toList(); + } else { + filtered = List.of(repeaters); + } + + // Sort: active first, then by distance (if GPS), then alphabetically + filtered.sort((a, b) { + // Active first + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + // By distance if GPS available + if (position != null) { + final distA = GpsService.distanceBetween( + position.latitude, position.longitude, a.lat, a.lon, + ); + final distB = GpsService.distanceBetween( + position.latitude, position.longitude, b.lat, b.lon, + ); + return distA.compareTo(distB); + } + // Alphabetically + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + return filtered; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final repeaters = appState.repeaters; + final position = appState.currentPosition; + final isImperial = appState.preferences.isImperial; + final colorScheme = Theme.of(context).colorScheme; + + final filtered = _filterAndSort(repeaters, position); + + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.cell_tower, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Select Repeater', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or hex ID...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onChanged: (value) => setState(() => _query = value), + ), + ), + // Count label + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Showing ${filtered.length} of ${repeaters.length} repeaters', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + // List + Expanded( + child: filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + repeaters.isEmpty + ? 'No repeaters available' + : 'No repeaters match your search', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.builder( + controller: widget.scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final r = filtered[index]; + return _RepeaterTile( + repeater: r, + position: position, + isImperial: isImperial, + onTap: () => Navigator.pop(context, r), + ); + }, + ), + ), + ], + ); + } +} + +class _RepeaterTile extends StatelessWidget { + final Repeater repeater; + final Position? position; + final bool isImperial; + final VoidCallback onTap; + + const _RepeaterTile({ + required this.repeater, + required this.position, + required this.isImperial, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isActive = repeater.isActive; + final badgeColor = isActive ? Colors.green : Colors.grey; + + // Distance text + String? distanceText; + if (position != null) { + final meters = GpsService.distanceBetween( + position!.latitude, position!.longitude, repeater.lat, repeater.lon, + ); + if (meters < 1000) { + distanceText = formatMeters(meters, isImperial: isImperial); + } else { + distanceText = + formatKilometers(meters / 1000, isImperial: isImperial); + } + } + + // Always show 4-byte (8-char) hex ID for identification + final displayHex = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Hex badge + Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayHex, + style: TextStyle( + fontSize: displayHex.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 12), + // Name + distance + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + if (distanceText != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.near_me, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Text( + distanceText, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Active/Stale chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + child: Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} From 904c1dd8d5a0e3060594316aeb02d8aef550c3d9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 16 Mar 2026 20:55:06 -0400 Subject: [PATCH 025/360] Final fixes for trace mode --- lib/models/noise_floor_session.dart | 2 +- lib/widgets/map_widget.dart | 16 ++++++++-------- lib/widgets/noise_floor_chart.dart | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 47f26276..9c6b574d 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -40,7 +40,7 @@ enum PingEventType { traceSuccess, // Cyan: Trace got response @HiveField(6) - traceFail, // Red: Trace no response + traceFail, // Grey: Trace no response } /// Repeater info for graph markers diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 213f6625..55244f90 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1127,21 +1127,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.grey, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', + color: Colors.cyan, + label: 'TRC', + description: 'Location where a trace reached the repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.cyan, - label: 'TRC', - description: 'Location where a trace reached the repeater', + color: Colors.grey, + label: 'DISC', + description: 'Location where you sent a discovery request but no repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.red, + color: Colors.grey, label: 'TRC', description: 'Location where a trace got no response', ), @@ -1628,7 +1628,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showTraceDetails(entry), child: Container( decoration: BoxDecoration( - color: entry.success ? Colors.cyan : Colors.red, + color: entry.success ? Colors.cyan : Colors.grey, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index d88357d4..aa760b46 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -849,11 +849,11 @@ class InteractiveNoiseFloorChartState extends State alignment: WrapAlignment.center, children: [ _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX/Trace Fail'), + _legendItem(context, Colors.red, 'TX Fail'), _legendItem(context, Colors.blue, 'RX'), _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.grey, 'DISC Fail'), _legendItem(context, Colors.cyan, 'Trace Success'), + _legendItem(context, Colors.grey, 'No Response'), ], ); } From 8f71d52ce97fd1fb3d035fe48fecd9f8b0558f60 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 16 Mar 2026 21:30:43 -0400 Subject: [PATCH 026/360] Log tab redesign --- lib/models/log_entry.dart | 41 + lib/providers/app_state_provider.dart | 10 + lib/screens/connection_screen.dart | 3 +- lib/screens/graph_screen.dart | 5 +- lib/screens/log_screen.dart | 1540 ++++++++----------------- lib/screens/settings_screen.dart | 3 +- 6 files changed, 545 insertions(+), 1057 deletions(-) diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index f0a468da..26429beb 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -181,6 +181,47 @@ enum SnrSeverity { good, // Green: SNR > 5 dB } +/// Ping type for unified log view +enum PingLogType { tx, rx, disc, trace } + +/// Wrapper for unified chronological ping log view +class UnifiedPingLogEntry implements Comparable { + final PingLogType type; + final DateTime timestamp; + final dynamic entry; + + UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + + TxLogEntry get asTx => entry as TxLogEntry; + RxLogEntry get asRx => entry as RxLogEntry; + DiscLogEntry get asDisc => entry as DiscLogEntry; + TraceLogEntry get asTrace => entry as TraceLogEntry; + + @override + int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + + String get timeString => switch (type) { + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; + + String get locationString => switch (type) { + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; + + String toCsv() => switch (type) { + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; +} + /// User Error Entry for error log class UserErrorEntry { final DateTime timestamp; diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 98b51706..b152b1be 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -342,6 +342,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get discLogEntries => List.unmodifiable(_discLogEntries); List get traceLogEntries => List.unmodifiable(_traceLogEntries); List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get unifiedPingLogEntries { + final merged = [ + ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ]; + merged.sort(); + return merged; + } ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index edc35d4b..d8134f9d 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -78,7 +78,8 @@ class _ConnectionScreenState extends State with WidgetsBinding return Scaffold( appBar: AppBar( - title: const Text('Connection'), + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: _buildBody(context, appState), diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index b9a4141d..977ce8cf 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -19,12 +19,13 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Noise Floor History'), + toolbarHeight: 40, + title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) IconButton( - icon: const Icon(Icons.delete_outline), + icon: const Icon(Icons.delete_outline, size: 20), onPressed: () => _confirmClearSessions(context, appState), tooltip: 'Clear all sessions', ), diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index e1723400..02e6bb8d 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -6,7 +6,7 @@ import '../models/log_entry.dart'; import '../providers/app_state_provider.dart'; import '../widgets/repeater_id_chip.dart'; -/// Log screen with tabs for TX Log, RX Log, and User Errors +/// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -20,7 +20,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix @override void initState() { super.initState(); - _tabController = TabController(length: 5, vsync: this); + _tabController = TabController(length: 2, vsync: this); } @override @@ -36,259 +36,130 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 4) { - _tabController.animateTo(4); // Switch to Error tab + if (mounted && _tabController.index != 1) { + _tabController.animateTo(1); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); }); } + final totalPings = appState.txLogEntries.length + + appState.rxLogEntries.length + + appState.discLogEntries.length + + appState.traceLogEntries.length; + + final errorCount = appState.errorLogEntries.length; + return Scaffold( appBar: AppBar( - title: const Text('Logs'), + toolbarHeight: 40, + title: const Text('Logs', style: TextStyle(fontSize: 18)), actions: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () => _copyCurrentTabToCsv(context, appState), - tooltip: 'Copy CSV', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmClearLogs(context, appState), - tooltip: 'Clear all logs', + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == 'copy') _copyCurrentTabToCsv(context, appState); + if (value == 'clear') _confirmClearLogs(context, appState); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), + const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + ], ), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(32), + child: TabBar( + controller: _tabController, + indicatorSize: TabBarIndicatorSize.tab, + dividerHeight: 1, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + ], + ), + ), ), - body: Column( + body: TabBarView( + controller: _tabController, children: [ - // Secondary bar with tabs (full width) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), - const SizedBox(width: 6), - Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), - const SizedBox(width: 6), - Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), - const SizedBox(width: 6), - Expanded(child: _buildTabChip(3, 'TRC', appState.traceLogEntries.length, isTrace: true)), - const SizedBox(width: 6), - Expanded(child: _buildTabChip(4, 'Err', appState.errorLogEntries.length, isError: true)), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _TxLogTab(entries: appState.txLogEntries), - _RxLogTab(entries: appState.rxLogEntries), - _DiscLogTab(entries: appState.discLogEntries), - _TraceLogTab(entries: appState.traceLogEntries), - _ErrorLogTab(entries: appState.errorLogEntries), - ], - ), + _AllPingsTab( + allEntries: appState.unifiedPingLogEntries, + txCount: appState.txLogEntries.length, + rxCount: appState.rxLogEntries.length, + discCount: appState.discLogEntries.length, + traceCount: appState.traceLogEntries.length, ), + _ErrorLogTab(entries: appState.errorLogEntries), ], ), ); } - /// Build a tab chip that matches StatusBar chip styling - Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false, bool isTrace = false}) { - final theme = Theme.of(context); - // Colors matching status bar chips - const discColor = Color(0xFF7B68EE); // DISC purple - const txColor = Colors.green; // TX green (matches status bar) - const rxColor = Colors.blue; // RX blue (matches status bar) - const traceColor = Colors.cyan; // TRC cyan (matches noise floor chart) - - return GestureDetector( - onTap: () { - _tabController.animateTo(index); - setState(() {}); - }, - child: AnimatedBuilder( - animation: _tabController, - builder: (context, child) { - final isCurrentlySelected = _tabController.index == index; - Color currentColor; - if (isCurrentlySelected) { - if (isError) { - currentColor = Colors.red; - } else if (isDisc) { - currentColor = discColor; - } else if (isTrace) { - currentColor = traceColor; - } else if (isTx) { - currentColor = txColor; - } else if (isRx) { - currentColor = rxColor; - } else { - currentColor = theme.colorScheme.primary; - } - } else { - currentColor = theme.colorScheme.onSurfaceVariant; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.15 : 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.4 : 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - label, - overflow: TextOverflow.clip, - softWrap: false, - style: TextStyle( - fontSize: 12, - fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, - color: currentColor, - ), - ), - ), - if (count > 0) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: currentColor, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), - ); - }, - ), - ); - } - void _copyCurrentTabToCsv(BuildContext context, AppStateProvider appState) { - final currentTab = _tabController.index; - - switch (currentTab) { - case 0: // TX Log - _copyTxLogToCsv(context, appState.txLogEntries); - break; - case 1: // RX Log - _copyRxLogToCsv(context, appState.rxLogEntries); - break; - case 2: // DISC Log - _copyDiscLogToCsv(context, appState.discLogEntries); - break; - case 3: // TRC Log - _copyTraceLogToCsv(context, appState.traceLogEntries); - break; - case 4: // Error Log - _copyErrorLogToCsv(context, appState.errorLogEntries); - break; + if (_tabController.index == 0) { + _copyAllPingsToCsv(context, appState); + } else { + _copyErrorLogToCsv(context, appState.errorLogEntries); } } - void _copyTxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No TX log entries to copy'), duration: Duration(seconds: 2)), - ); - return; - } + void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { + final tx = appState.txLogEntries; + final rx = appState.rxLogEntries; + final disc = appState.discLogEntries; + final trace = appState.traceLogEntries; - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,power,events'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - - void _copyRxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No RX log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), ); return; } final buffer = StringBuffer(); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('RX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - void _copyDiscLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No DISC log entries to copy'), duration: Duration(seconds: 2)), - ); - return; + if (tx.isNotEmpty) { + buffer.writeln('--- TX Log ---'); + buffer.writeln('timestamp,latitude,longitude,power,events'); + for (final entry in tx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + if (rx.isNotEmpty) { + buffer.writeln('--- RX Log ---'); + buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + for (final entry in rx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('DISC log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - void _copyTraceLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No trace log entries to copy'), duration: Duration(seconds: 2)), - ); - return; + if (disc.isNotEmpty) { + buffer.writeln('--- DISC Log ---'); + buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + for (final entry in disc) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - final buffer = StringBuffer(); - buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + if (trace.isNotEmpty) { + buffer.writeln('--- TRC Log ---'); + buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + for (final entry in trace) { + buffer.writeln(entry.toCsv()); + } } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Trace log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), ); } @@ -335,457 +206,199 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } } -/// TX Log Tab -class _TxLogTab extends StatelessWidget { - final List entries; +// ============================================================================= +// All Pings Tab — unified chronological view with type filters +// ============================================================================= + +class _AllPingsTab extends StatefulWidget { + final List allEntries; + final int txCount; + final int rxCount; + final int discCount; + final int traceCount; + + const _AllPingsTab({ + required this.allEntries, + required this.txCount, + required this.rxCount, + required this.discCount, + required this.traceCount, + }); - const _TxLogTab({required this.entries}); + @override + State<_AllPingsTab> createState() => _AllPingsTabState(); +} + +class _AllPingsTabState extends State<_AllPingsTab> { + final Set _activeFilters = { + PingLogType.tx, + PingLogType.rx, + PingLogType.disc, + PingLogType.trace, + }; + + void _toggleFilter(PingLogType type) { + setState(() { + if (_activeFilters.contains(type)) { + // Don't allow deselecting the last filter + if (_activeFilters.length > 1) { + _activeFilters.remove(type); + } + } else { + _activeFilters.add(type); + } + }); + } @override Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.upload_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No TX pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], + final filtered = widget.allEntries + .where((e) => _activeFilters.contains(e.type)) + .toList(); + + return Column( + children: [ + // Filter segmented row + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + children: [ + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _segmentDivider(context), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _segmentDivider(context), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _segmentDivider(context), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + ], + ), + ), + ), ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildTxEntry(context, entry); - }, + // List + Expanded( + child: filtered.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.list_alt, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('No pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + itemCount: filtered.length, + itemBuilder: (context, index) { + final unified = filtered[index]; + return switch (unified.type) { + PingLogType.tx => _buildTxCard(context, unified.asTx), + PingLogType.rx => _buildRxCard(context, unified.asRx), + PingLogType.disc => _buildDiscCard(context, unified.asDisc), + PingLogType.trace => _buildTraceCard(context, unified.asTrace), + }; + }, + ), + ), + ], ); } - Widget _buildTxEntry(BuildContext context, TxLogEntry entry) { - final appState = context.read(); - - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time and Power - Row( - children: [ - // Time badge + Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + final active = _activeFilters.contains(type); + return Expanded( + child: GestureDetector( + onTap: () => _toggleFilter(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7), + color: active ? color.withValues(alpha: 0.12) : Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + if (count > 0) ...[ + const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), ), child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, ), ), ), - const Spacer(), - // Power indicator (watts) - Text( - '${entry.power.toStringAsFixed(1)} W', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), ], - ), - - // Repeaters table - if (entry.events.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.events.map((event) => _buildRepeaterRow(context, event)), - ], - ), - ), - ] else ...[ - const SizedBox(height: 8), - Text( - 'No repeaters heard', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), ], - ], + ), ), ), - ), ); } - /// Build a table row for a repeater event - Widget _buildRepeaterRow(BuildContext context, RxEvent event) { - Color snrColor; - switch (event.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - case null: - snrColor = Colors.grey; - } - - // RSSI color based on signal strength - Color rssiColor; - if (event.rssi == null) { - rssiColor = Colors.grey; - } else if (event.rssi! >= -70) { - rssiColor = Colors.green; - } else if (event.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 60), - // SNR - Expanded( - child: Center( - child: _buildTxChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildTxChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor), - ), - ), - ], - ), - ), + static Widget _segmentDivider(BuildContext context) { + return VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), ); } - /// Build a small colored chip for TX table cells - Widget _buildTxChip(String value, Color color) { + // --------------------------------------------------------------------------- + // Type badge + // --------------------------------------------------------------------------- + + static Widget _buildTypeBadge(PingLogType type) { + final (label, color) = switch (type) { + PingLogType.tx => ('TX', Colors.green), + PingLogType.rx => ('RX', Colors.blue), + PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), + PingLogType.trace => ('TRC', Colors.cyan), + }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), - ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', - ), - ), - ); - } -} - -/// RX Log Tab -class _RxLogTab extends StatelessWidget { - final List entries; - - const _RxLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No RX observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildRxEntry(context, entry); - }, - ); - } - - Widget _buildRxEntry(BuildContext context, RxLogEntry entry) { - final appState = context.read(); - - Color snrColor; - switch (entry.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - case null: - snrColor = Colors.grey; - } - - // RSSI color based on signal strength - Color rssiColor; - if (entry.rssi == null) { - rssiColor = Colors.grey; - } else if (entry.rssi! >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (entry.rssi! >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } - - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time badge only - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), - const SizedBox(height: 10), - - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 60), - // SNR - Expanded( - child: Center( - child: _buildRxChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildRxChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, ), + ), ); } - /// Build a small colored chip for RX table cells - Widget _buildRxChip(String value, Color color) { + // --------------------------------------------------------------------------- + // Shared chip builder + // --------------------------------------------------------------------------- + + static Widget _buildChip(String value, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -804,108 +417,172 @@ class _RxLogTab extends StatelessWidget { ), ); } -} -/// DISC Log Tab (Discovery observations from Passive Mode) -class _DiscLogTab extends StatelessWidget { - final List entries; + // --------------------------------------------------------------------------- + // TX Card + // --------------------------------------------------------------------------- + + Widget _buildTxCard(BuildContext context, TxLogEntry entry) { + final appState = context.read(); + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString), + // Repeaters table + if (entry.events.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildRepeaterTable(context, entry.events), + ] else ...[ + const SizedBox(height: 8), + Text( + 'No repeaters heard', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + ), + ); + } - const _DiscLogTab({required this.entries}); + Widget _buildRepeaterTable(BuildContext context, List events) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) => _buildTxRepeaterRow(context, event)), + ], + ), + ); + } - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( children: [ - Icon(Icons.radar_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No discovery observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text('Enable Passive Mode to discover nearby nodes', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12)), + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 60), + Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), ], ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; // Already sorted most recent first - return _buildDiscEntry(context, entry); - }, + ), ); } - Widget _buildDiscEntry(BuildContext context, DiscLogEntry entry) { + // --------------------------------------------------------------------------- + // RX Card + // --------------------------------------------------------------------------- + + Widget _buildRxCard(BuildContext context, RxLogEntry entry) { final appState = context.read(); + final snrColor = _snrColor(entry.severity); + final rssiColor = _rssiColor(entry.rssi); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: Time and node count (matching TX log style) - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString), + const SizedBox(height: 10), + // Repeater table (single row) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], ), ), - ), - const Spacer(), - // Node count indicator (like power indicator in TX log) - Text( - '${entry.nodeCount} node${entry.nodeCount == 1 ? '' : 's'}', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + Divider(height: 1, color: Theme.of(context).dividerColor), + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 60), + Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + ], + ), + ), ), - ), - ], + ], + ), ), - const SizedBox(height: 8), + ], + ), + ), + ), + ); + } - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), + // --------------------------------------------------------------------------- + // DISC Card + // --------------------------------------------------------------------------- + + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry) { + final appState = context.read(); + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), @@ -917,61 +594,19 @@ class _DiscLogTab extends StatelessWidget { ), child: Column( children: [ - // Header row Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox( - width: 70, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) => _buildNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -993,20 +628,9 @@ class _DiscLogTab extends StatelessWidget { ); } - /// Build a table row for a discovered node - Widget _buildNodeRow(BuildContext context, DiscoveredNodeEntry node) { - // Color for RX SNR (what we received) - Color rxSnrColor; - switch (node.severity) { - case SnrSeverity.poor: - rxSnrColor = Colors.red; - case SnrSeverity.fair: - rxSnrColor = Colors.orange; - case SnrSeverity.good: - rxSnrColor = Colors.green; - } - - // TX SNR color (what they received from us) + Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + final rxSnrColor = _snrColorFromValue(node.localSnr); + final rssiColor = _rssiColor(node.localRssi); Color txSnrColor; if (node.remoteSnr <= -1) { txSnrColor = Colors.red; @@ -1016,30 +640,17 @@ class _DiscLogTab extends StatelessWidget { txSnrColor = Colors.green; } - // RSSI color based on signal strength - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - // Node ID with type SizedBox( width: 70, child: Row( children: [ - Flexible( - child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), - ), + Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11)), Text( node.nodeTypeLabel, style: const TextStyle( @@ -1051,97 +662,20 @@ class _DiscLogTab extends StatelessWidget { ], ), ), - // RX SNR - Expanded( - child: Center( - child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildChip('${node.localRssi}', rssiColor), - ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor), - ), - ), + Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), ); } - /// Build a small colored chip for table cells - Widget _buildChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), - ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', - ), - ), - ); - } -} - -/// Trace Log Tab (Trace Mode results) -class _TraceLogTab extends StatelessWidget { - final List entries; - - const _TraceLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.gps_fixed, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3)), - const SizedBox(height: 16), - Text( - 'No trace results yet', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - 'Enter a repeater ID and start Trace Mode', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: entries.length, - itemBuilder: (context, index) { - return _buildTraceEntry(context, entries[index]); - }, - ); - } + // --------------------------------------------------------------------------- + // Trace Card + // --------------------------------------------------------------------------- - Widget _buildTraceEntry(BuildContext context, TraceLogEntry entry) { + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry) { final colorScheme = Theme.of(context).colorScheme; final appState = context.read(); @@ -1149,75 +683,14 @@ class _TraceLogTab extends StatelessWidget { margin: const EdgeInsets.only(bottom: 8), color: colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: Time and repeater ID (matching disc style) - Row( - children: [ - // Time badge (neutral, matching disc) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: colorScheme.onSurface, - ), - ), - ), - const Spacer(), - // Repeater ID + success/fail text - Text( - entry.targetRepeaterId, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 6), - Text( - entry.success ? 'responded' : 'no response', - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), - - // Results table (matching disc style) + _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString), + // Results table if (entry.success) ...[ const SizedBox(height: 10), Container( @@ -1228,60 +701,18 @@ class _TraceLogTab extends StatelessWidget { ), child: Column( children: [ - // Header row Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox( - width: 70, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, - ), - ), - ), + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row _buildTraceNodeRow(context, entry), ], ), @@ -1304,98 +735,101 @@ class _TraceLogTab extends StatelessWidget { ); } - /// Build the data row for a trace result (matching disc node row style) Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { - // RX SNR color - final rxSnr = entry.localSnr; - Color rxSnrColor; - if (rxSnr == null) { - rxSnrColor = Colors.grey; - } else if (rxSnr <= -1) { - rxSnrColor = Colors.red; - } else if (rxSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - // RSSI color - final rssi = entry.localRssi; - Color rssiColor; - if (rssi == null) { - rssiColor = Colors.grey; - } else if (rssi >= -70) { - rssiColor = Colors.green; - } else if (rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - // TX SNR color (remote) - final txSnr = entry.remoteSnr; - Color txSnrColor; - if (txSnr == null) { - txSnrColor = Colors.grey; - } else if (txSnr <= -1) { - txSnrColor = Colors.red; - } else if (txSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); + final rssiColor = _rssiColor(entry.localRssi); + final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox( - width: 70, - child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 11), - ), - Expanded( - child: Center( - child: _buildChip(rxSnr?.toStringAsFixed(1) ?? '-', rxSnrColor), - ), - ), - Expanded( - child: Center( - child: _buildChip(rssi != null ? '$rssi' : '-', rssiColor), - ), - ), - Expanded( - child: Center( - child: _buildChip(txSnr?.toStringAsFixed(1) ?? '-', txSnrColor), - ), - ), + SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 11)), + Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), + Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), ], ), ); } - /// Build a small colored chip for table cells (matching disc style) - Widget _buildChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), - ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString) { + return Row( + children: [ + _buildTypeBadge(type), + const SizedBox(width: 6), + Text( + timeString, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 2), + Text( + locationString, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), ), + ], + ); + } + + static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + return Text( + text, + textAlign: center ? TextAlign.center : TextAlign.left, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } + + static Color _snrColor(SnrSeverity? severity) { + return switch (severity) { + SnrSeverity.poor => Colors.red, + SnrSeverity.fair => Colors.orange, + SnrSeverity.good => Colors.green, + null => Colors.grey, + }; + } + + static Color _snrColorFromValue(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + + static Color _snrColorFromNullableValue(double? snr) { + if (snr == null) return Colors.grey; + return _snrColorFromValue(snr); + } + + static Color _rssiColor(int? rssi) { + if (rssi == null) return Colors.grey; + if (rssi >= -70) return Colors.green; + if (rssi >= -100) return Colors.orange; + return Colors.red; + } } -/// Error Log Tab +// ============================================================================= +// Error Log Tab (unchanged) +// ============================================================================= + class _ErrorLogTab extends StatelessWidget { final List entries; @@ -1420,7 +854,7 @@ class _ErrorLogTab extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: entries.length, itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) + // Most recent first final entry = entries[entries.length - 1 - index]; return _buildErrorEntry(context, entry); }, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 873ad1db..e6332d58 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -102,7 +102,8 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Settings'), + toolbarHeight: 40, + title: const Text('Settings', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: ListView( From 6a670d68e4615cc8d0f34391df231b3101e1742a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 06:43:37 -0400 Subject: [PATCH 027/360] Resigned log & settings screen --- lib/screens/log_screen.dart | 213 ++++- lib/screens/settings_screen.dart | 1468 +++++++++++++++--------------- 2 files changed, 924 insertions(+), 757 deletions(-) diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 02e6bb8d..53598dd1 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../widgets/repeater_id_chip.dart'; @@ -16,6 +17,7 @@ class LogScreen extends StatefulWidget { class _LogScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final _allPingsKey = GlobalKey<_AllPingsTabState>(); @override void initState() { @@ -87,7 +89,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix controller: _tabController, children: [ _AllPingsTab( + key: _allPingsKey, allEntries: appState.unifiedPingLogEntries, + repeaters: appState.repeaters, txCount: appState.txLogEntries.length, rxCount: appState.rxLogEntries.length, discCount: appState.discLogEntries.length, @@ -108,6 +112,29 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { + // If a search filter is active, export only the filtered unified entries + final tabState = _allPingsKey.currentState; + final searchQuery = tabState?._searchQuery ?? ''; + if (searchQuery.isNotEmpty && tabState != null) { + final filtered = tabState._filteredEntries; + if (filtered.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + ); + return; + } + final buffer = StringBuffer(); + buffer.writeln('type,data'); + for (final entry in filtered) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), + ); + return; + } + final tx = appState.txLogEntries; final rx = appState.rxLogEntries; final disc = appState.discLogEntries; @@ -212,13 +239,16 @@ class _LogScreenState extends State with SingleTickerProviderStateMix class _AllPingsTab extends StatefulWidget { final List allEntries; + final List repeaters; final int txCount; final int rxCount; final int discCount; final int traceCount; const _AllPingsTab({ + super.key, required this.allEntries, + required this.repeaters, required this.txCount, required this.rxCount, required this.discCount, @@ -237,6 +267,18 @@ class _AllPingsTabState extends State<_AllPingsTab> { PingLogType.trace, }; + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + /// Current filtered entries (used by CSV export) + List _filteredEntries = []; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + void _toggleFilter(PingLogType type) { setState(() { if (_activeFilters.contains(type)) { @@ -250,14 +292,136 @@ class _AllPingsTabState extends State<_AllPingsTab> { }); } + // --------------------------------------------------------------------------- + // Repeater name resolution & search matching + // --------------------------------------------------------------------------- + + /// Resolve a short repeater ID to known repeater names via prefix matching. + static ({List names, bool ambiguous}) _resolveRepeaterNames( + String repeaterId, List repeaters, + ) { + final idLower = repeaterId.toLowerCase(); + final matches = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(idLower)) + .map((r) => r.name) + .toList(); + return (names: matches, ambiguous: matches.length > 1); + } + + /// Whether a hex ID has ambiguous name matches (maps to multiple repeaters). + static bool _isAmbiguousId(String repeaterId, List repeaters) { + return _resolveRepeaterNames(repeaterId, repeaters).ambiguous; + } + + /// True if the query looks like a hex string (only 0-9, a-f). + static bool _isHexQuery(String query) { + return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); + } + + /// Whether an entry matches the current search query. + bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty) return true; + final query = _searchQuery.toLowerCase(); + + switch (entry.type) { + case PingLogType.tx: + final tx = entry.asTx; + for (final event in tx.events) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.rx: + final rx = entry.asRx; + if (rx.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(rx.repeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + case PingLogType.disc: + final disc = entry.asDisc; + for (final node in disc.discoveredNodes) { + if (node.repeaterId.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.trace: + final trace = entry.asTrace; + if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + } + } + + /// Whether an entry should show the ambiguity indicator. + /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. + bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; + + switch (entry.type) { + case PingLogType.tx: + return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + case PingLogType.rx: + return _isAmbiguousId(entry.asRx.repeaterId, repeaters); + case PingLogType.disc: + return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + case PingLogType.trace: + return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); + } + } + @override Widget build(BuildContext context) { final filtered = widget.allEntries .where((e) => _activeFilters.contains(e.type)) + .where((e) => _matchesSearch(e, widget.repeaters)) .toList(); + _filteredEntries = filtered; + + final hasEntries = widget.allEntries.isNotEmpty; + final hasResults = filtered.isNotEmpty; return Column( children: [ + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: SizedBox( + height: 36, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: 'Search by repeater name or ID', + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + prefixIconConstraints: const BoxConstraints(minWidth: 36), + suffixIcon: _searchQuery.isNotEmpty + ? GestureDetector( + onTap: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: const Icon(Icons.close, size: 18), + ) + : null, + suffixIconConstraints: const BoxConstraints(minWidth: 36), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + ), + ), + onChanged: (value) => setState(() => _searchQuery = value.trim()), + ), + ), + ), // Filter segmented row Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), @@ -284,14 +448,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), // List Expanded( - child: filtered.isEmpty + child: !hasResults ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.list_alt, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon( + hasEntries ? Icons.search_off : Icons.list_alt, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), const SizedBox(height: 16), - Text('No pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text( + hasEntries && _searchQuery.isNotEmpty + ? 'No results for \'$_searchQuery\'' + : 'No pings logged yet', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), ], ), ) @@ -300,11 +473,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { itemCount: filtered.length, itemBuilder: (context, index) { final unified = filtered[index]; + final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); return switch (unified.type) { - PingLogType.tx => _buildTxCard(context, unified.asTx), - PingLogType.rx => _buildRxCard(context, unified.asRx), - PingLogType.disc => _buildDiscCard(context, unified.asDisc), - PingLogType.trace => _buildTraceCard(context, unified.asTrace), + PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), }; }, ), @@ -422,7 +596,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- - Widget _buildTxCard(BuildContext context, TxLogEntry entry) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), @@ -434,7 +608,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString), + _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), @@ -505,7 +679,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { // RX Card // --------------------------------------------------------------------------- - Widget _buildRxCard(BuildContext context, RxLogEntry entry) { + Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); @@ -520,7 +694,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString), + _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), const SizedBox(height: 10), // Repeater table (single row) Container( @@ -569,7 +743,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { // DISC Card // --------------------------------------------------------------------------- - Widget _buildDiscCard(BuildContext context, DiscLogEntry entry) { + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); return Card( @@ -582,7 +756,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString), + _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), @@ -675,7 +849,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Trace Card // --------------------------------------------------------------------------- - Widget _buildTraceCard(BuildContext context, TraceLogEntry entry) { + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { final colorScheme = Theme.of(context).colorScheme; final appState = context.read(); @@ -689,7 +863,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString), + _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Results table if (entry.success) ...[ const SizedBox(height: 10), @@ -757,10 +931,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Shared helpers // --------------------------------------------------------------------------- - static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString) { + static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { return Row( children: [ _buildTypeBadge(type), + if (showAmbiguity) ...[ + const SizedBox(width: 2), + Tooltip( + message: 'Repeater ID matches multiple nodes', + child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + ), + ], const SizedBox(width: 6), Text( timeString, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e6332d58..9c26a4a5 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -107,801 +107,770 @@ class _SettingsScreenState extends State { automaticallyImplyLeading: false, ), body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ - // Appearance section - _buildSectionHeader(context, 'Appearance'), - SwitchListTile( - secondary: Icon( - prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, - ), - title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), - value: prefs.themeMode == 'dark', - onChanged: (isDark) { - appState.setThemeMode(isDark ? 'dark' : 'light'); - }, - ), - SwitchListTile( - secondary: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - value: prefs.isImperial, - onChanged: (isImperial) { - appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); - }, - ), - - const Divider(), - - // Wardriving Settings section - _buildSectionHeader(context, 'Wardriving Settings'), - - // Sound Notifications Toggle - SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), - ), - - // Auto-Ping Interval Selector - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Auto-Ping Interval'), - subtitle: Text(prefs.autoPingIntervalDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), - ), - - // Min Ping Distance Selector - ListTile( - leading: const Icon(Icons.straighten), - title: const Text('Min Ping Distance'), - subtitle: Text(prefs.minPingDistanceDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), - ), - - // Auto-Stop After Idle Toggle - SwitchListTile( - secondary: const Icon(Icons.timer_off), - title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), - value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, - ), - - // Hybrid Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.compare_arrows), - title: Row( - children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHybridModeInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + // Lock indicator + if (isAutoMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), - ], - ), - subtitle: appState.enforceHybrid - ? const Text( - 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', - style: TextStyle(color: Colors.amber), - ) - : const Text('Combines Active and Passive modes'), - value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, - ), - - // Discovery Drop Toggle - SwitchListTile( - secondary: const Icon(Icons.signal_wifi_off), - title: Row( - children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showDiscDropInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + child: const Row( + children: [ + Icon(Icons.lock, size: 16, color: Colors.amber), + SizedBox(width: 8), + Text( + 'Some settings locked during auto-ping', + style: TextStyle(fontSize: 12, color: Colors.amber), + ), + ], ), - ], + ), ), - subtitle: appState.enforceDiscDrop - ? const Text( - 'Set by Regional Admin — reports dead zones for network analysis.', - style: TextStyle(color: Colors.amber), - ) - : const Text('Count failed discoveries as failed pings'), - value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - appState.updatePreferences(prefs.copyWith(discDropEnabled: value)); - }, - ), - // CARpeater Filter Setting - SwitchListTile( - secondary: const Icon(Icons.filter_alt), - title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), - value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - // Show dialog to set repeater ID when enabling - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, - ), + // General + _buildSection(context, 'General', [ + SwitchListTile( + secondary: Icon( + prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + ), + title: const Text('Theme'), + subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + value: prefs.themeMode == 'dark', + onChanged: (isDark) { + appState.setThemeMode(isDark ? 'dark' : 'light'); + }, + ), + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + value: prefs.isImperial, + onChanged: (isImperial) { + appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); + }, + ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + ]), - // CARpeater ID - show when enabled - if (prefs.ignoreCarpeater) + // Ping Settings + _buildSection(context, 'Ping Settings', [ + SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: const Text('Anonymous Mode'), + subtitle: Text(prefs.anonymousMode + ? 'Device broadcasts as "Anonymous"' + : 'Device uses its real name'), + value: prefs.anonymousMode, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, + ), ListTile( - leading: const SizedBox(width: 24), // Indent - title: const Text('CARpeater ID'), - subtitle: Text(prefs.ignoreRepeaterId != null - ? '0x${prefs.ignoreRepeaterId}' - : 'Not set'), + leading: const Icon(Icons.timer), + title: const Text('Auto-Ping Interval'), + subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), ), + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Min Ping Distance'), + subtitle: Text(prefs.minPingDistanceDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + SwitchListTile( + secondary: const Icon(Icons.timer_off), + title: const Text('Auto-Stop After Idle'), + subtitle: const Text('Stops auto-ping after 30 min without movement'), + value: prefs.autoStopAfterIdle, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); + }, + ), + ]), - // Anonymous Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.visibility_off), - title: const Text('Anonymous Mode'), - subtitle: Text(prefs.anonymousMode - ? 'Device broadcasts as "Anonymous"' - : 'Device uses its real name'), - value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, - ), - - // Delete Channel on Disconnect Toggle - SwitchListTile( - secondary: const Icon(Icons.delete_sweep), - title: const Text('Delete Channel on Disconnect'), - subtitle: const Text('Remove #wardriving channel from radio when disconnecting'), - value: prefs.deleteChannelOnDisconnect, - onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); - }, - ), - - // Disable RSSI Filter Toggle - SwitchListTile( - secondary: const Icon(Icons.shield_outlined), - title: const Text('Disable RSSI Filter'), - subtitle: Text(prefs.disableRssiFilter - ? 'Allows all signal strengths' - : 'Drops signals stronger than -30 dBm'), - value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, - ), - - // Lock indicator - if (isAutoMode) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + // Modes + _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.compare_arrows), + title: Row( children: [ - Icon(Icons.lock, size: 16, color: Colors.amber), - SizedBox(width: 8), - Expanded( - child: Text( - 'Settings locked during auto-ping mode', - style: TextStyle( - fontSize: 12, - color: Colors.amber, - ), + const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showHybridModeInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), + subtitle: appState.enforceHybrid + ? const Text( + 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Combines Active and Passive modes'), + value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, + onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { + appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); + }, ), - - // Radio Settings section - const Divider(), - _buildSectionHeader(context, 'Radio Settings'), - - // TX Bytes Setting - ListTile( - leading: const Icon(Icons.linear_scale), - title: Row( - children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHopBytesInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - subtitle: appState.enforceHopBytes - ? const Text( - 'Set by Regional Admin — larger IDs reduce collisions in your region.', - style: TextStyle(color: Colors.amber), - ) - : (appState.isConnected && !appState.supportsMultiBytePaths) - ? const Text( - 'Firmware 1.14+ required', - style: TextStyle(color: Colors.amber), - ) - : !appState.isConnected - ? const Text( - 'Connect to radio to configure', - style: TextStyle(color: Colors.amber), - ) - : const Text('Repeater ID size in TX/RX path hops'), - trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, - underline: const SizedBox(), - items: const [ - DropdownMenuItem(value: 1, child: Text('1')), - DropdownMenuItem(value: 2, child: Text('2')), - DropdownMenuItem(value: 3, child: Text('3')), - ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) - ? null - : (value) { - if (value != null) appState.setHopBytes(value); - }, - ), - ), - - // Trace Bytes Setting - ListTile( - leading: const Icon(Icons.gps_fixed), - title: Row( - children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showTraceBytesInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, + SwitchListTile( + secondary: const Icon(Icons.signal_wifi_off), + title: Row( + children: [ + const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showDiscDropInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), - ], - ), - subtitle: !appState.isConnected - ? const Text( - 'Connect to radio to configure', - style: TextStyle(color: Colors.amber), - ) - : (appState.isConnected && !appState.supportsMultiBytePaths) - ? const Text( - 'Firmware 1.14+ required', - style: TextStyle(color: Colors.amber), - ) - : const Text('Repeater ID size in trace path'), - trailing: DropdownButton( - value: appState.traceHopBytes, - underline: const SizedBox(), - items: const [ - DropdownMenuItem(value: 1, child: Text('1')), - DropdownMenuItem(value: 2, child: Text('2')), - DropdownMenuItem(value: 4, child: Text('4')), - ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) - ? null - : (value) { - if (value != null) appState.setTraceHopBytes(value); - }, - ), - ), - - // Background Mode - for "Always" location permission (iOS and Android) - if (!kIsWeb) ...[ - const Divider(), - _buildSectionHeader(context, 'Background Mode'), - _BackgroundModeToggle(appState: appState), - ], - - const Divider(), - - // Data Management section - _buildSectionHeader(context, 'Data Management'), - ListTile( - leading: const Icon(Icons.cloud_queue), - title: const Text('Queued Pings'), - subtitle: Text('${appState.queueSize} items waiting'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: appState.queueSize > 0 - ? () => appState.forceUploadQueue() - : null, - tooltip: 'Force upload', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: appState.queueSize > 0 - ? () => _confirmClearQueue(context, appState) - : null, - tooltip: 'Clear queue', - ), - ], - ), - ), - - // Clear Map Markers - ListTile( - leading: const Icon(Icons.delete_sweep), - title: const Text('Clear Map Markers'), - subtitle: const Text('Remove all TX/RX markers from map'), - onTap: () => _confirmClearPings(context, appState), - ), - - // Offline Sessions - _buildSectionHeader(context, 'Offline Sessions'), - if (appState.offlineSessions.isEmpty) - ListTile( - leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), - title: Text( - 'No offline sessions stored', - style: TextStyle(color: Colors.grey.shade500), - ), - subtitle: Text( - 'Sessions recorded in offline mode will appear here for upload', - style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ], ), - ) - else - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), - - const Divider(), - - // About section - _buildSectionHeader(context, 'About'), - ListTile( - leading: const Icon(Icons.perm_identity), - title: const Text('Device ID'), - subtitle: Text(appState.deviceId), - ), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text(AppConstants.appName), - subtitle: Text('Mesh network coverage mapper'), - ), - ListTile( - leading: const Icon(Icons.new_releases_outlined), - title: const Text('Version'), - subtitle: Text(AppConstants.appVersion), - onTap: () => _onVersionTap(appState), - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: const Text('Submit Feedback'), - subtitle: const Text('Report bugs or request features'), - onTap: () => _showBugReportDialog(context, appState), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.github), - title: const Text('GitHub'), - subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: const Text('Discord'), - subtitle: const Text('Join our community chat'), - onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), - ), - ListTile( - leading: const Icon(Icons.groups), - title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), - onTap: () => _launchUrl('https://ottawamesh.ca/'), - ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee ☕'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), - - // Exit Options (Android only) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ...[ - const Divider(), - _buildSectionHeader(context, 'Exit Options'), - - // Auto-close toggle - SwitchListTile( - secondary: const Icon(Icons.exit_to_app), - title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), - value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + subtitle: appState.enforceDiscDrop + ? const Text( + 'Set by Regional Admin — reports dead zones for network analysis.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Count failed discoveries as failed pings'), + value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, + onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { + appState.updatePreferences(prefs.copyWith(discDropEnabled: value)); + }, ), + ]), - // Manual close button - ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), - title: const Text('Close App'), - subtitle: const Text('Exit the app completely'), - onTap: () => _showCloseAppConfirmation(context, appState), + // Filtering + _buildSection(context, 'Filtering', [ + SwitchListTile( + secondary: const Icon(Icons.filter_alt), + title: const Text('CARpeater Filter'), + subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), + value: prefs.ignoreCarpeater, + onChanged: isAutoMode ? null : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); + } + }, ), - ], - - // Developer Tools section - only visible when developer mode is enabled - if (appState.developerModeEnabled) ...[ - const Divider(), - - _buildSectionHeader(context, 'Developer Tools'), - - // Developer mode toggle (to disable) + if (prefs.ignoreCarpeater) + ListTile( + leading: const SizedBox(width: 24), + title: const Text('CARpeater ID'), + subtitle: Text(prefs.ignoreRepeaterId != null + ? '0x${prefs.ignoreRepeaterId}' + : 'Not set'), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + ), SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: const Text('Developer Mode'), - subtitle: const Text('Disable to hide developer tools'), - value: appState.developerModeEnabled, - onChanged: (value) { - appState.setDeveloperMode(value); + secondary: const Icon(Icons.shield_outlined), + title: const Text('Disable RSSI Filter'), + subtitle: Text(prefs.disableRssiFilter + ? 'Allows all signal strengths' + : 'Drops signals stronger than -30 dBm'), + value: prefs.disableRssiFilter, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); + } }, ), + ]), - // GPS Simulator Toggle - SwitchListTile( - secondary: Icon( - Icons.gps_fixed, - color: appState.isGpsSimulatorEnabled ? Colors.orange : null, - ), - title: Row( - children: [ - const Text('GPS Simulator'), - if (appState.isGpsSimulatorEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SIMULATED', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + // Radio Settings + _buildSection(context, 'Radio', [ + ListTile( + leading: const Icon(Icons.linear_scale), + title: Row( + children: [ + const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showHopBytesInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], - ], - ), - subtitle: Text(appState.isGpsSimulatorEnabled - ? 'Smooth simulated movement active' - : 'Use simulated GPS for testing'), - value: appState.isGpsSimulatorEnabled, - onChanged: (value) { - if (value) { - appState.enableGpsSimulator(); - } else { - appState.disableGpsSimulator(); - } - }, - ), - - // Simulator Settings (only when enabled) - if (appState.isGpsSimulatorEnabled) ...[ - // Speed Slider - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Simulation Speed'), - subtitle: Slider( - value: appState.gpsSimulatorSpeed, - min: 10, - max: 120, - divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - onChanged: (value) { - appState.setGpsSimulatorSpeed(value); - }, ), - trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - style: const TextStyle(fontWeight: FontWeight.bold), + subtitle: appState.enforceHopBytes + ? const Text( + 'Set by Regional Admin — larger IDs reduce collisions in your region.', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in TX/RX path hops'), + trailing: DropdownButton( + value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + ], + onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setHopBytes(value); + }, ), ), - - // Pattern Selector ListTile( - leading: const SizedBox(width: 24), - title: const Text('Movement Pattern'), - trailing: SizedBox( - width: 180, - child: DropdownButton( - value: appState.gpsSimulatorPattern, - underline: const SizedBox(), - isExpanded: true, - items: [ - const DropdownMenuItem( - value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + leading: const Icon(Icons.gps_fixed), + title: Row( + children: [ + const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showTraceBytesInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - const DropdownMenuItem( - value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + ), + ], + ), + subtitle: !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in trace path'), + trailing: DropdownButton( + value: appState.traceHopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 4, child: Text('4')), + ], + onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setTraceHopBytes(value); + }, + ), + ), + SwitchListTile( + secondary: const Icon(Icons.delete_sweep), + title: Row( + children: [ + const Flexible(child: Text('Delete Channel on Disconnect')), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showDeleteChannelInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - if (appState.hasSimulatorRoute) - DropdownMenuItem( - value: SimulatorPattern.route, - child: Text( - 'Route: ${appState.simulatorRouteName ?? "Loaded"}', - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: (pattern) { - if (pattern != null) { - appState.setGpsSimulatorPattern(pattern); - } - }, - ), + ), + ], ), + value: prefs.deleteChannelOnDisconnect, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + }, ), + ]), - // Load Route File + // Data Management + _buildSection(context, 'Data', [ ListTile( - leading: const SizedBox(width: 24), - title: const Text('Load Route File'), - subtitle: Text(appState.hasSimulatorRoute - ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' - : 'KML or GPX file'), + leading: const Icon(Icons.cloud_queue), + title: const Text('Queued Pings'), + subtitle: Text('${appState.queueSize} items waiting'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () => _pickRouteFile(context, appState), - tooltip: 'Load route file', + icon: const Icon(Icons.cloud_upload), + onPressed: appState.queueSize > 0 + ? () => appState.forceUploadQueue() + : null, + tooltip: 'Force upload', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: appState.queueSize > 0 + ? () => _confirmClearQueue(context, appState) + : null, + tooltip: 'Clear queue', ), - if (appState.hasSimulatorRoute) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - appState.clearSimulatorRoute(); - AppToast.info(context, 'Route cleared'); - }, - tooltip: 'Clear route', - ), ], ), ), - - // Reset Position Button ListTile( - leading: const SizedBox(width: 24), - title: const Text('Reset Position'), - subtitle: Text(appState.hasSimulatorRoute - ? 'Reset to route start' - : 'Reset to Ottawa downtown'), - trailing: IconButton( - icon: const Icon(Icons.restart_alt), - onPressed: () { - appState.resetGpsSimulator(); - AppToast.info( - context, - appState.hasSimulatorRoute - ? 'Reset to route start' - : 'GPS simulator reset to Ottawa downtown', - ); - }, - ), + leading: const Icon(Icons.delete_sweep), + title: const Text('Clear Map Markers'), + subtitle: const Text('Remove all TX/RX markers from map'), + onTap: () => _confirmClearPings(context, appState), ), - ], - ], // Close developerModeEnabled conditional + ]), - // Debug section (always visible on mobile) - if (!kIsWeb) ...[ - const Divider(), - - _buildSectionHeader(context, 'Debug'), + // Offline Sessions + _buildSection(context, 'Offline Sessions', [ + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + onUpload: () => _uploadOfflineSession(context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), + onDownload: () => _downloadOfflineSession(context, appState, session.filename), + )), + ]), + + // About + _buildSection(context, 'About', [ + const ListTile( + leading: Icon(Icons.info_outline), + title: Text(AppConstants.appName), + subtitle: Text('Mesh network coverage mapper'), + ), + ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: const Text('Version'), + subtitle: Text(AppConstants.appVersion), + onTap: () => _onVersionTap(appState), + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('Submit Feedback'), + subtitle: const Text('Report bugs or request features'), + onTap: () => _showBugReportDialog(context, appState), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.github), + title: const Text('GitHub'), + subtitle: const Text('View issues and source code'), + onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Discord'), + subtitle: const Text('Join our community chat'), + onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), + ), + ListTile( + leading: const Icon(Icons.groups), + title: const Text('Community'), + subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + onTap: () => _launchUrl('https://ottawamesh.ca/'), + ), + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), + ]), - SwitchListTile( - secondary: Icon( - Icons.bug_report, - color: appState.debugLogsEnabled ? Colors.orange : null, + // Exit Options (Android only) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + _buildSection(context, 'Exit', [ + SwitchListTile( + secondary: const Icon(Icons.exit_to_app), + title: const Text('Close App After Disconnect'), + subtitle: const Text('Automatically exit the app when disconnecting'), + value: prefs.closeAppAfterDisconnect, + onChanged: (value) => appState.setCloseAppAfterDisconnect(value), ), - title: Row( - children: [ - const Text('Debug Logs'), - if (appState.debugLogsEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LOGGING', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, + ListTile( + leading: const Icon(Icons.power_settings_new, color: Colors.red), + title: const Text('Close App'), + subtitle: const Text('Exit the app completely'), + onTap: () => _showCloseAppConfirmation(context, appState), + ), + ]), + + // Developer Tools - only visible when developer mode is enabled + if (appState.developerModeEnabled) + _buildSection(context, 'Developer Tools', [ + SwitchListTile( + secondary: const Icon(Icons.developer_mode), + title: const Text('Developer Mode'), + subtitle: const Text('Disable to hide developer tools'), + value: appState.developerModeEnabled, + onChanged: (value) { + appState.setDeveloperMode(value); + }, + ), + SwitchListTile( + secondary: Icon( + Icons.gps_fixed, + color: appState.isGpsSimulatorEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('GPS Simulator'), + if (appState.isGpsSimulatorEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SIMULATED', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), - ), + ], ], - ], - ), - subtitle: Text( - appState.debugLogsEnabled - ? 'Writing logs to file' - : 'Enable to save debug logs to device', + ), + subtitle: Text(appState.isGpsSimulatorEnabled + ? 'Smooth simulated movement active' + : 'Use simulated GPS for testing'), + value: appState.isGpsSimulatorEnabled, + onChanged: (value) { + if (value) { + appState.enableGpsSimulator(); + } else { + appState.disableGpsSimulator(); + } + }, ), - value: appState.debugLogsEnabled, - onChanged: (value) async { - if (value) { - await appState.enableDebugLogs(); - } else { - await appState.disableDebugLogs(); - } - }, - ), - - // Log Files List (show when toggle is ON or when files exist) - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ - // Section header with Delete All button - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - 'Debug Log Files', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.grey.shade600, + if (appState.isGpsSimulatorEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Simulation Speed'), + subtitle: Slider( + value: appState.gpsSimulatorSpeed, + min: 10, + max: 120, + divisions: 11, + label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + onChanged: (value) { + appState.setGpsSimulatorSpeed(value); + }, + ), + trailing: Text( + formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Movement Pattern'), + trailing: SizedBox( + width: 180, + child: DropdownButton( + value: appState.gpsSimulatorPattern, + underline: const SizedBox(), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: SimulatorPattern.straight, + child: Text('Straight Line', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.circle, + child: Text('Circle', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.randomWalk, + child: Text('Random Walk', overflow: TextOverflow.ellipsis), + ), + if (appState.hasSimulatorRoute) + DropdownMenuItem( + value: SimulatorPattern.route, + child: Text( + 'Route: ${appState.simulatorRouteName ?? "Loaded"}', + overflow: TextOverflow.ellipsis, + ), ), + ], + onChanged: (pattern) { + if (pattern != null) { + appState.setGpsSimulatorPattern(pattern); + } + }, ), - const Spacer(), - if (appState.debugLogFiles.isNotEmpty) ...[ - TextButton.icon( - icon: const Icon(Icons.cloud_upload, size: 18), - label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Load Route File'), + subtitle: Text(appState.hasSimulatorRoute + ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' + : 'KML or GPX file'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () => _pickRouteFile(context, appState), + tooltip: 'Load route file', ), - TextButton.icon( - icon: const Icon(Icons.delete_sweep, size: 18), - label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + if (appState.hasSimulatorRoute) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + appState.clearSimulatorRoute(); + AppToast.info(context, 'Route cleared'); + }, + tooltip: 'Clear route', + ), + ], + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Reset Position'), + subtitle: Text(appState.hasSimulatorRoute + ? 'Reset to route start' + : 'Reset to Ottawa downtown'), + trailing: IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + appState.resetGpsSimulator(); + AppToast.info( + context, + appState.hasSimulatorRoute + ? 'Reset to route start' + : 'GPS simulator reset to Ottawa downtown', + ); + }, + ), + ), + ], + ]), + + // Debug section (always visible on mobile) + if (!kIsWeb) + _buildSection(context, 'Debug', [ + SwitchListTile( + secondary: Icon( + Icons.bug_report, + color: appState.debugLogsEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('Debug Logs'), + if (appState.debugLogsEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LOGGING', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ), ], ], ), - ), - - // Log files list or empty message - if (appState.debugLogFiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), - ), - ) - else - ...appState.debugLogFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); - final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) - : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; - - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; + subtitle: Text( + appState.debugLogsEnabled + ? 'Writing logs to file' + : 'Enable to save debug logs to device', + ), + value: appState.debugLogsEnabled, + onChanged: (value) async { + if (value) { + await appState.enableDebugLogs(); } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - if (isCurrentLog) { - sizeDisplay = '$sizeDisplay (current)'; + await appState.disableDebugLogs(); } - - return ListTile( - leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), - subtitle: Text( - sizeDisplay, - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), - tooltip: 'View', + }, + ), + if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Text( + 'Log Files', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, + ), + ), + const Spacer(), + if (appState.debugLogFiles.isNotEmpty) ...[ + TextButton.icon( + icon: const Icon(Icons.cloud_upload, size: 18), + label: const Text('Upload'), + onPressed: () => _showUploadLogsDialog(context, appState), ), - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + onPressed: () => _confirmDeleteAllLogs(context, appState), ), ], + ], + ), + ), + if (appState.debugLogFiles.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No debug logs yet', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), ), - ); - }), - ], - ], + ) + else + ...appState.debugLogFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isCurrentLog = index == 0; + final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final fileDate = timestampMatch != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + : null; + final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + if (isCurrentLog) { + sizeDisplay = '$sizeDisplay (current)'; + } - const SizedBox(height: 32), + return ListTile( + leading: const Icon(Icons.description, size: 20), + title: Text(dateStr, style: const TextStyle(fontSize: 13)), + subtitle: Text( + sizeDisplay, + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + onPressed: () => _showLogViewer(context, appState, file), + tooltip: 'View', + ), + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file), + tooltip: 'Share', + ), + ], + ), + ); + }), + ], + ]), ], ), ); } - Widget _buildSectionHeader(BuildContext context, String title) { + Widget _buildSection(BuildContext context, String title, List children) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + 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), + ], + ), ), ); } @@ -1179,6 +1148,45 @@ class _SettingsScreenState extends State { ); } + void _showDeleteChannelInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.delete_sweep, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Delete Channel on Disconnect')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, the #wardriving channel is removed from your radio when you disconnect. ' + 'This keeps your radio\'s channel list clean.\n\n' + 'When disabled, the channel remains on the radio after disconnect.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may retain the #wardriving channel until you reconnect and properly disconnect.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showHopBytesInfo(BuildContext context) { showDialog( context: context, @@ -1907,29 +1915,7 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Icons.location_on, color: _hasAlwaysPermission ? Colors.green : null, ), - title: Row( - children: [ - const Text('Background Location'), - if (_hasAlwaysPermission) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'ALWAYS', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), + title: const Text('Background Location'), subtitle: Text( _hasAlwaysPermission ? 'Location tracking works in background' From 914b6883c34865fc0fa8f8186beabb5f78493298 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 08:44:38 -0400 Subject: [PATCH 028/360] **New Features** - Live repeater snapshot: enable it in Settings to see the top 3 responded repeaters from your last ping, updating live as you wardrive **Improvements** - Redesigned the Log tab - Redesigned the Settings tab --- lib/models/user_preferences.dart | 12 ++++- lib/providers/app_state_provider.dart | 70 +++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 9 ++++ lib/widgets/map_widget.dart | 68 +++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 331519b2..4063fc8b 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -82,6 +82,9 @@ class UserPreferences { /// Auto-stop auto-ping after 30 minutes of idle (no movement) final bool autoStopAfterIdle; + /// Show top 3 repeaters by SNR on the map during wardriving + final bool showTopRepeaters; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -110,6 +113,7 @@ class UserPreferences { this.deleteChannelOnDisconnect = true, this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, + this.showTopRepeaters = false, }); /// Create from JSON (for persistence) @@ -142,6 +146,7 @@ class UserPreferences { deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, + showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, ); } @@ -175,6 +180,7 @@ class UserPreferences { 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, + 'showTopRepeaters': showTopRepeaters, }; } @@ -207,6 +213,7 @@ class UserPreferences { bool? deleteChannelOnDisconnect, int? minPingDistanceMeters, bool? autoStopAfterIdle, + bool? showTopRepeaters, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -236,6 +243,7 @@ class UserPreferences { deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, + showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, ); } @@ -293,7 +301,8 @@ class UserPreferences { other.discDropEnabled == discDropEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && - other.autoStopAfterIdle == autoStopAfterIdle; + other.autoStopAfterIdle == autoStopAfterIdle && + other.showTopRepeaters == showTopRepeaters; } @override @@ -325,6 +334,7 @@ class UserPreferences { deleteChannelOnDisconnect, minPingDistanceMeters, autoStopAfterIdle, + showTopRepeaters, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b152b1be..a223b682 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -165,6 +165,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _discLogEntries = []; final List _traceLogEntries = []; + // Top repeaters overlay — updated live on each ping event + List<({String repeaterId, double snr})> _topRepeatersOverlay = []; + // Targeted mode state String? _targetRepeaterId; @@ -337,6 +340,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isScanning => _isScanning; List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); + + /// Top 3 repeaters by best SNR (1-byte IDs only) across TX echoes and RX observations + List<({String repeaterId, double snr})> get topRepeatersBySnr => _topRepeatersOverlay; + + /// Update the top repeaters overlay with results from the latest ping. + /// New repeaters from this ping take priority (sorted by best SNR first). + /// Remaining slots are filled with carryover from the previous display. + void _updateTopRepeaters(List<({String repeaterId, double snr})> current) { + // Deduplicate current entries, keeping best SNR per repeater + final bestSnr = {}; + for (final r in current) { + final key = r.repeaterId.toUpperCase(); + if (!bestSnr.containsKey(key) || r.snr > bestSnr[key]!) { + bestSnr[key] = r.snr; + } + } + final fresh = bestSnr.entries + .map((e) => (repeaterId: e.key, snr: e.value)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + + if (fresh.length >= 3) { + _topRepeatersOverlay = fresh.take(3).toList(); + } else { + // Fill remaining slots with previous entries not already in fresh + final freshIds = fresh.map((r) => r.repeaterId).toSet(); + final carryover = _topRepeatersOverlay + .where((r) => !freshIds.contains(r.repeaterId)) + .toList(); + _topRepeatersOverlay = [...fresh, ...carryover].take(3).toList(); + } + } List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); @@ -1281,6 +1316,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + // Update top repeaters overlay with this RX observation + if (ping.repeaterId.length == 2) { + _updateTopRepeaters([(repeaterId: ping.repeaterId.toUpperCase(), snr: ping.snr)]); + } + notifyListeners(); }; @@ -1351,6 +1391,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + + // Update top repeaters overlay with current TX echoes + _updateTopRepeaters(existingEvents + .where((e) => e.repeaterId.length == 2 && e.snr != null) + .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList()); + debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); @@ -1395,6 +1442,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isNew) { _audioService.playReceiveSound(); } + + // Update top repeaters overlay with all discovered nodes from this ping + _updateTopRepeaters(discPing.discoveredNodes + .where((n) => n.repeaterId.length == 2) + .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList()); + notifyListeners(); }; @@ -1724,6 +1778,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); + // Update top repeaters overlay immediately + if (observation.repeaterId.length == 2 && observation.snr != null) { + _updateTopRepeaters([(repeaterId: repeaterKey, snr: observation.snr!)]); + } // Play receive sound for new RX observation _audioService.playReceiveSound(); // Record RX event for noise floor graph with location and repeater info @@ -1829,6 +1887,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); + // Update top repeaters overlay with this RX observation + if (entry.repeaterId.length == 2 && entry.snr != null) { + _updateTopRepeaters([(repeaterId: entry.repeaterId.toUpperCase(), snr: entry.snr!)]); + } + // Note: RX count is incremented in onObservation when pin is created (immediate feedback) // Enqueue to API with formatted heard_repeats string @@ -2715,6 +2778,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discLogEntries.clear(); _traceLogEntries.clear(); _errorLogEntries.clear(); + _topRepeatersOverlay = []; notifyListeners(); } @@ -2735,6 +2799,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _traceLogEntries.removeLast(); } debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + + // Update top repeaters overlay with successful trace result + if (entry.success && entry.targetRepeaterId.length == 2 && entry.localSnr != null) { + _updateTopRepeaters([(repeaterId: entry.targetRepeaterId.toUpperCase(), snr: entry.localSnr!)]); + } + notifyListeners(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9c26a4a5..62817d3f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -157,6 +157,15 @@ class _SettingsScreenState extends State { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); }, ), + SwitchListTile( + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: const Text('Show top 3 repeaters by SNR while wardriving'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, + ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), ]), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 55244f90..527b8fde 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -569,11 +569,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map _buildMap(appState, center), - // GPS Info overlay (top-left, respects dynamic island in landscape) + // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( top: topPadding, left: leftPadding, - child: _buildGpsInfoOverlay(appState), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters && appState.autoPingEnabled) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], + ], + ), ), // Map controls - top-right in both orientations, collapsible @@ -721,6 +730,61 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// GPS info overlay (top-left corner) + /// Top 3 repeaters by SNR overlay (bottom-right of map) + Widget _buildTopRepeatersOverlay(AppStateProvider appState) { + final topRepeaters = appState.topRepeatersBySnr; + if (topRepeaters.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final r in topRepeaters) + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + r.repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + const SizedBox(width: 6), + Text( + '${r.snr.toStringAsFixed(1)} dB', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(r.snr), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// SNR color: green > 5, orange -1..5, red <= -1 + static Color _snrColor(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; final hasGps = position != null; From 0a5ad3185642bf8a62f01e8b101b88453cba2fd5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 08:50:20 -0400 Subject: [PATCH 029/360] ### Bug Fixes - Connection screen now shows a "Server Unreachable" message with offline mode suggestion when auth fails due to network errors, instead of a generic error. - Fixed zone check flooding when driving with no network. Previously, every GPS update triggered an API call when the network was down. Position is now tracked even on failed calls, so retries are handled by the 5-second timer instead. ### Improvements - Online/offline toggle redesigned to look more clearly like a button. --- lib/providers/app_state_provider.dart | 9 +++++++++ lib/screens/connection_screen.dart | 12 ++++++++---- lib/widgets/offline_mode_toggle.dart | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a223b682..daa4a299 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -102,6 +102,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; @@ -310,6 +311,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; bool get isAuthError => _isAuthError; + bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; @@ -821,6 +823,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveredDevices = []; _connectionError = null; _isAuthError = false; + _isNetworkError = false; notifyListeners(); // Listen for discovered devices using subscription so stopScan() can cancel @@ -888,6 +891,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _connectionError = null; _isAuthError = false; + _isNetworkError = false; // Clean up any previous connection first if (_meshCoreConnection != null) { @@ -1703,12 +1707,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { _connectionError = 'Authentication failed'; } } else { _isAuthError = false; + _isNetworkError = false; // Provide clean user-facing messages for common BLE errors if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; @@ -3974,6 +3980,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { + // Update position even on failure to prevent zone check flooding + // (without this, every GPS update re-triggers a zone check while driving) + _lastZoneCheckPosition = _currentPosition; debugError('[GEOFENCE] Zone status check failed: no response from API'); _scheduleZoneCheckRetry( seconds: 5, diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index d8134f9d..9f3bb885 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1002,18 +1002,22 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.error_outline, + appState.isNetworkError ? Icons.cloud_off : Icons.error_outline, size: isLandscape ? 48 : 64, - color: Colors.red, + color: appState.isNetworkError ? Colors.orange : Colors.red, ), SizedBox(height: isLandscape ? 8 : 16), Text( - appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + appState.isNetworkError + ? 'Server Unreachable' + : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - appState.connectionError ?? 'Unknown error', + appState.isNetworkError + ? 'MeshMapper services unreachable, try again or use offline mode.' + : appState.connectionError ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart index cfdb9cb2..b54aec58 100644 --- a/lib/widgets/offline_mode_toggle.dart +++ b/lib/widgets/offline_mode_toggle.dart @@ -167,7 +167,7 @@ class OfflineModeToggle extends StatelessWidget { ), const SizedBox(width: 8), Text( - offlineMode ? 'Offline' : 'Online', + offlineMode ? 'Go Online' : 'Go Offline', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, From 7735ef57047d20fcfbc17b85cf3d06234227f6d9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 09:05:06 -0400 Subject: [PATCH 030/360] - Updated landscape controls to match the new portrait layout and added Trace mode support. - Updated the UI for the top heard repeaters display. --- lib/providers/app_state_provider.dart | 19 ++-- lib/screens/settings_screen.dart | 2 +- lib/widgets/map_widget.dart | 88 ++++++++++------ lib/widgets/ping_controls.dart | 140 +++++--------------------- 4 files changed, 96 insertions(+), 153 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index daa4a299..28d93d44 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1321,9 +1321,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); // Update top repeaters overlay with this RX observation - if (ping.repeaterId.length == 2) { - _updateTopRepeaters([(repeaterId: ping.repeaterId.toUpperCase(), snr: ping.snr)]); - } + _updateTopRepeaters([(repeaterId: ping.repeaterId.toUpperCase(), snr: ping.snr)]); notifyListeners(); }; @@ -1398,7 +1396,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update top repeaters overlay with current TX echoes _updateTopRepeaters(existingEvents - .where((e) => e.repeaterId.length == 2 && e.snr != null) + .where((e) => e.snr != null) .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) .toList()); @@ -1449,7 +1447,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update top repeaters overlay with all discovered nodes from this ping _updateTopRepeaters(discPing.discoveredNodes - .where((n) => n.repeaterId.length == 2) .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) .toList()); @@ -1785,7 +1782,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); // Update top repeaters overlay immediately - if (observation.repeaterId.length == 2 && observation.snr != null) { + if (observation.snr != null) { _updateTopRepeaters([(repeaterId: repeaterKey, snr: observation.snr!)]); } // Play receive sound for new RX observation @@ -1894,7 +1891,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); // Update top repeaters overlay with this RX observation - if (entry.repeaterId.length == 2 && entry.snr != null) { + if (entry.snr != null) { _updateTopRepeaters([(repeaterId: entry.repeaterId.toUpperCase(), snr: entry.snr!)]); } @@ -2784,7 +2781,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discLogEntries.clear(); _traceLogEntries.clear(); _errorLogEntries.clear(); - _topRepeatersOverlay = []; notifyListeners(); } @@ -2807,8 +2803,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); // Update top repeaters overlay with successful trace result - if (entry.success && entry.targetRepeaterId.length == 2 && entry.localSnr != null) { - _updateTopRepeaters([(repeaterId: entry.targetRepeaterId.toUpperCase(), snr: entry.localSnr!)]); + if (entry.success && entry.localSnr != null) { + // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay + final id = entry.targetRepeaterId.toUpperCase(); + final displayId = id.length > 6 ? id.substring(0, 6) : id; + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)]); } notifyListeners(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 62817d3f..e0d144f9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -160,7 +160,7 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.cell_tower), title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR while wardriving'), + subtitle: const Text('Show top 3 repeaters by SNR from last ping'), value: prefs.showTopRepeaters, onChanged: (value) { appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 527b8fde..9a1617a6 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -577,7 +577,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildGpsInfoOverlay(appState), - if (appState.preferences.showTopRepeaters && appState.autoPingEnabled) ...[ + if (appState.preferences.showTopRepeaters) ...[ const SizedBox(height: 6), _buildTopRepeatersOverlay(appState), ], @@ -733,7 +733,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Top 3 repeaters by SNR overlay (bottom-right of map) Widget _buildTopRepeatersOverlay(AppStateProvider appState) { final topRepeaters = appState.topRepeatersBySnr; - if (topRepeaters.isEmpty) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -745,34 +744,67 @@ class _MapWidgetState extends State with TickerProviderStateMixin { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final r in topRepeaters) - Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - r.repeaterId, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - const SizedBox(width: 6), - Text( - '${r.snr.toStringAsFixed(1)} dB', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: _snrColor(r.snr), - ), - ), - ], + const Text( + 'Top Heard', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Colors.white54, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + if (topRepeaters.isEmpty) + const Text( + '---', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.white38, ), ), + if (topRepeaters.isNotEmpty) + Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FixedColumnWidth(8), + 2: IntrinsicColumnWidth(), + }, + children: [ + for (final r in topRepeaters) + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + r.repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + r.snr.toStringAsFixed(1), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(r.snr), + ), + ), + ), + ], + ), + ], + ), ], ), ); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index d1e6695f..5c4f2e65 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; -import 'offline_mode_toggle.dart'; import 'repeater_picker_sheet.dart'; /// Modern ping control panel with icon-based buttons and animated status @@ -450,11 +449,13 @@ class _TargetedPingSection extends StatefulWidget { final bool isAnyModeRunning; final bool cooldownActive; final int cooldownRemaining; + final bool compact; const _TargetedPingSection({ required this.isAnyModeRunning, required this.cooldownActive, required this.cooldownRemaining, + this.compact = false, }); @override @@ -578,24 +579,26 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { size: 18, color: effectiveColor, ), - const SizedBox(width: 8), - Expanded( - child: Text( - _isStarting - ? 'Starting...' - : isTargetedRunning - ? (statusText ?? 'Stop') - : widget.cooldownActive - ? 'Cooldown ${widget.cooldownRemaining}s' - : 'Trace Mode', - style: TextStyle( - fontSize: 13, - fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, - color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + if (!widget.compact) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', + style: TextStyle( + fontSize: 13, + fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, + color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + overflow: TextOverflow.ellipsis, ), - overflow: TextOverflow.ellipsis, ), - ), + ], ], ), ), @@ -1148,11 +1151,6 @@ class LandscapePingControls extends StatelessWidget { final prefs = appState.preferences; final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Antenna selector - final soundEnabled = appState.isSoundEnabled; - final offlineMode = appState.offlineMode; - final isConnected = appState.isConnected; - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1248,34 +1246,12 @@ class LandscapePingControls extends StatelessWidget { ), const SizedBox(height: 10), - // Compact row for toggles - Row( - children: [ - Expanded( - child: _LandscapeToggle( - icon: offlineMode ? Icons.cloud_off : Icons.cloud_queue, - label: 'Offline', - isOn: offlineMode, - color: Colors.orange, - onTap: () => OfflineModeToggle.handleOfflineModeToggle( - context, - appState, - offlineMode, - isConnected, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _LandscapeToggle( - icon: soundEnabled ? Icons.volume_up : Icons.volume_off, - label: 'Sound', - isOn: soundEnabled, - color: Colors.blue, - onTap: () => appState.toggleSoundEnabled(), - ), - ), - ], + // Targeted Ping controls (Trace Mode) + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + compact: true, ), ], ); @@ -1574,70 +1550,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> } /// Compact toggle button for landscape panel -class _LandscapeToggle extends StatelessWidget { - final IconData icon; - final String label; - final bool isOn; - final Color color; - final VoidCallback onTap; - - const _LandscapeToggle({ - required this.icon, - required this.label, - required this.isOn, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final activeColor = isOn ? color : colorScheme.onSurfaceVariant; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isOn - ? activeColor.withValues(alpha: 0.12) - : colorScheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isOn - ? activeColor.withValues(alpha: 0.35) - : colorScheme.outline.withValues(alpha: 0.15), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 16, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isOn ? FontWeight.w600 : FontWeight.w500, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} - /// Compact action button for minimized panel - horizontal pill layout /// Supports expanding to show label when active class _CompactActionButton extends StatefulWidget { From 23c58499f7657a0fe17b7638a546c0d126eba73a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 09:13:36 -0400 Subject: [PATCH 031/360] - Added text scale clamping for device accessibility settings. Previously the system text scale factor was applied unclamped across the entire app, which could break layouts at larger text sizes. --- lib/main.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/main.dart b/lib/main.dart index 49efe51b..08ef6efb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -247,6 +247,14 @@ class _ThemedAppState extends State<_ThemedApp> { return MaterialApp( title: 'MeshMapper', + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ), + child: child!, + ); + }, theme: ThemeData( colorScheme: lightColorScheme, scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 From 6cc9d413c75893578146b42f9bbacc7053a9ac22 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 21:39:43 -0400 Subject: [PATCH 032/360] Fixing some font sizing and layouts --- lib/providers/app_state_provider.dart | 96 ++++++++--- lib/screens/log_screen.dart | 37 +++-- lib/screens/settings_screen.dart | 191 +++++++++++++++++----- lib/services/api_service.dart | 223 +++++++++++++++++++------- 4 files changed, 414 insertions(+), 133 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 28d93d44..2f98a53f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -64,6 +64,8 @@ enum OfflineUploadResult { authFailed, /// Some pings failed to upload partialFailure, + /// Another upload is already in progress + uploadInProgress, } /// Main application state provider @@ -484,6 +486,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Offline mode bool get offlineMode => _preferences.offlineMode; List get offlineSessions => _offlineSessionService.sessions; + bool _isUploadingOfflineSession = false; + bool get isUploadingOfflineSession => _isUploadingOfflineSession; // Developer mode bool get developerModeEnabled => _preferences.developerModeEnabled; @@ -3319,14 +3323,42 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Upload an offline session with authenticated API session - /// Uses stored device credentials to authenticate before uploading + /// Uses stored device credentials to authenticate before uploading. + /// Session is fully isolated from the shared ApiService state — offline uploads + /// never touch _sessionId and cannot trigger BLE disconnect on failure. /// + /// @param onProgress Optional callback for progress updates (e.g., "Batch 1/3") /// Returns the result of the upload operation - Future uploadOfflineSessionWithAuth(String filename) async { + Future uploadOfflineSessionWithAuth( + String filename, { + void Function(String status)? onProgress, + }) async { + // Concurrency guard — only one offline upload at a time + if (_isUploadingOfflineSession) { + debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + return OfflineUploadResult.uploadInProgress; + } + + _isUploadingOfflineSession = true; + notifyListeners(); + + try { + return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + } finally { + _isUploadingOfflineSession = false; + notifyListeners(); + } + } + + /// Internal implementation of offline session upload with isolated session + Future _uploadOfflineSessionIsolated( + String filename, { + void Function(String status)? onProgress, + }) async { // 1. Get session with stored device credentials final session = _offlineSessionService.getSession(filename); if (session == null) { - debugLog('[APP] Offline session not found: $filename'); + debugLog('[OFFLINE] Session not found: $filename'); return OfflineUploadResult.notFound; } @@ -3337,25 +3369,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(); if (pings == null || pings.isEmpty) { - debugLog('[APP] Offline session has no pings: $filename'); + debugLog('[OFFLINE] Session has no pings: $filename'); return OfflineUploadResult.invalidSession; } // 2. Get device credentials from session final publicKey = session.devicePublicKey; if (publicKey == null) { - debugLog('[APP] Offline session missing device public key: $filename'); + debugLog('[OFFLINE] Session missing device public key: $filename'); return OfflineUploadResult.invalidSession; } final deviceName = session.deviceName; if (deviceName == null || deviceName.isEmpty) { - debugLog('[APP] Offline session missing device name: $filename'); + debugLog('[OFFLINE] Session missing device name: $filename'); return OfflineUploadResult.invalidSession; } - // 3. Authenticate with offline_mode: true - debugLog('[APP] Authenticating for offline upload with device: $deviceName'); + onProgress?.call('Authenticating...'); + + // 3. Authenticate with offline_mode: true, skipSessionStore: true + // This prevents writing to shared _sessionId/_txAllowed/etc. + debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3368,22 +3403,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, offlineMode: true, + skipSessionStore: true, ); Map? effectiveAuth = authResult; if (authResult == null) { - debugError('[API] Offline upload auth failed: network error'); + debugError('[OFFLINE] Auth failed: network error'); return OfflineUploadResult.authFailed; } if (authResult['success'] != true) { final reason = authResult['reason'] as String? ?? 'unknown'; - debugLog('[API] Offline upload Stage 1 failed: $reason'); + debugLog('[OFFLINE] Stage 1 failed: $reason'); // Stage 2: If unknown_device and we have a stored contactUri, attempt registration if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[APP] Stage 2: Attempting registration via stored contact URI...'); + debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); final registerResult = await _apiService.requestAuth( reason: 'register', contactUri: session.contactUri, @@ -3396,62 +3432,76 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, offlineMode: true, + skipSessionStore: true, ); if (registerResult == null || registerResult['success'] != true) { final regReason = registerResult?['reason'] as String? ?? 'unknown'; - debugError('[API] Offline upload Stage 2 registration failed: $regReason'); + debugError('[OFFLINE] Stage 2 registration failed: $regReason'); return OfflineUploadResult.authFailed; } - debugLog('[APP] Stage 2 succeeded: device registered for offline upload'); + debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); effectiveAuth = registerResult; } else { - debugError('[API] Offline upload auth failed: $reason'); + debugError('[OFFLINE] Auth failed: $reason'); return OfflineUploadResult.authFailed; } } - debugLog('[APP] Offline upload authenticated, session: ${effectiveAuth!['session_id']}'); + // Extract session_id into local variable — never stored in shared state + final offlineSessionId = effectiveAuth!['session_id'] as String?; + if (offlineSessionId == null) { + debugError('[OFFLINE] Auth succeeded but no session_id in response'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); - // 4. Upload pings in batches of 50 + // 4. Upload pings in batches of 50 using isolated session const batchSize = 50; var uploadedCount = 0; var failedBatches = 0; + final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { + final batchNum = (i ~/ batchSize) + 1; + onProgress?.call('Batch $batchNum/$totalBatches'); + final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatch(batch); + final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; - debugLog('[APP] Uploaded batch ${(i ~/ batchSize) + 1}: ${batch.length} pings'); + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); } else { failedBatches++; - debugError('[APP] Failed to upload batch ${(i ~/ batchSize) + 1}'); + debugError('[OFFLINE] Failed to upload batch $batchNum'); } } // Delay after posting before disconnect await Future.delayed(const Duration(seconds: 1)); - // 5. Release API session + // 5. Release isolated API session (does not clear shared state) + onProgress?.call('Finalizing...'); await _apiService.requestAuth( reason: 'disconnect', publicKey: publicKey, + sessionId: offlineSessionId, ); - debugLog('[APP] Offline upload session released'); + debugLog('[OFFLINE] Isolated upload session released'); // 6. Mark session as uploaded (don't delete) if all batches succeeded if (failedBatches == 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[API] Uploaded ${pings.length} pings from $filename'); + debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[API] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 53598dd1..97267e0b 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -501,7 +501,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( label, style: TextStyle( - fontSize: 12, + fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w500, color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), @@ -509,17 +509,20 @@ class _AllPingsTabState extends State<_AllPingsTab> { if (count > 0) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + constraints: const BoxConstraints(minWidth: 18, minHeight: 16), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), + alignment: Alignment.center, child: Text( count > 99 ? '99+' : count.toString(), style: const TextStyle( - fontSize: 9, + fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white, + height: 1.0, ), ), ), @@ -560,8 +563,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Text( label, style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, + fontSize: 12, + fontWeight: FontWeight.bold, color: color, ), ), @@ -583,7 +586,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Text( value, style: TextStyle( - fontSize: 10, + fontSize: 11, fontWeight: FontWeight.w600, color: color, fontFamily: 'monospace', @@ -618,7 +621,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( 'No repeaters heard', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -666,7 +669,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 60), + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), ], @@ -722,7 +725,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 60), + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), ], @@ -789,7 +792,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( 'No nodes discovered', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -824,11 +827,11 @@ class _AllPingsTabState extends State<_AllPingsTab> { width: 70, child: Row( children: [ - Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11)), + Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: const TextStyle( - fontSize: 9, + fontSize: 11, fontWeight: FontWeight.w500, color: Color(0xFF7B68EE), ), @@ -896,7 +899,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( 'No response', style: TextStyle( - fontSize: 11, + fontSize: 12, color: colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -918,7 +921,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 11)), + SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), @@ -946,7 +949,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( timeString, style: TextStyle( - fontSize: 12, + fontSize: 14, fontWeight: FontWeight.w600, fontFamily: 'monospace', color: Theme.of(context).colorScheme.onSurface, @@ -958,7 +961,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( locationString, style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontFamily: 'monospace', ), @@ -972,7 +975,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { text, textAlign: center ? TextAlign.center : TextAlign.left, style: TextStyle( - fontSize: 10, + fontSize: 12, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e0d144f9..c99446f8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -233,13 +233,16 @@ class _SettingsScreenState extends State { children: [ const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHybridModeInfo(context), - child: Icon( + IconButton( + onPressed: () => _showHybridModeInfo(context), + icon: Icon( Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), @@ -260,13 +263,16 @@ class _SettingsScreenState extends State { children: [ const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), - GestureDetector( - onTap: () => _showDiscDropInfo(context), - child: Icon( + IconButton( + onPressed: () => _showDiscDropInfo(context), + icon: Icon( Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), @@ -278,7 +284,11 @@ class _SettingsScreenState extends State { : const Text('Count failed discoveries as failed pings'), value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - appState.updatePreferences(prefs.copyWith(discDropEnabled: value)); + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); + } }, ), ]), @@ -336,13 +346,16 @@ class _SettingsScreenState extends State { children: [ const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHopBytesInfo(context), - child: Icon( + IconButton( + onPressed: () => _showHopBytesInfo(context), + icon: Icon( Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), @@ -383,13 +396,16 @@ class _SettingsScreenState extends State { children: [ const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), - GestureDetector( - onTap: () => _showTraceBytesInfo(context), - child: Icon( + IconButton( + onPressed: () => _showTraceBytesInfo(context), + icon: Icon( Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), @@ -425,16 +441,22 @@ class _SettingsScreenState extends State { children: [ const Flexible(child: Text('Delete Channel on Disconnect')), const SizedBox(width: 4), - GestureDetector( - onTap: () => _showDeleteChannelInfo(context), - child: Icon( + IconButton( + onPressed: () => _showDeleteChannelInfo(context), + icon: Icon( Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), + subtitle: Text(prefs.deleteChannelOnDisconnect + ? 'Removes #wardriving channel from device' + : 'Keeps #wardriving channel on device'), value: prefs.deleteChannelOnDisconnect, onChanged: (value) { appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); @@ -493,6 +515,7 @@ class _SettingsScreenState extends State { else ...appState.offlineSessions.map((session) => _OfflineSessionTile( session: session, + uploadEnabled: !appState.isUploadingOfflineSession, onUpload: () => _uploadOfflineSession(context, appState, session.filename), onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), onDownload: () => _downloadOfflineSession(context, appState, session.filename), @@ -1157,6 +1180,51 @@ class _SettingsScreenState extends State { ); } + void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith(discDropEnabled: true), + ); + }, + child: const Text('Enable'), + ), + ], + ), + ); + } + void _showDeleteChannelInfo(BuildContext context) { showDialog( context: context, @@ -1332,13 +1400,22 @@ class _SettingsScreenState extends State { } final tile = RadioListTile( - title: Text('$interval seconds'), + title: Text( + '$interval seconds', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), subtitle: isDisabled ? const Text( 'Set by Regional Admin — slower intervals reduce congestion in your region', - style: TextStyle(color: Colors.amber), + style: TextStyle(fontSize: 12, color: Colors.amber), ) - : Text(description), + : Text( + description, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), value: interval, ); @@ -1563,31 +1640,61 @@ class _SettingsScreenState extends State { } Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { - // Show loading indicator - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ), - SizedBox(width: 12), - Text('Uploading session...'), - ], + // Progress text notifier for updating dialog without rebuilding screen + final progressNotifier = ValueNotifier('Authenticating...'); + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + 'Uploading session...', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + filename, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, status, __) => Text( + status, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ], + ), ), - duration: Duration(seconds: 30), // Will be dismissed when upload completes ), ); - final result = await appState.uploadOfflineSessionWithAuth(filename); + final result = await appState.uploadOfflineSessionWithAuth( + filename, + onProgress: (status) => progressNotifier.value = status, + ); + // Close progress dialog if (context.mounted) { - // Dismiss loading indicator - ScaffoldMessenger.of(context).hideCurrentSnackBar(); + Navigator.of(context).pop(); + } - // Show result + progressNotifier.dispose(); + + if (context.mounted) { + // Show result via SnackBar String message; Color backgroundColor; @@ -1612,6 +1719,10 @@ class _SettingsScreenState extends State { message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.uploadInProgress: + message = 'Another upload is already in progress'; + backgroundColor = Colors.orange; + break; } ScaffoldMessenger.of(context).showSnackBar( @@ -1948,12 +2059,14 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> /// Widget for displaying an offline session in the list class _OfflineSessionTile extends StatelessWidget { final OfflineSession session; + final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; final VoidCallback onDownload; const _OfflineSessionTile({ required this.session, + this.uploadEnabled = true, required this.onUpload, required this.onDelete, required this.onDownload, @@ -2000,9 +2113,9 @@ class _OfflineSessionTile extends StatelessWidget { if (!isUploaded) IconButton( icon: const Icon(Icons.cloud_upload), - onPressed: onUpload, + onPressed: uploadEnabled ? onUpload : null, tooltip: 'Upload session', - color: Colors.green, + color: uploadEnabled ? Colors.green : Colors.grey, ), // Delete button - always available IconButton( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index aed7fb7e..064c25a5 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -224,6 +224,8 @@ class ApiService { /// @param publicKey Device public key (for existing auth flow) /// @param contactUri Signed contact URI (for registration flow) /// @param offlineMode Set to true when uploading offline session data + /// @param skipSessionStore When true, does not write to shared _sessionId/_txAllowed/etc. Caller manages session locally. + /// @param sessionId Explicit session ID for disconnect. When provided, disconnect uses this instead of _sessionId and skips _clearSession(). /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, @@ -238,6 +240,8 @@ class ApiService { double? lon, double? accuracyMeters, bool offlineMode = false, + bool skipSessionStore = false, + String? sessionId, }) async { final stopwatch = Stopwatch()..start(); try { @@ -278,8 +282,8 @@ class ApiService { 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, }; } else { - // For disconnect: add session_id - payload['session_id'] = _sessionId; + // For disconnect: use explicit sessionId if provided, otherwise shared _sessionId + payload['session_id'] = sessionId ?? _sessionId; } final response = await _client.post( @@ -316,66 +320,71 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) if ((reason == 'connect' || reason == 'register') && data['success'] == true) { - _sessionId = data['session_id'] as String?; - _txAllowed = data['tx_allowed'] == true; - _rxAllowed = data['rx_allowed'] == true; - _sessionExpiresAt = data['expires_at'] as int?; - - // Parse channels array from auth response - final channelsData = data['channels']; - if (channelsData is List) { - _channels = channelsData.cast().toList(); - debugLog('[API] Regional channels: $_channels'); - } else { - _channels = []; - } + if (!skipSessionStore) { + _sessionId = data['session_id'] as String?; + _txAllowed = data['tx_allowed'] == true; + _rxAllowed = data['rx_allowed'] == true; + _sessionExpiresAt = data['expires_at'] as int?; + + // Parse channels array from auth response + final channelsData = data['channels']; + if (channelsData is List) { + _channels = channelsData.cast().toList(); + debugLog('[API] Regional channels: $_channels'); + } else { + _channels = []; + } - // Parse scopes array from auth response - final scopesData = data['scopes']; - if (scopesData is List && scopesData.isNotEmpty) { - _scopes = scopesData.cast().toList(); - debugLog('[API] Regional scopes: $_scopes'); - } else { - _scopes = []; - } + // Parse scopes array from auth response + final scopesData = data['scopes']; + if (scopesData is List && scopesData.isNotEmpty) { + _scopes = scopesData.cast().toList(); + debugLog('[API] Regional scopes: $_scopes'); + } else { + _scopes = []; + } - // Parse enforce_hybrid flag from auth response - _enforceHybrid = data['enforce_hybrid'] == true; - if (_enforceHybrid) { - debugLog('[API] Regional admin enforces hybrid mode'); - } + // Parse enforce_hybrid flag from auth response + _enforceHybrid = data['enforce_hybrid'] == true; + if (_enforceHybrid) { + debugLog('[API] Regional admin enforces hybrid mode'); + } - // Parse disc_drop flag from auth response - _enforceDiscDrop = data['disc_drop'] == true; - if (_enforceDiscDrop) { - debugLog('[API] Regional admin enforces discovery drop'); - } + // Parse disc_drop flag from auth response + _enforceDiscDrop = data['disc_drop'] == true; + if (_enforceDiscDrop) { + debugLog('[API] Regional admin enforces discovery drop'); + } - // Parse min_mode_interval from auth response - final minInterval = data['min_mode_interval']; - if (minInterval is int && minInterval > 0) { - _minModeInterval = minInterval; - debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); - } else { - _minModeInterval = 15; - } + // Parse min_mode_interval from auth response + final minInterval = data['min_mode_interval']; + if (minInterval is int && minInterval > 0) { + _minModeInterval = minInterval; + debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); + } else { + _minModeInterval = 15; + } - // Parse hop_bytes from auth response - final hopBytes = data['hop_bytes']; - if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { - _apiHopBytes = hopBytes; - if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + // Parse hop_bytes from auth response + final hopBytes = data['hop_bytes']; + if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { + _apiHopBytes = hopBytes; + if (_apiHopBytes > 1) { + debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + } + } else { + _apiHopBytes = 1; } - } else { - _apiHopBytes = 1; - } - // Note: Heartbeat is enabled by AppStateProvider when auto mode starts - // (not on initial auth, since heartbeat is only for auto mode) + // Note: Heartbeat is enabled by AppStateProvider when auto mode starts + // (not on initial auth, since heartbeat is only for auto mode) + } } else if (reason == 'disconnect') { - // Clear session on disconnect - _clearSession(); + // Only clear shared session when no explicit sessionId was provided + // (explicit sessionId means caller manages its own session lifecycle) + if (sessionId == null) { + _clearSession(); + } } return data; @@ -861,6 +870,112 @@ class ApiService { } } + /// Submit wardrive data using an explicit session ID (for offline uploads) + /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state + Future?> submitWardriveDataWithSessionId( + List> entries, + String sessionId, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': entries, + }; + + final response = await _client.post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ).timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); + rethrow; + } + + final antennaSummary = entries.map((e) => + '${e['type']}:external_antenna=${e['external_antenna']}' + ).join(', '); + _logApiCall( + endpoint: '/wardrive-api.php/wardrive (offline)', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: {'data': '${entries.length} items', 'items': antennaSummary}, + response: data, + ); + + // Do NOT update shared _sessionExpiresAt or schedule heartbeat + return data; + } catch (e) { + stopwatch.stop(); + debugError('[API] POST /wardrive-api.php/wardrive (offline) failed: $e'); + return null; + } + } + + /// Upload batch using explicit session ID (for offline uploads) + /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode + Future uploadBatchWithSessionId( + List> pings, + String sessionId, + ) async { + if (pings.isEmpty) return UploadResult.success; + + try { + final result = await submitWardriveDataWithSessionId(pings, sessionId); + + if (result == null) { + debugError('[API] Offline upload batch failed: no response'); + return UploadResult.retryable; + } + + if (result['success'] == true) { + debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); + return UploadResult.success; + } + + final reason = result['reason'] as String?; + + // For offline uploads, session/auth errors are non-retryable but do NOT cascade + const criticalErrors = { + 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', + 'invalid_key', 'unauthorized', 'bad_key', + 'outside_zone', 'zone_full', 'zone_disabled', + }; + if (criticalErrors.contains(reason)) { + debugError('[API] Offline upload batch session error: $reason'); + return UploadResult.nonRetryable; + } + + const nonRetryableErrors = { + 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + }; + if (nonRetryableErrors.contains(reason)) { + debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + return UploadResult.nonRetryable; + } + + return UploadResult.retryable; + } catch (e) { + debugError('[API] Offline upload batch exception: $e'); + return UploadResult.retryable; + } + } + /// Dispose of resources void dispose() { _heartbeatTimer?.cancel(); From eccc88d20978cee5312bc935b4154e507ecace04 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 17 Mar 2026 22:02:42 -0400 Subject: [PATCH 033/360] Fixed trace mode chip and trace icon --- lib/screens/home_screen.dart | 11 +++++++++++ lib/widgets/ping_controls.dart | 4 ++-- lib/widgets/status_bar.dart | 13 +++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6050b5cc..d984a989 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -175,6 +175,14 @@ class _HomeScreenState extends State { onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), + // Trace count + _buildAppBarStatChip( + Icons.route, + appState.pingStats.traceCount, + Colors.cyan, + onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + ), + const SizedBox(width: 8), // Upload count _buildAppBarStatChip( Icons.cloud_done, @@ -331,6 +339,9 @@ class _HomeScreenState extends State { case 'disc': return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 5c4f2e65..805417de 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -575,7 +575,7 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { child: Row( children: [ Icon( - Icons.gps_fixed, + Icons.route, size: 18, color: effectiveColor, ), @@ -896,7 +896,7 @@ class _CompactPingControlsState extends State { // Build trace mode button (only used when hasTargetRepeaterId) final traceModeButton = _CompactActionButton( - icon: Icons.gps_fixed, + icon: Icons.route, label: _getTraceModeLabel( isTargetedRunning: isTargetedRunning, discoveryWindowActive: discoveryWindowActive, diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 90fa4aff..563d28f6 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -158,6 +158,9 @@ class _StatusBarState extends State { case 'disc': return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -262,6 +265,16 @@ class _StatusBarState extends State { const SizedBox(width: 8), + // Trace count chip (animated) + _AnimatedStatChip( + icon: Icons.route, + value: appState.pingStats.traceCount, + color: Colors.cyan, + onTap: () => _showInfoPopup(context, 'trace'), + ), + + const SizedBox(width: 8), + // Uploaded count chip (animated) _AnimatedStatChip( icon: Icons.cloud_done, From e4e9faededd7ac8ed12b3859f5de4d668d2cec6a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 19 Mar 2026 21:24:37 -0400 Subject: [PATCH 034/360] ### New Features - Persistent radio power overrides: custom TX power settings are now saved per device, similar to antenna selection. On connect, your previously set power override is automatically applied with a visual indicator showing it's been overridden. A "Reset to Auto" button in the power selector restores the auto-detected power for your device model. ### Improvements - Redesigned the Top Heard overlay. Now shows the repeaters that heard your most recent ping sorted by SNR, with color-coded indicators per ping type (green for TX, purple for Discovery, cyan for Trace, blue for RX). Top 3 slots show the best results from your latest ping and fully replace on each new ping. A separate RX slot shows the strongest passively overheard repeater within a rolling 5-second window. Overlay clears on auto-ping stop, mode switch, disconnect, or log clear. --- lib/providers/app_state_provider.dart | 162 +++++++++++++++++++++----- lib/screens/connection_screen.dart | 48 ++++++-- lib/widgets/map_widget.dart | 106 +++++++++++------ 3 files changed, 244 insertions(+), 72 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2f98a53f..745b96f0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -52,6 +52,9 @@ enum AutoMode { targeted, } +/// Ping type for the top-heard overlay dots +enum OverlayPingType { tx, disc, trace, rx } + /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully @@ -169,7 +172,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _traceLogEntries = []; // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr})> _topRepeatersOverlay = []; + List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + ({String repeaterId, double snr})? _rxOverlaySlot; + Timer? _rxOverlayWindowTimer; // Targeted mode state String? _targetRepeaterId; @@ -191,6 +196,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _antennaRestoredFromDevice = false; bool get antennaRestoredFromDevice => _antennaRestoredFromDevice; + /// Per-device power overrides: maps companion name → {powerLevel, txPower} + Map> _devicePowerOverrides = {}; + + /// Whether the current power setting was auto-restored from a saved override + bool _powerRestoredFromDevice = false; + bool get powerRestoredFromDevice => _powerRestoredFromDevice; + // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; @@ -345,14 +357,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); - /// Top 3 repeaters by best SNR (1-byte IDs only) across TX echoes and RX observations - List<({String repeaterId, double snr})> get topRepeatersBySnr => _topRepeatersOverlay; + /// Top 3 repeaters by best SNR from TX/DISC/Trace pings + List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window + ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; - /// Update the top repeaters overlay with results from the latest ping. - /// New repeaters from this ping take priority (sorted by best SNR first). - /// Remaining slots are filled with carryover from the previous display. - void _updateTopRepeaters(List<({String repeaterId, double snr})> current) { - // Deduplicate current entries, keeping best SNR per repeater + /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. + /// Replaces all 3 slots entirely (no carryover from previous pings). + void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -361,21 +373,34 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } final fresh = bestSnr.entries - .map((e) => (repeaterId: e.key, snr: e.value)) + .map((e) => (repeaterId: e.key, snr: e.value, type: type)) .toList() ..sort((a, b) => b.snr.compareTo(a.snr)); + _topRepeatersOverlay = fresh.take(3).toList(); + } - if (fresh.length >= 3) { - _topRepeatersOverlay = fresh.take(3).toList(); + /// Update the RX overlay slot with a 5-second rolling window (best SNR wins). + void _updateRxOverlaySlot(String repeaterId, double snr) { + final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); + if (_rxOverlayWindowTimer?.isActive ?? false) { + if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { + _rxOverlaySlot = entry; + } } else { - // Fill remaining slots with previous entries not already in fresh - final freshIds = fresh.map((r) => r.repeaterId).toSet(); - final carryover = _topRepeatersOverlay - .where((r) => !freshIds.contains(r.repeaterId)) - .toList(); - _topRepeatersOverlay = [...fresh, ...carryover].take(3).toList(); + _rxOverlaySlot = entry; + _rxOverlayWindowTimer = Timer(const Duration(seconds: 5), () { + // Window closed — slot stays until next RX or cleared + }); } } + + /// Clear all overlay state (top 3 + RX slot). + void _clearOverlayState() { + _topRepeatersOverlay = []; + _rxOverlaySlot = null; + _rxOverlayWindowTimer?.cancel(); + _rxOverlayWindowTimer = null; + } List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); @@ -616,6 +641,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Loading preferences...'); await _loadPreferences(); await _loadDeviceAntennaPreferences(); + await _loadDevicePowerOverrides(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -1162,8 +1188,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { powerLevel: device.power, txPower: device.txPower, autoPowerSet: true, // Indicates power was auto-detected from device model + powerLevelSet: false, // Clear stale manual flag from previous session ); - // TODO: Persist to SharedPreferences when implemented notifyListeners(); debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } @@ -1324,8 +1350,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); - // Update top repeaters overlay with this RX observation - _updateTopRepeaters([(repeaterId: ping.repeaterId.toUpperCase(), snr: ping.snr)]); + // Update RX overlay slot with this RX observation + _updateRxOverlaySlot(ping.repeaterId, ping.snr); notifyListeners(); }; @@ -1402,7 +1428,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _updateTopRepeaters(existingEvents .where((e) => e.snr != null) .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList()); + .toList(), OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); @@ -1452,7 +1478,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update top repeaters overlay with all discovered nodes from this ping _updateTopRepeaters(discPing.discoveredNodes .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList()); + .toList(), OverlayPingType.disc); notifyListeners(); }; @@ -1652,6 +1678,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Restore per-device power override if previously saved + if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } + // Log connection status based on TX/RX permissions if (hasApiSession) { if (txAllowed && rxAllowed) { @@ -1785,9 +1826,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); - // Update top repeaters overlay immediately + // Update RX overlay slot immediately if (observation.snr != null) { - _updateTopRepeaters([(repeaterId: repeaterKey, snr: observation.snr!)]); + _updateRxOverlaySlot(repeaterKey, observation.snr!); } // Play receive sound for new RX observation _audioService.playReceiveSound(); @@ -1894,9 +1935,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); - // Update top repeaters overlay with this RX observation + // Update RX overlay slot with this RX observation if (entry.snr != null) { - _updateTopRepeaters([(repeaterId: entry.repeaterId.toUpperCase(), snr: entry.snr!)]); + _updateRxOverlaySlot(entry.repeaterId, entry.snr!); } // Note: RX count is incremented in onObservation when pin is created (immediate feedback) @@ -2183,6 +2224,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAnonymousRenamed = false; _originalDeviceName = null; + // Clear top-heard overlay + _clearOverlayState(); + // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; @@ -2359,8 +2403,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - // Reset antenna setting so user must choose again on next connect + // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); @@ -2499,6 +2544,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; @@ -2666,6 +2712,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingEnabled = false; _idleAutoStopReference = null; + // Clear top-heard overlay on stop + _clearOverlayState(); + // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed if (isTxMode) { @@ -2698,6 +2747,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop countdown timers when switching modes _autoPingTimer.stop(); _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); // Save offline session if offline mode is enabled if (_preferences.offlineMode) { await _saveOfflineSession(); @@ -2774,6 +2825,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); } @@ -2785,6 +2837,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discLogEntries.clear(); _traceLogEntries.clear(); _errorLogEntries.clear(); + _clearOverlayState(); notifyListeners(); } @@ -2811,7 +2864,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)]); + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); } notifyListeners(); @@ -3530,8 +3583,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = preferences; - // Clear restored flag — user is making a manual choice now + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; @@ -3541,6 +3595,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } + // Persist power override per device name + if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + _devicePowerOverrides[deviceName] = { + 'powerLevel': preferences.powerLevel, + 'txPower': preferences.txPower, + }; + _saveDevicePowerOverrides(); + debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + } else if (deviceName != null && preferences.autoPowerSet) { + // User re-selected the auto-detected value — clear any saved override + if (_devicePowerOverrides.remove(deviceName) != null) { + _saveDevicePowerOverrides(); + debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + } + } + // Propagate RSSI filter setting to live trackers/validators _syncRssiFilterSetting(preferences.disableRssiFilter); @@ -4693,6 +4763,40 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // Device Power Override Persistence + // ============================================ + + /// Load per-device power overrides from Hive storage + Future _loadDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_power_overrides'); + if (raw != null) { + _devicePowerOverrides = (raw as Map).map( + (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + ); + debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device power overrides: $e'); + } + } + + /// Save per-device power overrides to Hive storage + Future _saveDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_power_overrides', _devicePowerOverrides); + } catch (e) { + debugLog('[APP] Failed to save device power overrides: $e'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 9f3bb885..ce97f871 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -500,6 +500,18 @@ class _ConnectionScreenState extends State with WidgetsBinding fontWeight: FontWeight.bold, ), ), + ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 14, color: Colors.orange), + const SizedBox(width: 2), + const Text( + 'Override', + style: TextStyle( + fontSize: 12, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), ], if (!isAutoMode) ...[ const SizedBox(width: 4), @@ -867,6 +879,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, // Clear auto flag on override + powerLevelSet: true, // Mark as manually overridden ), ); Navigator.pop(context); // Close confirmation @@ -899,23 +912,29 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Auto-detection info banner - if (prefs.autoPowerSet && deviceModel != null) + // Auto-detection / override info banner + if (deviceModel != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - border: Border.all(color: Colors.green), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), + border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const Icon(Icons.auto_awesome, size: 20, color: Colors.green), + Icon( + prefs.autoPowerSet ? Icons.auto_awesome : Icons.edit, + size: 20, + color: prefs.autoPowerSet ? Colors.green : Colors.orange, + ), const SizedBox(width: 8), Expanded( child: Text( - 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W', + prefs.autoPowerSet + ? 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W' + : 'Override active \u2014 ${deviceModel.shortName} auto-detects as ${deviceModel.power}W', style: const TextStyle(fontSize: 12), ), ), @@ -935,7 +954,7 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = prefs.autoPowerSet && deviceModel != null && power == deviceModel.power; + final isRecommended = deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -982,6 +1001,21 @@ class _ConnectionScreenState extends State with WidgetsBinding ], ), actions: [ + if (!prefs.autoPowerSet && deviceModel != null) + TextButton( + onPressed: () { + appState.updatePreferences( + prefs.copyWith( + powerLevel: deviceModel.power, + txPower: deviceModel.txPower, + autoPowerSet: true, + powerLevelSet: false, + ), + ); + Navigator.pop(context); + }, + child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 9a1617a6..7d2e2ef5 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -729,10 +729,70 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Color for the overlay ping-type dot + static Color _overlayTypeColor(OverlayPingType type) { + return switch (type) { + OverlayPingType.tx => Colors.green, + OverlayPingType.disc => Colors.purple, + OverlayPingType.trace => Colors.cyan, + OverlayPingType.rx => Colors.blue, + }; + } + + /// Build a single overlay table row with colored dot, repeater ID, and SNR + TableRow _overlayRow(String repeaterId, double snr, Color dotColor) { + return TableRow( + children: [ + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + snr.toStringAsFixed(1), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(snr), + ), + ), + ), + ], + ); + } + /// GPS info overlay (top-left corner) - /// Top 3 repeaters by SNR overlay (bottom-right of map) + /// Top heard repeaters overlay (bottom-right of map) Widget _buildTopRepeatersOverlay(AppStateProvider appState) { final topRepeaters = appState.topRepeatersBySnr; + final rxSlot = appState.rxOverlaySlot; + final isEmpty = topRepeaters.isEmpty && rxSlot == null; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -754,7 +814,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), const SizedBox(height: 2), - if (topRepeaters.isEmpty) + if (isEmpty) const Text( '---', style: TextStyle( @@ -763,46 +823,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { color: Colors.white38, ), ), - if (topRepeaters.isNotEmpty) + if (!isEmpty) Table( defaultColumnWidth: const IntrinsicColumnWidth(), columnWidths: const { - 0: IntrinsicColumnWidth(), - 1: FixedColumnWidth(8), - 2: IntrinsicColumnWidth(), + 0: IntrinsicColumnWidth(), // dot + 1: IntrinsicColumnWidth(), // ID + 2: FixedColumnWidth(8), // spacer + 3: IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) - TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Text( - r.repeaterId, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - ), - const SizedBox(), - Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Text( - r.snr.toStringAsFixed(1), - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: _snrColor(r.snr), - ), - ), - ), - ], - ), + _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), + if (rxSlot != null) + _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), ], ), ], From da9e08fad6eaab64822eeb58cdbc1ab07275a7bf Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:06:32 -0400 Subject: [PATCH 035/360] v1.2.0 Release merge (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add offline mode prompt when zone check fails due to no internet When the zone status check fails with a network error, show a full-screen UI with an "Enable Offline Mode" button (matching the maintenance mode pattern) instead of the generic "Zone Check Failed" message. Also adds a red "No Internet" indicator in the zone status bar. Non-network errors retain the existing behavior. Co-Authored-By: Claude Opus 4.6 * Added option to disable carpeater RSSI filter under settings * Multi-hop CARpeater packets now strip the CARpeater hop and report coverage from the underlying repeater with null SNR/RSSI, instead of being dropped entirely. Single-hop packets and discovery responses are still dropped. The -30 dBm failsafe is bypassed for packets matching your CARpeater prefix but stays active for everything else. * Added Code of Conduct & Security * Update links * Exclude .vscode/ from version control Editor-specific configs shouldn't be tracked in a public repo so contributors can use their own setups. Co-Authored-By: Claude Opus 4.6 * Bug fixes: - [#98](https://github.com/MeshMapper/MeshMapper_Project/issues/98) - The heard repeaters panel now scrolls and dynamically expands to accommodate larger lists of heard repeaters - [#95](https://github.com/MeshMapper/MeshMapper_Project/issues/95) - Fixed the IATA popup panel being hidden behind the Android navigation bar - Fixed a bug where a stale BLE device name was used instead of the current one * Anonymous Mode — New privacy option in Settings that renames your device to "Anonymous" for all mesh pings. When enabled, other mesh users cannot see your companion name in wardrive broadcasts. Your device's public key is still used to authenticate your session with the MeshMapper API (geo-auth), but neither your sessions nor your pings are linked to it on the server. Toggling Anonymous Mode while connected automatically disconnects and reconnects with the new identity, switching to the Connection tab so you can see reconnection progress. On proper disconnect, the original device name is restored. Persisted across app restarts. * Update repeater endpoint from /repeaters.json to /get_repeaters.php Co-Authored-By: Claude Opus 4.6 * ### New Features - Automatic map tile refresh after successful data upload, so new coverage appears on the map within the app near real-time - Deterministic 5-second upload timer for TX and discovery pings, replacing the old batch system that could take up to 20 seconds ### Bug Fixes - Fixed tile refresh race condition that caused half-rendered tiles and ghost tiles at certain zoom levels - Fixed Light (OSM) and Satellite (ArcGIS) tiles failing to load at low zoom on iOS retina displays ### Improvements - Faster ping cycle with reduced listen window (7s to 5s) for TX echo, discovery response, and auto-ping cooldown - Map zoom range adjusted to 3-17 - Updated repeater endpoint from /repeaters.json to /get_repeaters.php - Immediate upload on disconnect with no wait period * Regional Flood Scoping: TX pings(Active Mode) are now scoped to your region's flood zone. Regional Admins set a scope in the admin panel, so only repeaters in your region forward wardriving pings. Discovery pings are unaffected. If no scope is assigned, pings flood globally as before. * New Features Regional admins can now enforce hybrid mode per-zone via the API; when enabled, hybrid mode is automatically activated and the toggle is locked on the Settings screen with a "Forced Enabled by Regional Admin" label Regional admins can set a minimum auto-ping interval per-zone; interval options below the minimum are greyed out in Settings with a "Disabled by Regional Admin" label If a user's saved interval falls below the admin-configured minimum, it is automatically bumped up on connect * ### Improvements - Admin-enforced settings now include reworded messaging with a brief explanation of why the constraint benefits the mesh * ## Bug Fixes - Fixed RX carpeater detection checking the wrong hop position. On RX packets, the carpeater is co-located with the wardriver so it only appears as the last hop (delivery repeater), not the first. The check now correctly evaluates the last hop. * Debug logging is now enabled by default on all builds (not just dev builds), and the user's toggle preference is persisted across app restarts via Hive * The API returns "scopes":["#*"] for zones with no regional scope. The code only checked for '*' as the wildcard, so '#*' fell through to the scope-setting path. * Added dialog to upload logs window * Add Discovery Drop feature — failed discovery requests can now be reported to the API as failed pings (repeater_id: "None"), helping identify dead zones. Includes user toggle in Settings (default off), API-enforced override via disc_drop auth field, red markers on map/noise floor chart for failed discoveries when enabled. * New Feature MeshCore firmware v1.14.0+ supports multi-byte path hashes (1, 2, or 3 bytes per hop), expanding repeater ID space from 256 to 65K or 16M unique IDs. The app now fully supports this protocol change. * Add hopBytes support and update repeater display logic - Introduced hopBytes property in Repeater model to manage bytes per hop. - Updated AppStateProvider to reflect changes in path hash mode. - Enhanced connection_screen and settings_screen to accommodate hopBytes settings. - Modified map_widget to display repeater markers based on hopBytes. - Improved repeater_id_chip to show hex ID badges with dynamic sizing. * Refactor settings screen theme and unit toggles to use SwitchListTile; improve UI text for clarity * Repeater markers now rotate with the map and stay upright Co-Authored-By: Claude Opus 4.6 * Prevent duplicate GPS stream subscriptions on restart (#6) Clean fix, guarding startWatching() against stacked subscriptions. Placement at the top of the method is correct (before any early returns). Pattern matches existing subscription management elsewhere in the codebase. * Own and dispose app-level stream subscriptions (#7) Good catch. These three stream subscriptions were the last unowned listeners in AppStateProvider. The fix mirrors the existing pattern used for _adapterStateSubscription, _logRxDataSubscription, _noiseFloorSubscription, and _batterySubscription. Clean and consistent. * Fix mobile BLE scan stream completion and cleanup (#8) Fixes a real issue: the scan stream had no termination path, which could leave callers hanging. The three cleanup paths (natural stop, manual stop, finally) cover all cases well. The identical() check on _scanController is a nice detail for rapid re-scan scenarios. Minor note: the unawaited watcher on isScanning could theoretically fire before scan results arrive if isScanning briefly emits false during startup, but the !controller.isClosed guards prevent any actual breakage. * Bump version to 1.2.0 and enhance authentication flow with staged public_key and contact_uri registration * - **Hive corruption fallback for ping queue:** When the `api_queue` Hive box fails to open (e.g. corruption), `_safeWrite()` was silently returning false and dropping all enqueued pings. A full wardrive session could complete with zero ping data uploaded, as all POSTs would be heartbeats only. Fixed by adding a `_memoryQueue` in-memory fallback: failed writes go to memory instead of being dropped, `_uploadBatch` drains both Hive and memory queues, `queueSize` reflects both, and all clear/disconnect/init paths also flush `_memoryQueue`. * Add heartbeat retry, local session expiry tracking, and offline queue preservation - Heartbeat retries on network failure with exponential backoff (5 attempts, 30s-120s) - Local session deadline timer fires at expires_at, triggers session error if backend is unreachable - On session expiry, queued wardrive data is saved as an offline session instead of discarded * Cancel stale auto-ping restore timers (#10) LGTM. Right fix, matches the pattern used by _reconnectTimer and _reconnectTimeoutTimer. The generation counter is slightly defensive given Dart's single-threaded model but adds no complexity so no complaints. * Recheck location permission after disclosure (#11) Clean separation of disclosure (show once) from permission (always recheck), plus proper permanently-denied handling * - Discovery pings now extract repeater IDs using the region's hop byte count instead of always assuming 1 byte. In multi-byte regions, TX/RX logs showed full IDs like "4E7A" while discovery logs still showed the truncated "4E" * fixed overflow in disc request byte count fix * - Added option to keep the #wardriving channel after exiting a session (#149, @Skye) * - Minimum ping distance is now configurable for TX and discovery pings. The default 25m floor stays the same, but users can increase it to reduce unnecessary pings in dense city driving or stop-and-go traffic. RX batch flushing distance is unaffected. * - GPS-related zone check errors (inaccurate/stale) no longer retry every 5 seconds or auto-switch to the error tab. The next zone check waits until the user moves 100m instead of hammering retries while stationary. The zone status bar now shows a GPS icon with "GPS Unavailable" in orange, and the full-screen error view displays a specific title and message with a manual "Retry Zone Check" button. * lib/screens/settings_screen.dart — Replaced the radio button dialog with a text field. Users type any number they want (minimum 25m enforced). The field shows "meters" as a suffix and "Minimum 25m" as helper text. Save button only accepts valid integers >= 25. * - Hybrid mode now defaults to enabled for new users and existing users who haven't explicitly toggled it off. Saved preferences are preserved. * Implmented Trace Mode * - Fixed a regression where device firmware version was no longer being parsed correctly * - Redesigned the Connection tab for clarity. The previous layout was getting cluttered and hard to navigate. - Offline data session management is now always visible under Settings. * ### Bug Fixes - Fixed offline wardrive pings being lost when the app was killed by the OS, backgrounded, or disconnected mid-session - Offline pings now auto-save periodically, so at most ~60 seconds of data is lost in a worst-case app kill - Disconnecting while in offline mode now saves accumulated pings before cleanup - App backgrounding/suspension now triggers an immediate save of offline ping data * Bug 1 — Trace Mode fails after path byte mode switch: - lib/services/ping_service.dart:71: Changed final int _hopBytes to int _hopBytes and added a set hopBytes setter - lib/providers/app_state_provider.dart:1961: Added _pingService?.hopBytes = newHopBytes; in _applyLivePathHashMode() so PingService stays in sync when the user changes path mode at runtime Bug 2 — "No log files available" when only active log exists: - lib/widgets/upload_logs_dialog.dart:67: Changed DebugFileLogger.listUploadableLogFiles() to widget.appState.prepareDebugLogsForUpload() — this rotates the active log first (same pattern as BugReportSheet), so the rotated file appears in the list * ### Improvements - Auto-ping now shows "Skipped Xs" when a ping is skipped due to insufficient movement (25m minimum), replacing the generic countdown label across all UI layouts * ### New Features - Auto-stop after idle: auto-ping now automatically stops after 30 minutes of continuous skipped pings (no movement), saving battery and radio activity - New "Auto-Stop After Idle" toggle in Settings (default: ON), located under Min Ping Distance * fix trace bytes * Added Repeater Picker dropdown for traces * Final fixes for trace mode * Log tab redesign * Resigned log & settings screen * **New Features** - Live repeater snapshot: enable it in Settings to see the top 3 responded repeaters from your last ping, updating live as you wardrive **Improvements** - Redesigned the Log tab - Redesigned the Settings tab * ### Bug Fixes - Connection screen now shows a "Server Unreachable" message with offline mode suggestion when auth fails due to network errors, instead of a generic error. - Fixed zone check flooding when driving with no network. Previously, every GPS update triggered an API call when the network was down. Position is now tracked even on failed calls, so retries are handled by the 5-second timer instead. ### Improvements - Online/offline toggle redesigned to look more clearly like a button. * - Updated landscape controls to match the new portrait layout and added Trace mode support. - Updated the UI for the top heard repeaters display. * - Added text scale clamping for device accessibility settings. Previously the system text scale factor was applied unclamped across the entire app, which could break layouts at larger text sizes. * Fixing some font sizing and layouts * Fixed trace mode chip and trace icon * ### New Features - Persistent radio power overrides: custom TX power settings are now saved per device, similar to antenna selection. On connect, your previously set power override is automatically applied with a visual indicator showing it's been overridden. A "Reset to Auto" button in the power selector restores the auto-detected power for your device model. ### Improvements - Redesigned the Top Heard overlay. Now shows the repeaters that heard your most recent ping sorted by SNR, with color-coded indicators per ping type (green for TX, purple for Discovery, cyan for Trace, blue for RX). Top 3 slots show the best results from your latest ping and fully replace on each new ping. A separate RX slot shows the strongest passively overheard repeater within a rolling 5-second window. Overlay clears on auto-ping stop, mode switch, disconnect, or log clear. --------- Co-authored-by: Rob Ekl --- .build_version | 2 +- .gitignore | 6 +- .vscode/tasks.json | 21 - CODE_OF_CONDUCT.md | 104 + DEVELOPMENT.md | 13 +- SECURITY.md | 62 + lib/main.dart | 13 +- lib/models/api_queue_item.dart | 81 +- lib/models/log_entry.dart | 134 +- lib/models/noise_floor_session.dart | 11 + lib/models/ping_data.dart | 12 +- lib/models/repeater.dart | 17 + lib/models/user_preferences.dart | 88 +- lib/providers/app_state_provider.dart | 1399 ++++++++++-- lib/screens/connection_screen.dart | 1061 +++++---- lib/screens/graph_screen.dart | 5 +- lib/screens/home_screen.dart | 31 +- lib/screens/log_screen.dart | 1476 +++++++------ lib/screens/main_scaffold.dart | 66 +- lib/screens/settings_screen.dart | 1898 +++++++++++------ lib/services/api_queue_service.dart | 270 ++- lib/services/api_service.dart | 308 ++- lib/services/background_service.dart | 2 + lib/services/bluetooth/mobile_bluetooth.dart | 29 +- lib/services/gps_service.dart | 30 +- lib/services/meshcore/connection.dart | 127 +- lib/services/meshcore/crypto_service.dart | 14 + lib/services/meshcore/disc_tracker.dart | 20 +- lib/services/meshcore/packet_metadata.dart | 129 +- lib/services/meshcore/packet_validator.dart | 31 +- lib/services/meshcore/protocol_constants.dart | 2 + lib/services/meshcore/rx_logger.dart | 89 +- lib/services/meshcore/trace_tracker.dart | 211 ++ lib/services/meshcore/tx_tracker.dart | 91 +- lib/services/meshcore/unified_rx_handler.dart | 23 +- lib/services/offline_session_service.dart | 75 + lib/services/ping_service.dart | 386 +++- lib/utils/debug_logger_stub.dart | 4 +- lib/widgets/map_widget.dart | 1361 ++++++++---- lib/widgets/noise_floor_chart.dart | 7 +- lib/widgets/offline_mode_toggle.dart | 183 ++ lib/widgets/ping_controls.dart | 790 +++---- lib/widgets/regional_config_card.dart | 114 +- lib/widgets/repeater_id_chip.dart | 73 +- lib/widgets/repeater_picker_sheet.dart | 341 +++ lib/widgets/status_bar.dart | 15 +- lib/widgets/upload_logs_dialog.dart | 825 +++++++ 47 files changed, 8776 insertions(+), 3274 deletions(-) delete mode 100644 .vscode/tasks.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md create mode 100644 lib/services/meshcore/trace_tracker.dart create mode 100644 lib/widgets/offline_mode_toggle.dart create mode 100644 lib/widgets/repeater_picker_sheet.dart create mode 100644 lib/widgets/upload_logs_dialog.dart diff --git a/.build_version b/.build_version index 7dea76ed..26aaba0e 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.0.1 +1.2.0 diff --git a/.gitignore b/.gitignore index 25d86f82..d7c37fee 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,7 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +.vscode/ # Flutter/Dart/Pub related **/doc/api/ @@ -75,6 +72,7 @@ ios/Flutter/flutter_export_environment.sh *.g.dart # Debug logs +debug/ debuglog/ meshmapper-debug-*.txt diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index b7bd3c4d..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Flutter: Build & Serve Web", - "type": "shell", - "command": " - ", - "isBackground": true, - "problemMatcher": [], - "group": "build", - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": false - } - } - ] -} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..89cf71ba --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,104 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a welcoming experience for everyone, regardless of background or +identity. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- Trolling, insulting or derogatory comments, and personal attacks +- Public or private harassment of any kind +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of unacceptable behavior may be reported to the project maintainers at +the [MeshMapper Project issue tracker](https://github.com/MeshMapper/MeshMapper_Project/issues). + +All complaints will be reviewed and investigated promptly and fairly. All +community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels. +Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, or targeted conduct +against an individual or group. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ac712180..b7b1ad2d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -295,10 +295,21 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s ### Packet Structure - Custom binary protocol with header byte (0x11 = GROUP_TEXT, 0x21 = ADVERT) -- Path encoding: hop count + repeater IDs (4 bytes each) +- Path encoding: `pathLen` byte encodes hash size (top 2 bits) + hop count (bottom 6 bits), followed by `hopCount * hashSize` path bytes + - `pathHashSize = (pathLen >> 6) + 1` → 1, 2, 3, or 4 bytes per hop + - `pathHashCount = pathLen & 63` → 0-63 hops - SNR/RSSI metadata in BLE event payload - Encrypted message payload (AES-ECB with channel key) +### Multi-Byte Path Support (v1.14.0+) +- **Purpose**: Expands repeater ID space from 256 (1-byte) to 65K (2-byte) or 16M (3-byte) unique IDs +- **TX mode**: Configured via `CMD_SET_PATH_HASH_MODE = 61 (0x3D)` — `[0x3D][0x00][mode]` where mode=0→1-byte, 1→2-byte, 2→3-byte +- **RX auto-detect**: Each received packet's `pathLen` byte is decoded to determine hash size, regardless of the user's TX setting +- **DeviceInfo**: v10+ firmware includes `path_hash_mode` byte after manufacturer + firmware version fields +- **API enforcement**: Auth response may include `hop_bytes` (1/2/3) to enforce regional path byte size +- **Lifecycle**: Radio mode is set during connection and restored to original on clean disconnect. Unclean disconnect leaves radio in configured mode. +- **Discovery pings**: NOT affected — multi-byte paths apply only to TX/RX channel messages + ## Platform-Specific Notes ### Web (Chrome/Edge only) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..812bab3d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| Latest | Yes | + +Only the latest release on the `main` branch receives security updates. + +## Reporting a Vulnerability + +If you discover a security vulnerability in MeshMapper Flutter App, please report it responsibly. **Do not open a public GitHub issue for security vulnerabilities.** + +### How to Report + +1. Open a **private security advisory** at: + https://github.com/MeshMapper/MeshMapper_Project/security/advisories/new + +2. Include the following information: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +### What to Expect + +- **Acknowledgment**: We will acknowledge receipt of your report within 72 hours. +- **Assessment**: We will assess the severity and impact of the vulnerability. +- **Fix timeline**: Critical issues will be addressed as quickly as possible. We aim to release a fix within 30 days of confirmation. +- **Disclosure**: We will coordinate with you on public disclosure timing after a fix is available. + +## Security Considerations + +### API Key Handling + +- API keys are injected at build time via `--dart-define=API_KEY=...` and are **never** hardcoded in source code. + +### Bluetooth Security + +- The app communicates with MeshCore devices over Bluetooth Low Energy (BLE) using the MeshCore companion protocol. +- Channel messages are encrypted using AES-ECB with SHA-256 derived channel keys (encryption mode is dictated by the MeshCore protocol). + +### Data Privacy + +- GPS location data is sent to the MeshMapper API for community mesh coverage mapping. +- Users must be within a valid geographic zone (server-side validation) to submit data. +- No personal information beyond device name/public key and location data is transmitted. + +## Scope + +The following are **in scope** for security reports: + +- Vulnerabilities in the Flutter application code +- API key or secret exposure +- Authentication or session management issues +- Data leakage or privacy concerns + +The following are **out of scope**: + +- Vulnerabilities in third-party dependencies (report to the upstream project) +- Issues with the MeshCore firmware or radio protocol (report to the MeshCore project) \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index f29e80eb..08ef6efb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,15 +15,14 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; -import 'utils/constants.dart'; import 'utils/debug_logger_io.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Enable debug file logging FIRST on mobile for dev builds + // Enable debug file logging FIRST on mobile to capture early logs // This must happen before DebugLogger.initialize() to capture early logs - if (!kIsWeb && AppConstants.isDevelopmentBuild) { + if (!kIsWeb) { await DebugFileLogger.enable(); } @@ -248,6 +247,14 @@ class _ThemedAppState extends State<_ThemedApp> { return MaterialApp( title: 'MeshMapper', + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ), + child: child!, + ); + }, theme: ThemeData( colorScheme: lightColorScheme, scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index f2e4bcd8..337cab1e 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -44,7 +44,7 @@ class ApiQueueItem extends HiveObject { final String heardRepeats; /// Earliest time this item can be uploaded (milliseconds since epoch) - /// TX items have 5-second delay; RX/DISC are immediate + /// All items are immediate; upload timing is controlled by flush timers @HiveField(13) final int canUploadAfter; @@ -81,7 +81,7 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch + 5000, // 5 seconds from now + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, ); @@ -138,10 +138,87 @@ class ApiQueueItem extends HiveObject { ); } + /// Create from a successful TRACE ping (targeted zero-hop trace) + /// heardRepeats format: "repeaterId:localSnr:localRssi:remoteSnr" + factory ApiQueueItem.fromTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + return ApiQueueItem( + type: 'TRACE', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: heardRepeats, + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + + /// Create from a failed DISC discovery (no nodes responded) + factory ApiQueueItem.fromDiscDrop({ + required double latitude, + required double longitude, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + return ApiQueueItem( + type: 'DISC', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: 'None', + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + /// Convert to API JSON format (matches WebClient exactly) Map toApiJson() { + // For TRACE type, parse the heardRepeats field to extract individual values + if (type == 'TRACE') { + // Format: "repeaterId:localSnr:localRssi:remoteSnr" + final parts = heardRepeats.split(':'); + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': parts.isNotEmpty ? parts[0] : '', + 'local_snr': parts.length > 1 ? double.tryParse(parts[1]) : null, + 'local_rssi': parts.length > 2 ? int.tryParse(parts[2]) : null, + 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // For DISC type, parse the heardRepeats field to extract individual values if (type == 'DISC') { + // Failed discovery (no nodes responded) + if (heardRepeats == 'None') { + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': 'None', + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final parts = heardRepeats.split(':'); return { diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 758d4abe..26429beb 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,7 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => '${e.repeaterId}(${e.snr.toStringAsFixed(2)})').join(','); + : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,21 +39,23 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double snr; // Signal-to-noise ratio in dB - final int rssi; // RSSI in dBm + final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ required this.repeaterId, - required this.snr, - this.rssi = 0, + this.snr, + this.rssi, }); /// Get SNR color severity (red, orange, green) + /// Returns null for CARpeater pass-through (no signal data) /// Reference: getSnrSeverityClass() in wardrive.js - SnrSeverity get severity { - if (snr <= -1) { + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) { return SnrSeverity.poor; // Red: -12 to -1 dB - } else if (snr <= 5) { + } else if (snr! <= 5) { return SnrSeverity.fair; // Orange: 0 to 5 dB } else { return SnrSeverity.good; // Green: 6 to 13+ dB @@ -66,8 +68,8 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double snr; // Signal-to-noise ratio in dB - final int rssi; // Received signal strength indicator in dBm + final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -76,8 +78,8 @@ class RxLogEntry { RxLogEntry({ required this.timestamp, required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.latitude, @@ -97,10 +99,12 @@ class RxLogEntry { } /// Get SNR color severity - SnrSeverity get severity { - if (snr <= -1) { + /// Returns null for CARpeater pass-through (no signal data) + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) { return SnrSeverity.poor; - } else if (snr <= 5) { + } else if (snr! <= 5) { return SnrSeverity.fair; } else { return SnrSeverity.good; @@ -109,12 +113,67 @@ class RxLogEntry { /// Get CSV row String toCsv() { - return '${timestamp.toIso8601String()},$repeaterId,$snr,$rssi,' + return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' '$latitude,$longitude'; } } +/// Trace Log Entry (targeted zero-hop trace result) +class TraceLogEntry { + final DateTime timestamp; + final double latitude; + final double longitude; + final String targetRepeaterId; + final int? noiseFloor; + final double? localSnr; + final double? remoteSnr; + final int? localRssi; + final bool success; + + TraceLogEntry({ + required this.timestamp, + required this.latitude, + required this.longitude, + required this.targetRepeaterId, + this.noiseFloor, + this.localSnr, + this.remoteSnr, + this.localRssi, + required this.success, + }); + + /// Get formatted timestamp (HH:MM:SS) + String get timeString { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + } + + /// Get formatted location (5 decimal places) + String get locationString { + return '${latitude.toStringAsFixed(5)},${longitude.toStringAsFixed(5)}'; + } + + /// Get SNR color severity based on local SNR + SnrSeverity? get severity { + if (localSnr == null) return null; + if (localSnr! <= -1) { + return SnrSeverity.poor; + } else if (localSnr! <= 5) { + return SnrSeverity.fair; + } else { + return SnrSeverity.good; + } + } + + /// Get CSV row + String toCsv() { + return '${timestamp.toIso8601String()},$targetRepeaterId,${localSnr ?? 'null'},${localRssi ?? 'null'},' + '${remoteSnr ?? 'null'},$latitude,$longitude,${noiseFloor ?? ''},$success'; + } +} + /// SNR Severity levels for color coding enum SnrSeverity { poor, // Red: SNR ≤ -1 dB @@ -122,6 +181,47 @@ enum SnrSeverity { good, // Green: SNR > 5 dB } +/// Ping type for unified log view +enum PingLogType { tx, rx, disc, trace } + +/// Wrapper for unified chronological ping log view +class UnifiedPingLogEntry implements Comparable { + final PingLogType type; + final DateTime timestamp; + final dynamic entry; + + UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + + TxLogEntry get asTx => entry as TxLogEntry; + RxLogEntry get asRx => entry as RxLogEntry; + DiscLogEntry get asDisc => entry as DiscLogEntry; + TraceLogEntry get asTrace => entry as TraceLogEntry; + + @override + int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + + String get timeString => switch (type) { + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; + + String get locationString => switch (type) { + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; + + String toCsv() => switch (type) { + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; +} + /// User Error Entry for error log class UserErrorEntry { final DateTime timestamp; @@ -197,7 +297,7 @@ class DiscLogEntry { /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final String nodeType; // "REPEATER" or "ROOM" final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index a9212162..9c6b574d 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -35,6 +35,12 @@ enum PingEventType { @HiveField(4) discFail, // Grey: Discovery no response + + @HiveField(5) + traceSuccess, // Cyan: Trace got response + + @HiveField(6) + traceFail, // Grey: Trace no response } /// Repeater info for graph markers @@ -99,6 +105,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => Colors.blue, PingEventType.discSuccess => Colors.purple, PingEventType.discFail => Colors.grey, + PingEventType.traceSuccess => Colors.cyan, + PingEventType.traceFail => Colors.grey, }; /// Get a display label for this event type @@ -108,6 +116,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => 'RX', PingEventType.discSuccess => 'DISC Success', PingEventType.discFail => 'DISC Fail', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Fail', }; } @@ -154,6 +164,7 @@ class NoiseFloorSession extends HiveObject { String get modeDisplay => switch (mode) { 'active' => 'Active Mode', 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', _ => 'Passive Mode', }; diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 2425d603..0e42d087 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -111,14 +111,14 @@ class RxPing { /// Repeater that heard a TX ping (from echo tracking) class HeardRepeater { final String repeaterId; // Hex ID (e.g., "4e", "77") - final double snr; // Best SNR observed - final int rssi; // RSSI in dBm + final double? snr; // Best SNR observed (null for CARpeater pass-through) + final int? rssi; // RSSI in dBm (null for CARpeater pass-through) final int seenCount; // How many times this repeater was heard const HeardRepeater({ required this.repeaterId, - required this.snr, - this.rssi = 0, + this.snr, + this.rssi, this.seenCount = 1, }); } @@ -139,6 +139,7 @@ class PingStats { final int txCount; final int rxCount; final int discCount; // Discovery count (Passive Mode) + final int traceCount; // Trace count (Targeted Mode) final int successfulUploads; final int failedUploads; final int queuedCount; @@ -147,6 +148,7 @@ class PingStats { this.txCount = 0, this.rxCount = 0, this.discCount = 0, + this.traceCount = 0, this.successfulUploads = 0, this.failedUploads = 0, this.queuedCount = 0, @@ -156,6 +158,7 @@ class PingStats { int? txCount, int? rxCount, int? discCount, + int? traceCount, int? successfulUploads, int? failedUploads, int? queuedCount, @@ -164,6 +167,7 @@ class PingStats { txCount: txCount ?? this.txCount, rxCount: rxCount ?? this.rxCount, discCount: discCount ?? this.discCount, + traceCount: traceCount ?? this.traceCount, successfulUploads: successfulUploads ?? this.successfulUploads, failedUploads: failedUploads ?? this.failedUploads, queuedCount: queuedCount ?? this.queuedCount, diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index cdf1867b..bbf76bd1 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -34,6 +34,9 @@ class Repeater { /// The repeater is active while `now < staleTime`. final int? staleTime; + /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) + final int hopBytes; + const Repeater({ required this.id, required this.hexId, @@ -45,6 +48,7 @@ class Repeater { this.iata, this.createdAt, this.staleTime, + this.hopBytes = 1, }); /// Parse from JSON object in repeaters.json @@ -78,6 +82,7 @@ class Repeater { iata: json['iata'] as String?, createdAt: createdAt, staleTime: staleTime, + hopBytes: (json['hop_bytes'] as int?) ?? 1, ); } @@ -93,6 +98,7 @@ class Repeater { 'iata': iata, 'created_at': createdAt, 'stale_time': staleTime, + 'hop_bytes': hopBytes, }; } @@ -130,6 +136,17 @@ class Repeater { /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; + /// Get display hex ID based on hop bytes (or override). + /// [overrideHopBytes] is used when regional admin enforces a byte size. + String displayHexId({int? overrideHopBytes}) { + final bytes = overrideHopBytes ?? hopBytes; + final hexChars = bytes * 2; // 1 byte = 2 hex chars + if (hexId.length >= hexChars) { + return hexId.substring(0, hexChars).toUpperCase(); + } + return id; // Fallback to short numeric ID + } + @override String toString() => 'Repeater(id=$id, name=$name, enabled=$isEnabled)'; } diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 3eb4f576..4063fc8b 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -64,6 +64,27 @@ class UserPreferences { /// Map rotation lock (disable rotation gestures) final bool mapRotationLocked; + /// Disable RSSI carpeater filter (allow all signal strengths) + final bool disableRssiFilter; + + /// Anonymous mode: rename companion to "Anonymous" during wardriving + final bool anonymousMode; + + /// Discovery drop: count failed discoveries as failed pings and report to API + final bool discDropEnabled; + + /// Delete wardriving channel from radio on disconnect + final bool deleteChannelOnDisconnect; + + /// Minimum ping distance in meters (25m floor, user can increase) + final int minPingDistanceMeters; + + /// Auto-stop auto-ping after 30 minutes of idle (no movement) + final bool autoStopAfterIdle; + + /// Show top 3 repeaters by SNR on the map during wardriving + final bool showTopRepeaters; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -82,10 +103,17 @@ class UserPreferences { this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', - this.hybridModeEnabled = false, + this.hybridModeEnabled = true, this.mapAutoFollow = false, this.mapAlwaysNorth = true, this.mapRotationLocked = false, + this.disableRssiFilter = false, + this.anonymousMode = false, + this.discDropEnabled = false, + this.deleteChannelOnDisconnect = true, + this.minPingDistanceMeters = 25, + this.autoStopAfterIdle = true, + this.showTopRepeaters = false, }); /// Create from JSON (for persistence) @@ -108,10 +136,17 @@ class UserPreferences { closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', - hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? false, + hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, mapAutoFollow: (json['mapAutoFollow'] as bool?) ?? false, mapAlwaysNorth: (json['mapAlwaysNorth'] as bool?) ?? true, mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, + disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, + anonymousMode: (json['anonymousMode'] as bool?) ?? false, + discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, + autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, + showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, ); } @@ -139,6 +174,13 @@ class UserPreferences { 'mapAutoFollow': mapAutoFollow, 'mapAlwaysNorth': mapAlwaysNorth, 'mapRotationLocked': mapRotationLocked, + 'disableRssiFilter': disableRssiFilter, + 'anonymousMode': anonymousMode, + 'discDropEnabled': discDropEnabled, + 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, + 'minPingDistanceMeters': minPingDistanceMeters, + 'autoStopAfterIdle': autoStopAfterIdle, + 'showTopRepeaters': showTopRepeaters, }; } @@ -165,6 +207,13 @@ class UserPreferences { bool? mapAutoFollow, bool? mapAlwaysNorth, bool? mapRotationLocked, + bool? disableRssiFilter, + bool? anonymousMode, + bool? discDropEnabled, + bool? deleteChannelOnDisconnect, + int? minPingDistanceMeters, + bool? autoStopAfterIdle, + bool? showTopRepeaters, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -188,6 +237,13 @@ class UserPreferences { mapAutoFollow: mapAutoFollow ?? this.mapAutoFollow, mapAlwaysNorth: mapAlwaysNorth ?? this.mapAlwaysNorth, mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, + disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, + anonymousMode: anonymousMode ?? this.anonymousMode, + discDropEnabled: discDropEnabled ?? this.discDropEnabled, + deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, + showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, ); } @@ -213,6 +269,9 @@ class UserPreferences { return '$autoPingInterval seconds'; } + /// Get min ping distance display string + String get minPingDistanceDisplay => '${minPingDistanceMeters}m'; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -236,12 +295,19 @@ class UserPreferences { other.hybridModeEnabled == hybridModeEnabled && other.mapAutoFollow == mapAutoFollow && other.mapAlwaysNorth == mapAlwaysNorth && - other.mapRotationLocked == mapRotationLocked; + other.mapRotationLocked == mapRotationLocked && + other.disableRssiFilter == disableRssiFilter && + other.anonymousMode == anonymousMode && + other.discDropEnabled == discDropEnabled && + other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && + other.minPingDistanceMeters == minPingDistanceMeters && + other.autoStopAfterIdle == autoStopAfterIdle && + other.showTopRepeaters == showTopRepeaters; } @override int get hashCode { - return Object.hash( + return Object.hashAll([ powerLevel, txPower, externalAntenna, @@ -262,7 +328,14 @@ class UserPreferences { mapAutoFollow, mapAlwaysNorth, mapRotationLocked, - ); + disableRssiFilter, + anonymousMode, + discDropEnabled, + deleteChannelOnDisconnect, + minPingDistanceMeters, + autoStopAfterIdle, + showTopRepeaters, + ]); } /// Check if using imperial units @@ -296,3 +369,8 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } + +/// Minimum ping distance (meters) +class MinPingDistance { + static const int min = 25; +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a91f16ff..745b96f0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -29,6 +29,7 @@ import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; +import '../services/meshcore/crypto_service.dart'; import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; @@ -47,8 +48,13 @@ enum AutoMode { passive, /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater + targeted, } +/// Ping type for the top-heard overlay dots +enum OverlayPingType { tx, disc, trace, rx } + /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully @@ -61,6 +67,8 @@ enum OfflineUploadResult { authFailed, /// Some pings failed to upload partialFailure, + /// Another upload is already in progress + uploadInProgress, } /// Main application state provider @@ -99,10 +107,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; StreamSubscription? _adapterStateSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _gpsStatusSubscription; + StreamSubscription? _gpsPositionSubscription; // GPS state GpsStatus _gpsStatus = GpsStatus.permissionDenied; @@ -114,7 +126,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Device info DeviceModel? _deviceModel; String? _manufacturerString; + String? _firmwareVersionString; String? _devicePublicKey; + String? _offlineContactUri; /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; @@ -131,6 +145,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { PingStats _pingStats = const PingStats(); bool _autoPingEnabled = false; AutoMode _autoMode = AutoMode.active; + DateTime? _idleAutoStopReference; + static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked int _queueSize = 0; int? _currentNoiseFloor; @@ -153,6 +169,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _txLogEntries = []; final List _rxLogEntries = []; final List _discLogEntries = []; + final List _traceLogEntries = []; + + // Top repeaters overlay — updated live on each ping event + List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + ({String repeaterId, double snr})? _rxOverlaySlot; + Timer? _rxOverlayWindowTimer; + + // Targeted mode state + String? _targetRepeaterId; // User error log entries final List _errorLogEntries = []; @@ -160,6 +185,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // User preferences UserPreferences _preferences = const UserPreferences(); + // Anonymous mode state + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -167,6 +196,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _antennaRestoredFromDevice = false; bool get antennaRestoredFromDevice => _antennaRestoredFromDevice; + /// Per-device power overrides: maps companion name → {powerLevel, txPower} + Map> _devicePowerOverrides = {}; + + /// Whether the current power setting was auto-restored from a saved override + bool _powerRestoredFromDevice = false; + bool get powerRestoredFromDevice => _powerRestoredFromDevice; + // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; @@ -199,6 +235,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; + // Tile refresh after upload + int _overlayCacheBust = 0; + Timer? _tileRefreshTimer; + // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -212,8 +252,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; + Timer? _restoreAutoPingTimer; + Timer? _offlineAutoSaveTimer; + Timer? _zoneRefreshTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; + int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); @@ -222,6 +266,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab + bool _requestConnectionTabSwitch = false; // Request switch to connection tab // Repeater markers state List _repeaters = []; @@ -231,6 +276,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels from API (for UI display) List _regionalChannels = []; + // Regional scope from API (for UI display and flood filtering) + String? _scope; + + // Path hash mode tracking (for multi-byte path support) + int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected + int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; List _storedNoiseFloorSessions = []; @@ -252,6 +306,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + } else if (state == AppLifecycleState.paused) { + debugLog('[APP] App paused (backgrounded)'); + // Save offline pings immediately on pause to prevent data loss if OS kills app + if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { + _autoSaveOfflinePings(); + } } } @@ -265,6 +325,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; bool get isAuthError => _isAuthError; + bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; @@ -273,6 +334,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; + String? get firmwareVersionString => _firmwareVersionString; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -284,6 +346,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isPendingDisable => _pingService?.pendingDisable ?? false; /// True when running any mode that does TX (Active or Hybrid) bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) + bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; int? get currentBatteryPercent => _currentBatteryPercent; @@ -291,14 +356,71 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isScanning => _isScanning; List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); + + /// Top 3 repeaters by best SNR from TX/DISC/Trace pings + List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window + ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; + + /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. + /// Replaces all 3 slots entirely (no carryover from previous pings). + void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + final bestSnr = {}; + for (final r in current) { + final key = r.repeaterId.toUpperCase(); + if (!bestSnr.containsKey(key) || r.snr > bestSnr[key]!) { + bestSnr[key] = r.snr; + } + } + final fresh = bestSnr.entries + .map((e) => (repeaterId: e.key, snr: e.value, type: type)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + _topRepeatersOverlay = fresh.take(3).toList(); + } + + /// Update the RX overlay slot with a 5-second rolling window (best SNR wins). + void _updateRxOverlaySlot(String repeaterId, double snr) { + final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); + if (_rxOverlayWindowTimer?.isActive ?? false) { + if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { + _rxOverlaySlot = entry; + } + } else { + _rxOverlaySlot = entry; + _rxOverlayWindowTimer = Timer(const Duration(seconds: 5), () { + // Window closed — slot stays until next RX or cleared + }); + } + } + + /// Clear all overlay state (top 3 + RX slot). + void _clearOverlayState() { + _topRepeatersOverlay = []; + _rxOverlaySlot = null; + _rxOverlayWindowTimer?.cancel(); + _rxOverlayWindowTimer = null; + } List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); + List get traceLogEntries => List.unmodifiable(_traceLogEntries); List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get unifiedPingLogEntries { + final merged = [ + ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ]; + merged.sort(); + return merged; + } ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; + bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -318,6 +440,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; + int get overlayCacheBust => _overlayCacheBust; int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -341,6 +464,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isSwitchingMode => _isSwitchingMode; String? get modeSwitchError => _modeSwitchError; + // Anonymous mode getter + bool get isAnonymousRenamed => _isAnonymousRenamed; + // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; @@ -351,6 +477,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); + // Regional scope getter (for UI) + String? get scope => _scope; + // Noise floor session getters NoiseFloorSession? get currentNoiseFloorSession => _currentNoiseFloorSession; List get storedNoiseFloorSessions => @@ -369,10 +498,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get rxAllowed => _apiService.rxAllowed; bool get hasApiSession => _apiService.hasSession; bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; + bool get enforceHybrid => _apiService.enforceHybrid; + bool get enforceDiscDrop => _apiService.enforceDiscDrop; + bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + int get minModeInterval => _apiService.minModeInterval; + bool get enforceHopBytes => _apiService.enforceHopBytes; + int get hopBytes => _hopBytes; + int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get traceHopBytes => _traceHopBytes; + bool get supportsMultiBytePaths => _originalPathHashMode != null; // Offline mode bool get offlineMode => _preferences.offlineMode; List get offlineSessions => _offlineSessionService.sessions; + bool _isUploadingOfflineSession = false; + bool get isUploadingOfflineSession => _isUploadingOfflineSession; // Developer mode bool get developerModeEnabled => _preferences.developerModeEnabled; @@ -429,8 +569,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); - // Auto-enable debug logging for development builds - await _autoEnableDebugLogsIfDevelopmentBuild(); + // Initialize debug logging (enabled by default, respects user preference) + await _initDebugLogs(); // Initialize channel service with Public channel only (regional channels added after auth) await ChannelService.initializePublicChannel(); @@ -453,7 +593,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -469,6 +610,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); + + // Schedule overlay tile refresh after server has time to regenerate tiles + // Cache buster change + notifyListeners triggers flutter_map's reloadImages() + // which updates tile URLs in-place and refetches cleanly + _tileRefreshTimer?.cancel(); + _tileRefreshTimer = Timer(const Duration(seconds: 5), () { + _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; + debugLog('[MAP] Refreshing overlay tiles'); + notifyListeners(); + }); }; // Initialize offline session service @@ -490,6 +641,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Loading preferences...'); await _loadPreferences(); await _loadDeviceAntennaPreferences(); + await _loadDevicePowerOverrides(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -514,7 +666,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); - _bluetoothService.connectionStream.listen((status) async { + await _connectionSubscription?.cancel(); + _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -538,7 +691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to GPS changes debugLog('[INIT] Setting up GPS status listener...'); - _gpsService.statusStream.listen((status) { + await _gpsStatusSubscription?.cancel(); + _gpsStatusSubscription = _gpsService.statusStream.listen((status) { final previousStatus = _gpsStatus; _gpsStatus = status; @@ -563,7 +717,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); - _gpsService.positionStream.listen((position) async { + await _gpsPositionSubscription?.cancel(); + _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -698,6 +853,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveredDevices = []; _connectionError = null; _isAuthError = false; + _isNetworkError = false; notifyListeners(); // Listen for discovered devices using subscription so stopScan() can cancel @@ -708,8 +864,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ).listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { - _discoveredDevices.add(device); - selectedDevice = device; + // Prefer remembered device name (from SelfInfo) over BLE cache + var enrichedDevice = device; + if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + device.name != _rememberedDevice!.name) { + enrichedDevice = DiscoveredDevice( + id: device.id, + name: _rememberedDevice!.name, + rssi: device.rssi, + ); + debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + } + _discoveredDevices.add(enrichedDevice); + selectedDevice = enrichedDevice; notifyListeners(); } }, @@ -754,6 +921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _connectionError = null; _isAuthError = false; + _isNetworkError = false; // Clean up any previous connection first if (_meshCoreConnection != null) { @@ -780,8 +948,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; } - // Use SelfInfo name (user's configured name) if available, otherwise fall back to BLE advertisement name - final deviceName = _meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" + if (_preferences.anonymousMode && !_isAnonymousRenamed) { + final realName = _meshCoreConnection!.selfInfo?.name; + if (realName != null && realName.isNotEmpty) { + _originalDeviceName = realName; + try { + await _meshCoreConnection!.setAdvertName('Anonymous'); + _isAnonymousRenamed = true; + _displayDeviceName = 'Anonymous'; + debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + // Short delay for firmware to process + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugError('[CONN] Anonymous mode: rename failed: $e'); + // Continue with real name if rename fails + } + } + } + + // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot request auth: could not retrieve device name'); return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; @@ -831,6 +1020,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(result); + return result; } @@ -920,6 +1112,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(registerResult); + return registerResult; }; } else { @@ -934,14 +1129,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected - // Use companion name (selfInfo.name) or BLE device name with MeshCore- prefix stripped - var deviceName = _meshCoreConnection!.selfInfo?.name ?? - connectedDeviceName; + // Use original name (not "Anonymous") for bug report identification + var deviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); if (deviceName != null) { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); @@ -949,6 +1146,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } + + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } } notifyListeners(); }); @@ -981,8 +1188,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { powerLevel: device.power, txPower: device.txPower, autoPowerSet: true, // Indicates power was auto-detected from device model + powerLevelSet: false, // Clear stale manual flag from previous session ); - // TODO: Persist to SharedPreferences when implemented notifyListeners(); debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } @@ -1010,12 +1217,54 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - final newValidator = PacketValidator(allowedChannels: allowedChannels); + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); _unifiedRxHandler!.updateValidator(newValidator); debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } + // Set flood scope from API response (regional TX filtering) + // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) + // Any other value (e.g., "ottawa") → derive TransportKey and set scope + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[CONN] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + debugLog('[CONN] Flood scope set successfully'); + } else { + _scope = null; + debugLog('[CONN] No regional scope — using unscoped flood'); + } + + // Enforce hybrid mode if required by regional admin + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } + + // Enforce discovery drop if required by regional admin + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } + + // Enforce minimum auto-ping interval if required by regional admin + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + } + + // Configure multi-byte path hash mode on radio + await _configurePathHashMode(); + // Create ping service with wakelock (create new instance per connection) _pingService = PingService( gpsService: _gpsService, @@ -1029,19 +1278,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceId: _deviceId, txTracker: _txTracker, audioService: _audioService, + disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, shouldIgnoreRepeater: (String repeaterId) { - // Same filter as RxLogger - check user preferences for ignored repeater ID final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - // Case-insensitive comparison (both uppercase) - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current == ignored; + return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); } return false; }, ); + // Wire UnifiedRxHandler so trace payloads route to TraceTracker + _pingService!.unifiedRxHandler = _unifiedRxHandler; + // Set validation callbacks _pingService!.checkExternalAntennaConfigured = () { // External antenna must be explicitly set (yes or no) before pinging @@ -1062,6 +1313,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; + // Check if discovery drop is enabled + _pingService!.getDiscDropEnabled = () => discDropEnabled; + _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -1096,6 +1350,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + // Update RX overlay slot with this RX observation + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + notifyListeners(); }; @@ -1112,7 +1369,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1125,7 +1383,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr}, isNew: $isNew)'); + debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events @@ -1165,6 +1423,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + + // Update top repeaters overlay with current TX echoes + _updateTopRepeaters(existingEvents + .where((e) => e.snr != null) + .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), OverlayPingType.tx); + debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); @@ -1182,6 +1447,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up auto ping scheduled callback for countdown display _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { _autoPingTimer.startWithSkipReason(intervalMs, skipReason); + + // Track idle time for auto-stop + if (skipReason != null) { + // Ping was skipped — check if idle too long + if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + final elapsed = DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); + } + } + } else { + // Successful ping — reset idle reference + _idleAutoStopReference = DateTime.now(); + } }; // Wire up discovery ping callback - fires immediately (like onTxPing) @@ -1195,6 +1474,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isNew) { _audioService.playReceiveSound(); } + + // Update top repeaters overlay with all discovered nodes from this ping + _updateTopRepeaters(discPing.discoveredNodes + .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), OverlayPingType.disc); + notifyListeners(); }; @@ -1212,8 +1497,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastTx.events.isNotEmpty) { repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( repeaterId: e.repeaterId, - snr: e.snr, - rssi: e.rssi, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, )).toList(); } } @@ -1248,8 +1533,64 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } + + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; + + // Wire up trace ping callback (for log entry creation) + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; + + // Wire up trace window complete callback for noise floor graph + _pingService!.onTraceWindowComplete = (result) { + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + )]; + // Update the log entry with success data + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + notifyListeners(); + } + } + recordPingEvent( - success ? PingEventType.discSuccess : PingEventType.discFail, + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, latitude: lat, longitude: lon, repeaters: repeaters, @@ -1293,6 +1634,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update local state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -1305,12 +1647,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // BLE advertisement name may be cached/stale after device rename final selfInfoName = _meshCoreConnection?.selfInfo?.name; if (selfInfoName != null && selfInfoName.isNotEmpty) { - _displayDeviceName = selfInfoName; - debugLog('[APP] Display name set from SelfInfo: "$selfInfoName"'); + // Keep "Anonymous" display name if anonymous mode is active + _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; + debugLog('[APP] Display name set: "$_displayDeviceName"'); + + // Update remembered device with real name (not "Anonymous") + // BLE advertisement name may be stale after device rename + final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { + final updatedName = 'MeshCore-$realName'; + if (_rememberedDevice!.name != updatedName) { + await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); + debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + } + } } // Restore per-device antenna preference if previously saved - final resolvedName = displayDeviceName; + // Use original name for keying, not "Anonymous" + final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( @@ -1323,6 +1678,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Restore per-device power override if previously saved + if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } + // Log connection status based on TX/RX permissions if (hasApiSession) { if (txAllowed && rxAllowed) { @@ -1332,6 +1702,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else { debugLog('[CONN] Connected with limited access'); } + + // Start periodic zone refresh to keep slot counts current + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); + } } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1374,12 +1749,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { _connectionError = 'Authentication failed'; } } else { _isAuthError = false; + _isNetworkError = false; // Provide clean user-facing messages for common BLE errors if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; @@ -1398,6 +1775,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create TX tracker (stored for use by PingService) _txTracker = TxTracker(); + _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; + + // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) + _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1407,37 +1789,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; debugLog('[APP] TxTracker.onCarpeaterDrop callback SET'); - // Function to check if repeater should be ignored (carpeater filter) - _txTracker!.shouldIgnoreRepeater = (String repeaterId) { - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - return repeaterId.toUpperCase() == ignored; - } - return false; - }; - debugLog('[APP] TxTracker.shouldIgnoreRepeater callback SET'); - // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( - // Function to check if repeater should be ignored (carpeater filter) - shouldIgnoreRepeater: (String repeaterId) { - // Check user preferences for ignored repeater ID - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - // Case-insensitive comparison (both uppercase) - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current == ignored; - } - return false; - }, + // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) + carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' - 'snr=${observation.snr}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); + 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); @@ -1452,8 +1813,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: observation.lon, repeaterId: observation.repeaterId, timestamp: DateTime.now(), - snr: observation.snr, - rssi: observation.rssi, + snr: observation.snr ?? 0.0, + rssi: observation.rssi ?? 0, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1465,6 +1826,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); + // Update RX overlay slot immediately + if (observation.snr != null) { + _updateRxOverlaySlot(repeaterKey, observation.snr!); + } // Play receive sound for new RX observation _audioService.playReceiveSound(); // Record RX event for noise floor graph with location and repeater info @@ -1475,8 +1840,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: [ MarkerRepeaterInfo( repeaterId: observation.repeaterId, - snr: observation.snr, - rssi: observation.rssi, + snr: observation.snr ?? 0.0, + rssi: observation.rssi ?? 0, ), ], ); @@ -1496,7 +1861,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' - 'snr=${entry.snr}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); + 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1513,20 +1878,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastPinIndex != -1) { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; - if (entry.snr > existingPin.snr) { + // Only update if new SNR is non-null and better (null never replaces non-null) + final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr, // UPDATE to best SNR from batch - rssi: entry.rssi, + snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + rssi: entry.rssi ?? existingPin.rssi, ); debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' - '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr.toStringAsFixed(2)}'); + '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' - 'batch best ${entry.snr.toStringAsFixed(2)} <= pin ${existingPin.snr.toStringAsFixed(2)}'); + 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { // Edge case: pin not found (should have been created in onObservation) @@ -1535,8 +1902,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: entry.lon, repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr, - rssi: entry.rssi, + snr: entry.snr ?? 0.0, + rssi: entry.rssi ?? 0, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1566,13 +1933,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogEntries.add(rxLogEntry); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' - 'snr=${entry.snr}, pathLen=${entry.pathLength}'); + 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); + + // Update RX overlay slot with this RX observation + if (entry.snr != null) { + _updateRxOverlaySlot(entry.repeaterId, entry.snr!); + } // Note: RX count is incremented in onObservation when pin is created (immediate feedback) // Enqueue to API with formatted heard_repeats string - // Format: "repeaterId(snr)" e.g. "4e(12.25)" - final heardRepeats = '${entry.repeaterId}(${entry.snr.toStringAsFixed(2)})'; + // Format: "repeaterId(snr)" e.g. "4e(12.25)" or "4e(null)" for CARpeater pass-through + final heardRepeats = entry.snr != null + ? '${entry.repeaterId}(${entry.snr!.toStringAsFixed(2)})' + : '${entry.repeaterId}(null)'; await _apiQueueService.enqueueRx( latitude: entry.lat, longitude: entry.lon, @@ -1617,8 +1991,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); - final validator = PacketValidator(allowedChannels: allowedChannels); - + final validator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + // Create unified handler _unifiedRxHandler = UnifiedRxHandler( txTracker: _txTracker!, @@ -1639,19 +2016,174 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Full disconnect cleanup - called on normal BLE disconnect (user-requested or no remembered device) /// Extracted from the original BLE disconnect listener + /// Configure multi-byte path hash mode on the radio during connection + /// Reads device's current mode, determines effective mode, and sends command if needed + Future _configurePathHashMode() async { + final deviceInfo = _meshCoreConnection?.deviceInfo; + if (deviceInfo == null) return; + + // Store the device's current mode (from DeviceInfo response) + _originalPathHashMode = deviceInfo.pathHashMode; + + // Sync runtime hopBytes from device's current mode + if (_originalPathHashMode != null) { + final deviceHopBytes = _originalPathHashMode! + 1; + _hopBytes = deviceHopBytes; + // Map TX bytes to trace bytes (3-byte traces not possible, use 4) + _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + } else { + _hopBytes = 1; + _traceHopBytes = 1; + } + + final effective = effectiveHopBytes; + final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceHopBytes = deviceMode + 1; + + if (effective != deviceHopBytes && _originalPathHashMode != null) { + // Need to change the radio's path hash mode + try { + await _meshCoreConnection!.setPathHashMode(effective - 1); + _hopBytes = effective; // Update runtime state to reflect new mode + _traceHopBytes = effective == 3 ? 4 : effective; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + + // Show warning popup if changing from 1-byte to multi-byte + if (deviceMode == 0 && effective > 1) { + final reason = enforceHopBytes + ? 'set by your regional admin' + : 'set in your app preferences'; + _pendingPathHashWarning = (hopBytes: effective, reason: reason); + notifyListeners(); // Trigger UI to show warning + } + } catch (e) { + debugError('[PATH] Failed to set path hash mode: $e'); + } + } else if (_originalPathHashMode == null && effective > 1) { + // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte + debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + if (enforceHopBytes) { + _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + notifyListeners(); + } + } else { + debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + } + } + + /// Restore radio to original path hash mode on clean disconnect + /// Skipped if the user manually changed the setting — they know what they're doing + Future _restorePathHashMode() async { + if (_originalPathHashMode == null) return; + + if (_userChangedPathMode) { + debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + _originalPathHashMode = null; + _userChangedPathMode = false; + return; + } + + final originalMode = _originalPathHashMode!; + final originalHopBytes = originalMode + 1; + + // Compare current runtime mode against what the device had before we changed it + if (_hopBytes != originalHopBytes) { + try { + await _meshCoreConnection?.setPathHashMode(originalMode); + debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + } catch (e) { + debugError('[PATH] Failed to restore path hash mode: $e'); + } + } else { + debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + } + _originalPathHashMode = null; + _userChangedPathMode = false; + } + + /// Send path hash mode to radio immediately when user changes setting while connected + void _applyLivePathHashMode(int newHopBytes) { + if (_originalPathHashMode == null) { + // Old firmware — can't send command, show warning + debugWarn('[PATH] Cannot change path mode: firmware does not support it'); + _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _hopBytes = 1; // Force back to 1 + notifyListeners(); + return; + } + + _hopBytes = newHopBytes; + _userChangedPathMode = true; + _pingService?.hopBytes = newHopBytes; + // Auto-map trace bytes when TX bytes change (3→4, others stay same) + final oldTraceHopBytes = _traceHopBytes; + _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + // Clear target repeater if trace bytes changed — old hex ID has wrong byte length + if (_traceHopBytes != oldTraceHopBytes) { + _targetRepeaterId = null; + } + final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 + _meshCoreConnection?.setPathHashMode(mode); + debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + notifyListeners(); + } + + /// Set hop bytes (called from settings UI). Each companion device may differ. + void setHopBytes(int value) { + if (value < 1 || value > 3) return; + if (value == _hopBytes) return; + + if (isConnected) { + _applyLivePathHashMode(value); + } else { + _hopBytes = value; + notifyListeners(); + } + } + + /// Set trace hop bytes (called from settings UI). Valid values: 1, 2, 4. + void setTraceHopBytes(int value) { + if (value != 1 && value != 2 && value != 4) return; + if (value == _traceHopBytes) return; + _traceHopBytes = value; + _pingService?.traceHopBytes = value; + // Clear target repeater — old hex ID has wrong byte length + _targetRepeaterId = null; + debugLog('[TRACE] User changed trace bytes to $value'); + notifyListeners(); + } + + /// Pending path hash warning data (for UI to show dialog) + ({int hopBytes, String reason})? _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + + /// Clear the pending warning after UI has shown it + void clearPathHashWarning() { + _pendingPathHashWarning = null; + } + Future _fullDisconnectCleanup() async { + _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping timers _autoPingTimer.stop(); _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); } @@ -1688,6 +2220,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Reset anonymous mode state (BLE already gone, can't restore name) + _isAnonymousRenamed = false; + _originalDeviceName = null; + + // Clear top-heard overlay + _clearOverlayState(); + // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; @@ -1697,6 +2236,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { + _cancelPendingAutoPingRestore(); _isAutoReconnecting = true; _reconnectAttempt = 0; _connectionStep = ConnectionStep.reconnecting; @@ -1710,6 +2250,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; + _idleAutoStopReference = null; // Stop heartbeat _apiService.disableHeartbeat(); @@ -1818,9 +2359,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Restore auto-ping if it was active if (wasAutoPing) { + final restoreGeneration = _reconnectRestoreGeneration; // Use a short delay to ensure connection is fully set up - Timer(const Duration(milliseconds: 500), () { - if (_connectionStep == ConnectionStep.connected) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + restoreGeneration != _reconnectRestoreGeneration || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + return; + } + + if (!_autoPingEnabled) { toggleAutoPing(previousMode); debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } @@ -1843,17 +2396,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; - // Reset antenna setting so user must choose again on next connect + // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); + // Reset anonymous mode state (BLE already gone, can't restore name) + _isAnonymousRenamed = false; + _originalDeviceName = null; + // Do full disconnect cleanup (releases API session, etc.) _fullDisconnectCleanup(); notifyListeners(); @@ -1869,6 +2428,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; @@ -1876,10 +2436,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping if running (before releasing session) if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; + _idleAutoStopReference = null; } // End noise floor session on disconnect @@ -1896,6 +2460,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop RX wardriving if active (flushes batches to queue) _rxLogger?.stopWardriving(trigger: 'disconnect'); + // Save offline pings before clearing queue (no-op if not in offline mode or no pings) + await _saveOfflineSession(); + // ALWAYS START FRESH - clear any queued data on disconnect // Pings without a valid session cannot be uploaded later await _apiQueueService.clearOnDisconnect(); @@ -1915,9 +2482,37 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Restore original device name if anonymous mode renamed it (BLE must still be connected) + if (_isAnonymousRenamed && _originalDeviceName != null) { + try { + await _meshCoreConnection?.setAdvertName(_originalDeviceName!); + debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + } catch (e) { + debugError('[CONN] Anonymous mode: failed to restore name: $e'); + logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, autoSwitch: false); + } + _isAnonymousRenamed = false; + _originalDeviceName = null; + } + + // Restore original path hash mode before disconnect (while BLE still connected) + await _restorePathHashMode(); + + // Clear flood scope before disconnect (safety — BLE disconnect resets radio state anyway) + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[CONN] Failed to clear flood scope: $e'); + } + // Delete wardriving channel FIRST, while BLE connection is still active // This prevents "GATT Server is disconnected" errors - await _meshCoreConnection?.deleteWardrivingChannelEarly(); + if (_preferences.deleteChannelOnDisconnect) { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } else { + debugLog('[CHANNEL] Skipping channel deletion (user preference)'); + } // Cleanup unified RX handler and TX tracker _logRxDataSubscription?.cancel(); @@ -1944,18 +2539,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnected; _deviceModel = null; _manufacturerString = null; + _firmwareVersionString = null; _devicePublicKey = null; + _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; _authType = null; + _originalPathHashMode = null; + _userChangedPathMode = false; + _hopBytes = 1; + _traceHopBytes = 1; - // Clear regional channels (keeps only Public) + // Clear regional channels (keeps only Public) and scope ChannelService.clearRegionalChannels(); _regionalChannels = []; + _scope = null; // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -2036,14 +2639,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Toggle auto-ping mode (Active, Passive, or Hybrid) - /// Returns false if blocked by cooldown (Active/Hybrid Mode only - Passive Mode ignores cooldown) + /// Set the target repeater ID for targeted mode + void setTargetRepeaterId(String? id) { + _targetRepeaterId = id; + notifyListeners(); + } + + /// Auto-stop auto-ping after prolonged idle (no movement) + void _triggerIdleAutoStop() { + if (!_autoPingEnabled) return; + final elapsed = _idleAutoStopReference != null + ? DateTime.now().difference(_idleAutoStopReference!).inMinutes + : 30; + debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); + logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + _idleAutoStopReference = null; + toggleAutoPing(_autoMode); + } + + /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) + /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) Future toggleAutoPing(AutoMode mode) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; final isHybrid = mode == AutoMode.hybrid; - final isTxMode = !isPassive; // Active and Hybrid both do TX + final isTargeted = mode == AutoMode.targeted; + final isTxMode = !isPassive; // Active, Hybrid, and Targeted all do TX // If currently running the same mode, stop it (always allow stopping) if (_autoPingEnabled && _autoMode == mode) { @@ -2088,12 +2710,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); _autoPingEnabled = false; + _idleAutoStopReference = null; - // Start 7-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode + // Clear top-heard overlay on stop + _clearOverlayState(); + + // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed if (isTxMode) { - _cooldownTimer.start(7000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (7s) - blocks TX Ping and TX modes'); + _cooldownTimer.start(5000); + debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2121,6 +2747,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop countdown timers when switching modes _autoPingTimer.stop(); _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); // Save offline session if offline mode is enabled if (_preferences.offlineMode) { await _saveOfflineSession(); @@ -2138,7 +2766,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.setAutoPingInterval(intervalMs); debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); - final started = await _pingService!.enableAutoPing(passiveMode: isPassive, hybridMode: isHybrid); + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { @@ -2152,9 +2785,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reference: state.rxTracking.isWardriving = true in wardrive.js _rxLogger?.startWardriving(); _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active'; + final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -2174,7 +2808,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : 'Active Mode'; + final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -2191,6 +2825,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); } @@ -2200,7 +2835,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txLogEntries.clear(); _rxLogEntries.clear(); _discLogEntries.clear(); + _traceLogEntries.clear(); _errorLogEntries.clear(); + _clearOverlayState(); notifyListeners(); } @@ -2214,6 +2851,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Add a trace log entry (from Trace Mode) + void _addTraceLogEntry(TraceLogEntry entry) { + _traceLogEntries.insert(0, entry); + if (_traceLogEntries.length > _maxLogEntries) { + _traceLogEntries.removeLast(); + } + debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + + // Update top repeaters overlay with successful trace result + if (entry.success && entry.localSnr != null) { + // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay + final id = entry.targetRepeaterId.toUpperCase(); + final displayId = id.length > 6 ? id.substring(0, 6) : id; + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + } + + notifyListeners(); + } + /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { @@ -2282,6 +2938,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode _inZone = null; _currentZone = null; @@ -2289,6 +2947,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _lastZoneCheckPosition = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { + // Stop auto-save timer when leaving offline mode + _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); @@ -2341,6 +3001,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(offlineMode: true); _apiQueueService.offlineMode = true; + // 5b. Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -2375,8 +3038,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _saveOfflineSession(); // 4. Request new auth session - final deviceName = _meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Use "Anonymous" if renamed, otherwise real name + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot switch to online mode: no device name available'); @@ -2396,33 +3062,117 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - debugLog('[APP] Requesting auth for online mode'); - final result = await _apiService.requestAuth( + // ============================================================ + // STAGE 1: Try existing public_key authentication + // ============================================================ + debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + model: modelString, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, ); - if (result == null) { - debugError('[APP] Auth request failed: no response'); - _modeSwitchError = 'Unable to connect to server'; + // Check for maintenance mode + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } - if (result['success'] != true) { - final reason = result['reason'] as String?; - final message = result['message'] as String?; - debugError('[APP] Auth request failed: $reason - $message'); - _modeSwitchError = message ?? reason ?? 'Authentication failed'; + // Check if Stage 1 succeeded + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + } else if (result == null) { + // API unreachable (null = network/timeout error) + debugError('[APP] API unreachable - network error'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; return (success: false, error: _modeSwitchError); + } else { + // Stage 1 failed — check if Stage 2 is worth attempting + debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + _modeSwitchError = result['message'] as String? ?? 'GPS error'; + return (success: false, error: _modeSwitchError); + } + + // ============================================================ + // STAGE 2: Auth failed, attempt registration via signed contact_uri + // ============================================================ + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + _modeSwitchError = 'Companion not found in backend and failed to register via API'; + return (success: false, error: _modeSwitchError); + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: modelString, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; + return (success: false, error: _modeSwitchError); + } + + if (registerResult['success'] != true) { + final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + _modeSwitchError = serverMessage ?? 'Registration rejected by server'; + return (success: false, error: _modeSwitchError); + } + + // Registration successful + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + + result = registerResult; } // 5. Auth successful - update state @@ -2496,6 +3246,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } @@ -2535,17 +3286,59 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } - // Include device info for auth during upload (same priority as online auth: SelfInfo name → BLE name) + // Include device info for auth during upload (use real name, not "Anonymous" — sessions upload later) // Note: Connection already validates device name exists, so this should never be null + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, - deviceName: _meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''), + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, ); + _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); + _stopOfflineAutoSaveTimer(); notifyListeners(); } + /// Periodically auto-save offline pings to prevent data loss from app kill. + /// Uses a non-destructive snapshot so in-memory accumulation continues. + void _autoSaveOfflinePings() { + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + + final pings = _apiQueueService.getOfflinePingsSnapshot(); + if (pings.isEmpty) return; + + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + + _offlineSessionService.updateCurrentSession( + pings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + } + + void _startOfflineAutoSaveTimer() { + _offlineAutoSaveTimer?.cancel(); + _offlineAutoSaveTimer = Timer.periodic(const Duration(seconds: 60), (_) { + _autoSaveOfflinePings(); + }); + debugLog('[OFFLINE] Auto-save timer started (60s interval)'); + } + + void _stopOfflineAutoSaveTimer() { + if (_offlineAutoSaveTimer != null) { + _offlineAutoSaveTimer!.cancel(); + _offlineAutoSaveTimer = null; + debugLog('[OFFLINE] Auto-save timer stopped'); + } + } + /// Upload a stored offline session Future uploadOfflineSession(String filename) async { final sessionData = _offlineSessionService.getSessionData(filename); @@ -2583,14 +3376,42 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Upload an offline session with authenticated API session - /// Uses stored device credentials to authenticate before uploading + /// Uses stored device credentials to authenticate before uploading. + /// Session is fully isolated from the shared ApiService state — offline uploads + /// never touch _sessionId and cannot trigger BLE disconnect on failure. /// + /// @param onProgress Optional callback for progress updates (e.g., "Batch 1/3") /// Returns the result of the upload operation - Future uploadOfflineSessionWithAuth(String filename) async { + Future uploadOfflineSessionWithAuth( + String filename, { + void Function(String status)? onProgress, + }) async { + // Concurrency guard — only one offline upload at a time + if (_isUploadingOfflineSession) { + debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + return OfflineUploadResult.uploadInProgress; + } + + _isUploadingOfflineSession = true; + notifyListeners(); + + try { + return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + } finally { + _isUploadingOfflineSession = false; + notifyListeners(); + } + } + + /// Internal implementation of offline session upload with isolated session + Future _uploadOfflineSessionIsolated( + String filename, { + void Function(String status)? onProgress, + }) async { // 1. Get session with stored device credentials final session = _offlineSessionService.getSession(filename); if (session == null) { - debugLog('[APP] Offline session not found: $filename'); + debugLog('[OFFLINE] Session not found: $filename'); return OfflineUploadResult.notFound; } @@ -2601,25 +3422,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(); if (pings == null || pings.isEmpty) { - debugLog('[APP] Offline session has no pings: $filename'); + debugLog('[OFFLINE] Session has no pings: $filename'); return OfflineUploadResult.invalidSession; } // 2. Get device credentials from session final publicKey = session.devicePublicKey; if (publicKey == null) { - debugLog('[APP] Offline session missing device public key: $filename'); + debugLog('[OFFLINE] Session missing device public key: $filename'); return OfflineUploadResult.invalidSession; } final deviceName = session.deviceName; if (deviceName == null || deviceName.isEmpty) { - debugLog('[APP] Offline session missing device name: $filename'); + debugLog('[OFFLINE] Session missing device name: $filename'); return OfflineUploadResult.invalidSession; } - // 3. Authenticate with offline_mode: true - debugLog('[APP] Authenticating for offline upload with device: $deviceName'); + onProgress?.call('Authenticating...'); + + // 3. Authenticate with offline_mode: true, skipSessionStore: true + // This prevents writing to shared _sessionId/_txAllowed/etc. + debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -2632,54 +3456,105 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, offlineMode: true, + skipSessionStore: true, ); - if (authResult == null || authResult['success'] != true) { - final reason = authResult?['reason'] as String? ?? 'unknown'; - debugError('[API] Offline upload auth failed: $reason'); + Map? effectiveAuth = authResult; + + if (authResult == null) { + debugError('[OFFLINE] Auth failed: network error'); return OfflineUploadResult.authFailed; } - debugLog('[APP] Offline upload authenticated, session: ${authResult['session_id']}'); + if (authResult['success'] != true) { + final reason = authResult['reason'] as String? ?? 'unknown'; + debugLog('[OFFLINE] Stage 1 failed: $reason'); + + // Stage 2: If unknown_device and we have a stored contactUri, attempt registration + if (reason == 'unknown_device' && session.contactUri != null) { + debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: session.contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: 'Offline Upload', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + + if (registerResult == null || registerResult['success'] != true) { + final regReason = registerResult?['reason'] as String? ?? 'unknown'; + debugError('[OFFLINE] Stage 2 registration failed: $regReason'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + effectiveAuth = registerResult; + } else { + debugError('[OFFLINE] Auth failed: $reason'); + return OfflineUploadResult.authFailed; + } + } + + // Extract session_id into local variable — never stored in shared state + final offlineSessionId = effectiveAuth!['session_id'] as String?; + if (offlineSessionId == null) { + debugError('[OFFLINE] Auth succeeded but no session_id in response'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); - // 4. Upload pings in batches of 50 + // 4. Upload pings in batches of 50 using isolated session const batchSize = 50; var uploadedCount = 0; var failedBatches = 0; + final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { + final batchNum = (i ~/ batchSize) + 1; + onProgress?.call('Batch $batchNum/$totalBatches'); + final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatch(batch); + final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; - debugLog('[APP] Uploaded batch ${(i ~/ batchSize) + 1}: ${batch.length} pings'); + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); } else { failedBatches++; - debugError('[APP] Failed to upload batch ${(i ~/ batchSize) + 1}'); + debugError('[OFFLINE] Failed to upload batch $batchNum'); } } // Delay after posting before disconnect await Future.delayed(const Duration(seconds: 1)); - // 5. Release API session + // 5. Release isolated API session (does not clear shared state) + onProgress?.call('Finalizing...'); await _apiService.requestAuth( reason: 'disconnect', publicKey: publicKey, + sessionId: offlineSessionId, ); - debugLog('[APP] Offline upload session released'); + debugLog('[OFFLINE] Isolated upload session released'); // 6. Mark session as uploaded (don't delete) if all batches succeeded if (failedBatches == 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[API] Uploaded ${pings.length} pings from $filename'); + debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[API] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -2705,23 +3580,104 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void updatePreferences(UserPreferences preferences) { debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); + _preferences = preferences; - // Clear restored flag — user is making a manual choice now + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; - // Persist antenna choice per device name - final deviceName = displayDeviceName; + // Persist antenna choice per device name (use original name, not "Anonymous") + final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } + // Persist power override per device name + if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + _devicePowerOverrides[deviceName] = { + 'powerLevel': preferences.powerLevel, + 'txPower': preferences.txPower, + }; + _saveDevicePowerOverrides(); + debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + } else if (deviceName != null && preferences.autoPowerSet) { + // User re-selected the auto-detected value — clear any saved override + if (_devicePowerOverrides.remove(deviceName) != null) { + _saveDevicePowerOverrides(); + debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + } + } + + // Propagate RSSI filter setting to live trackers/validators + _syncRssiFilterSetting(preferences.disableRssiFilter); + + // Propagate CARpeater prefix to live trackers + _syncCarpeaterPrefix(); + + // Propagate min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = preferences.minPingDistanceMeters; + notifyListeners(); _savePreferences(); } + /// Set anonymous mode, disconnecting and reconnecting if currently connected + Future setAnonymousMode(bool enabled) async { + if (enabled == _preferences.anonymousMode) return; + + _preferences = _preferences.copyWith(anonymousMode: enabled); + _savePreferences(); + notifyListeners(); + + // If connected, disconnect and reconnect for clean auth session + if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + final deviceToReconnect = _bluetoothService.connectedDevice; + if (deviceToReconnect != null) { + _requestConnectionTabSwitch = true; + notifyListeners(); + await disconnect(); // Full cleanup (restores name if previously anonymous) + // Short delay for BLE cleanup + await Future.delayed(const Duration(milliseconds: 500)); + await connectToDevice(deviceToReconnect); + } + } + } + + /// Propagate carpeaterPrefix to live TxTracker and RxLogger + void _syncCarpeaterPrefix() { + final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + if (_txTracker != null) { + _txTracker!.carpeaterPrefix = prefix; + debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); + } + if (_rxLogger != null) { + _rxLogger!.carpeaterPrefix = prefix; + debugLog('[APP] Synced RxLogger.carpeaterPrefix = ${prefix ?? 'null'}'); + } + } + + /// Propagate disableRssiFilter to all active trackers and validators + void _syncRssiFilterSetting(bool disableRssiFilter) { + if (_txTracker != null) { + _txTracker!.disableRssiFilter = disableRssiFilter; + } + if (_unifiedRxHandler != null) { + final oldValidator = _unifiedRxHandler!.validator; + final newValidator = PacketValidator( + allowedChannels: oldValidator.allowedChannels, + disableRssiFilter: disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + } + if (_pingService != null) { + _pingService!.disableRssiFilter = disableRssiFilter; + } + } + /// Set developer mode (unlocked by tapping version 7 times) void setDeveloperMode(bool enabled) { _preferences = _preferences.copyWith(developerModeEnabled: enabled); @@ -2816,6 +3772,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } + /// Clear the connection tab switch request (called by main scaffold after switching) + void clearConnectionTabSwitchRequest() { + _requestConnectionTabSwitch = false; + } + // ============================================ // API Error Handling // ============================================ @@ -2907,6 +3868,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { authErrors.contains(reason) || zoneErrors.contains(reason)) { debugLog('[API] Session error requires disconnect: $reason'); + + // Preserve queued wardrive data to offline storage before disconnect clears it + if (sessionErrors.contains(reason)) { + try { + final queuedPings = await _apiQueueService.extractAllAsJson(); + if (queuedPings.isNotEmpty) { + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + await _offlineSessionService.saveSession( + queuedPings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + } + } catch (e) { + debugError('[APP] Failed to preserve queue to offline storage: $e'); + } + } + // Don't call requestAuth disconnect - session is already invalid on server // Just cleanup locally and disconnect await disconnect(); @@ -3115,6 +4099,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { + // Update position even on failure to prevent zone check flooding + // (without this, every GPS update re-triggers a zone check while driving) + _lastZoneCheckPosition = _currentPosition; debugError('[GEOFENCE] Zone status check failed: no response from API'); _scheduleZoneCheckRetry( seconds: 5, @@ -3153,11 +4140,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { - logError('GPS Accuracy Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_inaccurate'); + logError('GPS Accuracy Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_inaccurate'; + notifyListeners(); } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_stale'); + logError('GPS Stale Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_stale'; + notifyListeners(); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); @@ -3209,6 +4200,68 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Sync zone capacity display with auth result. + /// The /status API (pre-connection) and /auth API (during connection) can + /// return different capacity views. This keeps the connection screen's slot + /// display consistent with the map tab's txAllowed flag. + void _syncZoneCapacityFromAuth(Map authResult) { + if (_currentZone == null) return; + + // If auth response includes slot data, use it directly (forward-compatible) + if (authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = authResult['slots_available']; + debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + } + if (authResult.containsKey('slots_max')) { + _currentZone!['slots_max'] = authResult['slots_max']; + debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + } + + // Sync at_capacity with tx_allowed + final authTxAllowed = authResult['tx_allowed'] == true; + _currentZone!['at_capacity'] = !authTxAllowed; + + // If auth says TX not allowed and server didn't provide slot data, set slots to 0 + if (!authTxAllowed && !authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = 0; + debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + } + + // If auth says TX allowed and we have slot data but server didn't provide updated count, + // decrement by 1 (we just took a slot) + if (authTxAllowed && !authResult.containsKey('slots_available')) { + final available = _currentZone!['slots_available'] as int?; + if (available != null && available > 0) { + _currentZone!['slots_available'] = available - 1; + debugLog('[CAPACITY] Took a slot, slots_available=${available - 1}'); + } + } + + notifyListeners(); + } + + /// Start periodic zone status refresh while connected. + /// Keeps slot counts and capacity status fresh during a session. + void _startZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) async { + if (!isConnected || _preferences.offlineMode) { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + return; + } + debugLog('[CAPACITY] Periodic zone refresh'); + await checkZoneStatus(); + }); + debugLog('[CAPACITY] Started 60s zone refresh timer'); + } + + /// Stop zone status refresh timer. + void _stopZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -3235,21 +4288,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Debug File Logging (Mobile Only) // ============================================ - /// Auto-enable debug file logging for development builds - Future _autoEnableDebugLogsIfDevelopmentBuild() async { + /// Initialize debug file logging, respecting persisted user preference. + /// Enabled by default on all builds. If the user previously disabled it, + /// that preference is restored. + Future _initDebugLogs() async { if (kIsWeb) return; // File logging not available on web - if (AppConstants.isDevelopmentBuild) { - debugLog('[INIT] Development build detected (${AppConstants.appVersion}), auto-enabling debug logs'); - try { - await DebugFileLogger.enable(); + try { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) { + // Can't read preference — keep default (enabled, already started in main.dart) _debugLogsEnabled = true; await _refreshDebugLogFiles(); - } catch (e) { - debugError('[INIT] Failed to auto-enable debug logs: $e'); + return; } - } else { - debugLog('[INIT] Release build (${AppConstants.appVersion}), debug logs disabled by default'); + + final userDisabled = box.get('debug_logs_enabled') == false; + + if (userDisabled) { + debugLog('[INIT] Debug logs disabled by user preference, turning off'); + await DebugFileLogger.disable(); + _debugLogsEnabled = false; + DebugLogger.setEnabled(false); + } else { + debugLog('[INIT] Debug logging enabled (${AppConstants.appVersion})'); + // DebugFileLogger already enabled in main.dart + _debugLogsEnabled = true; + await _refreshDebugLogFiles(); + } + } catch (e) { + debugError('[INIT] Failed to init debug logs: $e'); + // Fallback: keep enabled (already started in main.dart) + _debugLogsEnabled = true; } } @@ -3266,6 +4336,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _debugLogsEnabled = true; DebugLogger.setEnabled(true); await _refreshDebugLogFiles(); + // Persist user preference + final box = await _openBoxSafely(_preferencesBoxName); + await box?.put('debug_logs_enabled', true); notifyListeners(); debugLog('[DEBUG] Debug file logging enabled'); } catch (e) { @@ -3285,6 +4358,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await DebugFileLogger.disable(); _debugLogsEnabled = false; DebugLogger.setEnabled(false); + // Persist user preference + final box = await _openBoxSafely(_preferencesBoxName); + await box?.put('debug_logs_enabled', false); notifyListeners(); } catch (e) { debugError('[DEBUG] Failed to disable debug file logging: $e'); @@ -3630,6 +4706,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); + + // Apply saved min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = _preferences.minPingDistanceMeters; } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -3683,6 +4763,40 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // Device Power Override Persistence + // ============================================ + + /// Load per-device power overrides from Hive storage + Future _loadDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_power_overrides'); + if (raw != null) { + _devicePowerOverrides = (raw as Map).map( + (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + ); + debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device power overrides: $e'); + } + } + + /// Save per-device power overrides to Hive storage + Future _saveDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_power_overrides', _devicePowerOverrides); + } catch (e) { + debugLog('[APP] Failed to save device power overrides: $e'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ @@ -3945,6 +5059,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Cleanup // ============================================ + void _cancelPendingAutoPingRestore() { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = null; + _reconnectRestoreGeneration++; + } + @override @override void notifyListeners() { @@ -3956,6 +5076,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); + _connectionSubscription?.cancel(); + _gpsStatusSubscription?.cancel(); + _gpsPositionSubscription?.cancel(); _logRxDataSubscription?.cancel(); _noiseFloorSubscription?.cancel(); _batterySubscription?.cancel(); @@ -3964,6 +5087,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneCheckCountdownTimer?.cancel(); _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); + _restoreAutoPingTimer?.cancel(); + _offlineAutoSaveTimer?.cancel(); + _zoneRefreshTimer?.cancel(); + _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index e186fe3e..ce97f871 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,7 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; /// BLE device selection and connection screen @@ -74,84 +75,50 @@ class _ConnectionScreenState extends State with WidgetsBinding @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; - - // Build FAB for scanning - show Cancel during scan, Scan when idle - // Hide FAB entirely during maintenance mode (maintenance UI has its own buttons) - // Hide FAB when Bluetooth is off (shows full-screen message instead) - // Hide FAB during auto-reconnect - Widget? fab; - if (appState.isScanning) { - // Show Cancel FAB during active scan - fab = isLandscape - ? FloatingActionButton.small( - onPressed: () => appState.stopScan(), - backgroundColor: Colors.red, - child: const Icon(Icons.close), - ) - : FloatingActionButton.extended( - onPressed: () => appState.stopScan(), - icon: const Icon(Icons.close), - label: const Text('Cancel'), - backgroundColor: Colors.red, - ); - } else if (appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff) { - // Offline mode bypasses both zone and maintenance checks - final canScan = appState.offlineMode || appState.inZone == true; - fab = isLandscape - ? FloatingActionButton.small( - onPressed: canScan ? () => appState.startScan() : null, - backgroundColor: canScan ? null : Colors.grey, - child: const Icon(Icons.bluetooth_searching), - ) - : FloatingActionButton.extended( - onPressed: canScan ? () => appState.startScan() : null, - icon: const Icon(Icons.bluetooth_searching), - label: Text(appState.offlineMode - ? 'Scan' - : appState.gpsStatus == GpsStatus.disabled - ? 'GPS Disabled' - : appState.gpsStatus == GpsStatus.permissionDenied - ? 'GPS Required' - : appState.isCheckingZone - ? 'Checking Zone...' - : appState.inZone == true - ? 'Scan' - : appState.inZone == false - ? 'Outside Zone' - : 'Checking Zone...'), - backgroundColor: canScan ? null : Colors.grey, - ); - } return Scaffold( appBar: AppBar( - title: const Text('Connection'), + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: _buildBody(context, appState), - floatingActionButton: fab, ); } Widget _buildBody(BuildContext context, AppStateProvider appState) { - // Show reconnecting UI + // Reconnecting and connection progress don't show the zone bar or bottom bar if (appState.connectionStep == ConnectionStep.reconnecting) { return _buildReconnectingView(context, appState); } - - // Show connection progress if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } + // All other states: zone bar top, content middle, action bar bottom + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildStateContent(context, appState)), + _buildBottomBar(context, appState), + ], + ); + } + + /// Routes to the correct sub-view (zone bar is already rendered above) + Widget _buildStateContent(BuildContext context, AppStateProvider appState) { // Show connected state if (appState.isConnected) { + final pathWarning = appState.pendingPathHashWarning; + if (pathWarning != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + appState.clearPathHashWarning(); + }); + } return _buildConnectedInfo(context, appState); } @@ -164,6 +131,99 @@ class _ConnectionScreenState extends State with WidgetsBinding return _buildDeviceList(context, appState); } + /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + ); + } + + /// The right-side action button: Scan, Cancel, or Disconnect + Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.isConnected) { + // Disconnect + return _buildBottomButton( + icon: Icons.bluetooth_disabled, + label: 'Disconnect', + color: Colors.red, + onPressed: () async => await appState.disconnect(), + ); + } + + if (appState.isScanning) { + // Cancel scan + return _buildBottomButton( + icon: Icons.close, + label: 'Cancel', + color: Colors.red, + onPressed: () => appState.stopScan(), + ); + } + + // Scan — disabled when can't scan + final canScan = appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + !appState.isBluetoothOff && + (appState.offlineMode || appState.inZone == true); + + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canScan ? () => appState.startScan() : null, + ); + } + + /// Styled button matching OfflineModeToggle shape + Widget _buildBottomButton({ + required IconData icon, + required String label, + required Color color, + VoidCallback? onPressed, + }) { + final enabled = onPressed != null; + final effectiveColor = enabled ? color : Colors.grey; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: effectiveColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: effectiveColor), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveColor, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; @@ -248,132 +308,129 @@ class _ConnectionScreenState extends State with WidgetsBinding // Get device name - uses displayDeviceName which prefers SelfInfo name over BLE advertisement name final deviceName = appState.displayDeviceName ?? 'Unknown'; - // Parse version from manufacturer string and use shortName from device model for hardware - // Format examples: - // - "MeshCore (Heltec V3) v1.10.0" - // - "Ikoka Stick-E22-30dBm (Xiao_nrf52) nightly-e31c46f" + // Extract version from firmware version string (v7+), fall back to manufacturer string String? version; - final manufacturerString = appState.manufacturerString; - if (manufacturerString != null) { - // Use regex to find version pattern directly instead of splitting - // Match: v followed by digits/dots, OR nightly- followed by hex, OR just digits.digits - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); - final match = versionRegex.firstMatch(manufacturerString); - if (match != null) { - version = match.group(1); + final fwString = appState.firmwareVersionString; + if (fwString != null && fwString.isNotEmpty) { + final semverMatch = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(fwString); + if (semverMatch != null) { + version = semverMatch.group(1); + } else { + final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + if (nightlyMatch != null) { + version = nightlyMatch.group(1); + } + } + } + if (version == null) { + final manufacturerString = appState.manufacturerString; + if (manufacturerString != null) { + final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final match = versionRegex.firstMatch(manufacturerString); + if (match != null) { + version = match.group(1); + } } } - // Use shortName from device model if available, otherwise fall back to manufacturer string - final hardware = appState.deviceModel?.shortName ?? manufacturerString ?? 'Unknown'; - + final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; + final platform = appState.deviceModel?.platform; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final prefs = appState.preferences; + final isAutoMode = appState.autoPingEnabled; + final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Build device info card - final deviceInfoCard = Card( + // Compact device summary card + final deviceSummaryCard = Card( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Header: BT icon + name/status Row( children: [ - const Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 28, - ), - const SizedBox(width: 12), + const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Connected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - ), + deviceName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, ), Text( - deviceName, - style: Theme.of(context).textTheme.bodyLarge, + 'Connected', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), ), ], ), ), ], ), - const Divider(height: 20), - _buildInfoRow('Hardware', hardware), - _buildInfoRow('Version', version ?? 'Unknown'), - if (appState.deviceModel != null) - _buildInfoRow('Platform', appState.deviceModel!.platform), - if (appState.devicePublicKey != null) + const SizedBox(height: 8), + + // Detail chips: hardware, version, platform + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _buildDetailChip(context, Icons.memory, hardware), + if (version != null) _buildDetailChip(context, Icons.code, version), + if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + ], + ), + + // Power level row + const SizedBox(height: 4), + _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + + // Public key row + if (appState.devicePublicKey != null) ...[ + const SizedBox(height: 8), _buildPublicKeyRow(context, appState.devicePublicKey!), - if (appState.authType != null && !appState.offlineMode) + ], + + // Registered via row + if (appState.authType != null && !appState.offlineMode) ...[ + const SizedBox(height: 4), _buildAuthTypeRow(context, appState.authType!), + ], ], ), ), ); - // Build disconnect button - final disconnectButton = ElevatedButton.icon( - onPressed: () async { - await appState.disconnect(); - }, - icon: const Icon(Icons.bluetooth_disabled), - label: const Text('Disconnect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), + // Compact channels card + final channelsCard = RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + scope: appState.offlineMode ? null : appState.scope, + isOfflineMode: appState.offlineMode, + compact: true, ); if (isLandscape) { - // Landscape: two-column layout return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left column: device info + disconnect Expanded( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: deviceInfoCard, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ], - ), + child: SingleChildScrollView(child: deviceSummaryCard), ), const SizedBox(width: 12), - // Right column: power + regional config Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildPowerLevelCard(context, appState), - const SizedBox(height: 12), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - isOfflineMode: appState.offlineMode, - ), - ], - ), - ), + child: SingleChildScrollView(child: channelsCard), ), ], ), @@ -381,55 +438,122 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Portrait: vertical layout - return Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), + // Portrait: compact vertical layout (bottom bar provided by _buildBody) + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + deviceSummaryCard, + const SizedBox(height: 12), + channelsCard, + ], + ), + ); + } + + /// Power level row matching Registered via / Public Key format + Widget _buildPowerRow( + BuildContext context, + AppStateProvider appState, + bool isPowerSet, + bool isAutoMode, + UserPreferences prefs, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: InkWell( + onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( children: [ - deviceInfoCard, - const SizedBox(height: 16), - _buildPowerLevelCard(context, appState), - const SizedBox(height: 16), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - isOfflineMode: appState.offlineMode, + const SizedBox( + width: 120, + child: Text( + 'Power Level', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + const SizedBox(width: 4), + Text( + isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + style: TextStyle( + fontWeight: FontWeight.w500, + color: isPowerSet ? null : Colors.orange, + ), + ), + if (prefs.autoPowerSet) ...[ + const SizedBox(width: 4), + const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const SizedBox(width: 2), + const Text( + 'Auto', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 14, color: Colors.orange), + const SizedBox(width: 2), + const Text( + 'Override', + style: TextStyle( + fontSize: 12, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + if (!isAutoMode) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + ], + ], ), ], ), ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ), - ], + ), ); } - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + /// Small detail chip with icon + text + Widget _buildDetailChip(BuildContext context, IconData icon, String text) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), + Icon(icon, size: 12, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, ), ), - Expanded(child: Text(value)), ], ), ); } + + Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -721,50 +845,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildPowerLevelCard(BuildContext context, AppStateProvider appState) { - final prefs = appState.preferences; - final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - return Card( - child: ListTile( - leading: const Icon(Icons.power), - title: const Text('Power Level'), - subtitle: Builder( - builder: (context) { - if (!isPowerSet) { - return Text( - 'Unknown hardware - select power', - style: TextStyle(color: Colors.orange.shade700), - ); - } - return Row( - children: [ - Text(prefs.powerLevelDisplay), - if (prefs.autoPowerSet) ...[ - const SizedBox(width: 8), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), - const SizedBox(width: 4), - const Text( - 'Auto', - style: TextStyle( - fontSize: 12, - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ); - }, - ), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), - ), - ); - } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; @@ -799,6 +879,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, // Clear auto flag on override + powerLevelSet: true, // Mark as manually overridden ), ); Navigator.pop(context); // Close confirmation @@ -831,23 +912,29 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Auto-detection info banner - if (prefs.autoPowerSet && deviceModel != null) + // Auto-detection / override info banner + if (deviceModel != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - border: Border.all(color: Colors.green), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), + border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const Icon(Icons.auto_awesome, size: 20, color: Colors.green), + Icon( + prefs.autoPowerSet ? Icons.auto_awesome : Icons.edit, + size: 20, + color: prefs.autoPowerSet ? Colors.green : Colors.orange, + ), const SizedBox(width: 8), Expanded( child: Text( - 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W', + prefs.autoPowerSet + ? 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W' + : 'Override active \u2014 ${deviceModel.shortName} auto-detects as ${deviceModel.power}W', style: const TextStyle(fontSize: 12), ), ), @@ -867,7 +954,7 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = prefs.autoPowerSet && deviceModel != null && power == deviceModel.power; + final isRecommended = deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -914,6 +1001,21 @@ class _ConnectionScreenState extends State with WidgetsBinding ], ), actions: [ + if (!prefs.autoPowerSet && deviceModel != null) + TextButton( + onPressed: () { + appState.updatePreferences( + prefs.copyWith( + powerLevel: deviceModel.power, + txPower: deviceModel.txPower, + autoPowerSet: true, + powerLevelSet: false, + ), + ); + Navigator.pop(context); + }, + child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), @@ -934,18 +1036,22 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.error_outline, + appState.isNetworkError ? Icons.cloud_off : Icons.error_outline, size: isLandscape ? 48 : 64, - color: Colors.red, + color: appState.isNetworkError ? Colors.orange : Colors.red, ), SizedBox(height: isLandscape ? 8 : 16), Text( - appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + appState.isNetworkError + ? 'Server Unreachable' + : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - appState.connectionError ?? 'Unknown error', + appState.isNetworkError + ? 'MeshMapper services unreachable, try again or use offline mode.' + : appState.connectionError ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), @@ -982,6 +1088,17 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; + // Network error: show wifi off indicator + } else if (appState.zoneCheckErrorReason == 'network') { + locationIcon = Icons.wifi_off; + locationText = 'No Internet'; + locationColor = Colors.red; + // GPS error: show GPS issue indicator + } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + locationIcon = Icons.gps_off; + locationText = 'GPS Unavailable'; + locationColor = Colors.orange; // Show "Checking Zone..." whenever a zone check is in progress // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { @@ -1152,103 +1269,89 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.engineering, + size: 64, + color: Colors.orange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Maintenance Mode', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 32), + // Primary action: Enable Offline Mode + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + if (appState.maintenanceUrl != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('More Info'), + ), + ], + const SizedBox(height: 32), + // Info note at bottom + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.engineering, - size: 64, - color: Colors.orange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'Maintenance Mode', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 32), - // Primary action: Enable Offline Mode - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - if (appState.maintenanceUrl != null) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('More Info'), - ), - ], - const SizedBox(height: 32), - // Info note at bottom - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } // Show Bluetooth off message (takes priority over zone checks) if (appState.isBluetoothOff) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_disabled, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Bluetooth is Off', - message: 'Please enable Bluetooth to scan for MeshCore devices.', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_disabled, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Bluetooth is Off', + message: 'Please enable Bluetooth to scan for MeshCore devices.', ); } @@ -1266,71 +1369,150 @@ class _ConnectionScreenState extends State with WidgetsBinding message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.public_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Region Not Available', - message: message, - action: OutlinedButton.icon( - onPressed: () => _launchOnboardingUrl(), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Request Region Onboarding'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.public_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Region Not Available', + message: message, + action: OutlinedButton.icon( + onPressed: () => _launchOnboardingUrl(), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Request Region Onboarding'), + ), ); } // Show zone checking status on initial startup (before zone is known) if (appState.inZone == null && !appState.offlineMode) { if (appState.zoneCheckError != null) { - // Zone check failed — show error with countdown final countdown = appState.zoneCheckRetryCountdown; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.cloud_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Zone Check Failed', - message: countdown > 0 - ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' - : '${appState.zoneCheckError}\n\nRetrying...', + + // Network error — show offline mode option (matches maintenance pattern) + if (appState.zoneCheckErrorReason == 'network') { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: Colors.deepOrange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'No Internet Connection', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + countdown > 0 + ? 'Retrying in ${countdown}s...' + : 'Retrying...', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + ), + ), + ], + ), + ), + ], ), ), - ], + ); + } + + // GPS errors — no auto-retry, show manual retry button + if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: appState.zoneCheckErrorReason == 'gps_inaccurate' + ? 'GPS Accuracy Error' + : 'GPS Stale Error', + message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + action: FilledButton.icon( + onPressed: () => appState.checkZoneStatus(), + icon: const Icon(Icons.refresh), + label: const Text('Retry Zone Check'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ); + } + + // Other non-network errors — show simple error with countdown + return _buildMessageContent( + context: context, + icon: Icons.cloud_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Zone Check Failed', + message: countdown > 0 + ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' + : '${appState.zoneCheckError}\n\nRetrying...', ); } // Zone check in progress or waiting for GPS - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_searching, - iconColor: Colors.blue.withValues(alpha: 0.7), - title: 'Checking MeshMapper Zone...', - message: 'Verifying your location with MeshMapper', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_searching, + iconColor: Colors.blue.withValues(alpha: 0.7), + title: 'Checking MeshMapper Zone...', + message: 'Verifying your location with MeshMapper', ); } if (appState.isScanning) { return Column( children: [ - _buildZoneStatusBar(context, appState), const LinearProgressIndicator(), Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), ], @@ -1341,12 +1523,7 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect)), - ], - ); + return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1354,74 +1531,48 @@ class _ConnectionScreenState extends State with WidgetsBinding // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', - action: isIOS - ? null - : ElevatedButton.icon( - onPressed: () => Geolocator.openLocationSettings(), - icon: const Icon(Icons.settings), - label: const Text('Open Location Settings'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Location Services Disabled', + message: 'Please enable Location Services to verify you\'re in an allowed zone.', + action: isIOS + ? null + : ElevatedButton.icon( + onPressed: () => Geolocator.openLocationSettings(), + icon: const Icon(Icons.settings), + label: const Text('Open Location Settings'), + ), ); } // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', - action: ElevatedButton.icon( - onPressed: () => _requestLocationPermission(appState), - icon: const Icon(Icons.location_on), - label: const Text('Enable Location'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'GPS Permission Required', + message: 'Location access is needed to verify you\'re in an allowed zone.', + action: ElevatedButton.icon( + onPressed: () => _requestLocationPermission(appState), + icon: const Icon(Icons.location_on), + label: const Text('Enable Location'), + ), ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_searching, - iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - title: 'No devices found', - message: 'Tap Scan to search for MeshCore devices', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_searching, + iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + title: 'No devices found', + message: 'Tap Scan to search for MeshCore devices', ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), - ], - ); + return _buildDeviceListView(context, appState, canConnect: canConnect); } void _launchOnboardingUrl() async { @@ -1543,6 +1694,82 @@ class _ConnectionScreenState extends State with WidgetsBinding }, ); } + + void _showPathHashWarning(BuildContext context, int hopBytes, String reason) { + if (reason == 'firmware_unsupported') { + // Firmware too old to support multi-byte paths + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber, size: 24, color: Colors.amber), + SizedBox(width: 8), + Flexible(child: Text('Firmware Update Recommended')), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your regional admin has enabled $hopBytes-byte path mode for this zone, ' + 'but your companion firmware does not support it.', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + 'The app will operate in 1-byte mode. Consider updating your companion ' + 'firmware to v1.14.0+ for more accurate repeater identification.', + style: TextStyle(fontSize: 13), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } else { + // Mode changed successfully + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.linear_scale, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Multi-Byte Paths Enabled')), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your radio has been updated from 1-byte to $hopBytes-byte paths for this session ($reason).', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + 'Your radio will remain in multi-byte mode until you change it.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } } class _DeviceListTile extends StatelessWidget { diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index b9a4141d..977ce8cf 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -19,12 +19,13 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Noise Floor History'), + toolbarHeight: 40, + title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) IconButton( - icon: const Icon(Icons.delete_outline), + icon: const Icon(Icons.delete_outline, size: 20), onPressed: () => _confirmClearSessions(context, appState), tooltip: 'Clear all sessions', ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index eacda815..d984a989 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -175,6 +175,14 @@ class _HomeScreenState extends State { onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), + // Trace count + _buildAppBarStatChip( + Icons.route, + appState.pingStats.traceCount, + Colors.cyan, + onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + ), + const SizedBox(width: 8), // Upload count _buildAppBarStatChip( Icons.cloud_done, @@ -230,7 +238,7 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -331,6 +339,9 @@ class _HomeScreenState extends State { case 'disc': return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -880,20 +891,12 @@ class _HomeScreenState extends State { description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), - // Offline mode toggle - _buildHelpItem( - icon: Icons.cloud_off, - color: Colors.orange, - title: 'Offline Mode', - description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.', - ), - - // Sound toggle + // Trace Mode _buildHelpItem( - icon: Icons.volume_up, - color: Colors.blue, - title: 'Sound', - description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.', + icon: Icons.gps_fixed, + color: Colors.cyan, + title: 'Trace Mode', + description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index bb8a6a39..97267e0b 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -3,10 +3,11 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../widgets/repeater_id_chip.dart'; -/// Log screen with tabs for TX Log, RX Log, and User Errors +/// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -16,11 +17,12 @@ class LogScreen extends StatefulWidget { class _LogScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final _allPingsKey = GlobalKey<_AllPingsTabState>(); @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 2, vsync: this); } @override @@ -36,227 +38,155 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 3) { - _tabController.animateTo(3); // Switch to Error tab + if (mounted && _tabController.index != 1) { + _tabController.animateTo(1); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); }); } + final totalPings = appState.txLogEntries.length + + appState.rxLogEntries.length + + appState.discLogEntries.length + + appState.traceLogEntries.length; + + final errorCount = appState.errorLogEntries.length; + return Scaffold( appBar: AppBar( - title: const Text('Logs'), + toolbarHeight: 40, + title: const Text('Logs', style: TextStyle(fontSize: 18)), actions: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () => _copyCurrentTabToCsv(context, appState), - tooltip: 'Copy CSV', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmClearLogs(context, appState), - tooltip: 'Clear all logs', + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == 'copy') _copyCurrentTabToCsv(context, appState); + if (value == 'clear') _confirmClearLogs(context, appState); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), + const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + ], ), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(32), + child: TabBar( + controller: _tabController, + indicatorSize: TabBarIndicatorSize.tab, + dividerHeight: 1, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + ], + ), + ), ), - body: Column( + body: TabBarView( + controller: _tabController, children: [ - // Secondary bar with tabs (full width) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(3, 'Errors', appState.errorLogEntries.length, isError: true)), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _TxLogTab(entries: appState.txLogEntries), - _RxLogTab(entries: appState.rxLogEntries), - _DiscLogTab(entries: appState.discLogEntries), - _ErrorLogTab(entries: appState.errorLogEntries), - ], - ), + _AllPingsTab( + key: _allPingsKey, + allEntries: appState.unifiedPingLogEntries, + repeaters: appState.repeaters, + txCount: appState.txLogEntries.length, + rxCount: appState.rxLogEntries.length, + discCount: appState.discLogEntries.length, + traceCount: appState.traceLogEntries.length, ), + _ErrorLogTab(entries: appState.errorLogEntries), ], ), ); } - /// Build a tab chip that matches StatusBar chip styling - Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false}) { - final theme = Theme.of(context); - // Colors matching status bar chips - const discColor = Color(0xFF7B68EE); // DISC purple - const txColor = Colors.green; // TX green (matches status bar) - const rxColor = Colors.blue; // RX blue (matches status bar) - - return GestureDetector( - onTap: () { - _tabController.animateTo(index); - setState(() {}); - }, - child: AnimatedBuilder( - animation: _tabController, - builder: (context, child) { - final isCurrentlySelected = _tabController.index == index; - Color currentColor; - if (isCurrentlySelected) { - if (isError) { - currentColor = Colors.red; - } else if (isDisc) { - currentColor = discColor; - } else if (isTx) { - currentColor = txColor; - } else if (isRx) { - currentColor = rxColor; - } else { - currentColor = theme.colorScheme.primary; - } - } else { - currentColor = theme.colorScheme.onSurfaceVariant; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.15 : 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.4 : 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, - color: currentColor, - ), - ), - if (count > 0) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: currentColor, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), - ); - }, - ), - ); - } - void _copyCurrentTabToCsv(BuildContext context, AppStateProvider appState) { - final currentTab = _tabController.index; - - switch (currentTab) { - case 0: // TX Log - _copyTxLogToCsv(context, appState.txLogEntries); - break; - case 1: // RX Log - _copyRxLogToCsv(context, appState.rxLogEntries); - break; - case 2: // DISC Log - _copyDiscLogToCsv(context, appState.discLogEntries); - break; - case 3: // Error Log - _copyErrorLogToCsv(context, appState.errorLogEntries); - break; + if (_tabController.index == 0) { + _copyAllPingsToCsv(context, appState); + } else { + _copyErrorLogToCsv(context, appState.errorLogEntries); } } - void _copyTxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { + // If a search filter is active, export only the filtered unified entries + final tabState = _allPingsKey.currentState; + final searchQuery = tabState?._searchQuery ?? ''; + if (searchQuery.isNotEmpty && tabState != null) { + final filtered = tabState._filteredEntries; + if (filtered.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + ); + return; + } + final buffer = StringBuffer(); + buffer.writeln('type,data'); + for (final entry in filtered) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No TX log entries to copy'), duration: Duration(seconds: 2)), + SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), ); return; } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,power,events'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } + final tx = appState.txLogEntries; + final rx = appState.rxLogEntries; + final disc = appState.discLogEntries; + final trace = appState.traceLogEntries; - void _copyRxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No RX log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), ); return; } final buffer = StringBuffer(); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + + if (tx.isNotEmpty) { + buffer.writeln('--- TX Log ---'); + buffer.writeln('timestamp,latitude,longitude,power,events'); + for (final entry in tx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('RX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - void _copyDiscLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No DISC log entries to copy'), duration: Duration(seconds: 2)), - ); - return; + if (rx.isNotEmpty) { + buffer.writeln('--- RX Log ---'); + buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + for (final entry in rx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + if (disc.isNotEmpty) { + buffer.writeln('--- DISC Log ---'); + buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + for (final entry in disc) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); + } + + if (trace.isNotEmpty) { + buffer.writeln('--- TRC Log ---'); + buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + for (final entry in trace) { + buffer.writeln(entry.toCsv()); + } } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('DISC log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), ); } @@ -284,7 +214,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, and error logs.'), + content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -303,231 +233,349 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } } -/// TX Log Tab -class _TxLogTab extends StatelessWidget { - final List entries; +// ============================================================================= +// All Pings Tab — unified chronological view with type filters +// ============================================================================= + +class _AllPingsTab extends StatefulWidget { + final List allEntries; + final List repeaters; + final int txCount; + final int rxCount; + final int discCount; + final int traceCount; + + const _AllPingsTab({ + super.key, + required this.allEntries, + required this.repeaters, + required this.txCount, + required this.rxCount, + required this.discCount, + required this.traceCount, + }); - const _TxLogTab({required this.entries}); + @override + State<_AllPingsTab> createState() => _AllPingsTabState(); +} + +class _AllPingsTabState extends State<_AllPingsTab> { + final Set _activeFilters = { + PingLogType.tx, + PingLogType.rx, + PingLogType.disc, + PingLogType.trace, + }; + + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + /// Current filtered entries (used by CSV export) + List _filteredEntries = []; @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.upload_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No TX pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } + void dispose() { + _searchController.dispose(); + super.dispose(); + } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildTxEntry(context, entry); - }, - ); + void _toggleFilter(PingLogType type) { + setState(() { + if (_activeFilters.contains(type)) { + // Don't allow deselecting the last filter + if (_activeFilters.length > 1) { + _activeFilters.remove(type); + } + } else { + _activeFilters.add(type); + } + }); } - Widget _buildTxEntry(BuildContext context, TxLogEntry entry) { - final appState = context.read(); + // --------------------------------------------------------------------------- + // Repeater name resolution & search matching + // --------------------------------------------------------------------------- + + /// Resolve a short repeater ID to known repeater names via prefix matching. + static ({List names, bool ambiguous}) _resolveRepeaterNames( + String repeaterId, List repeaters, + ) { + final idLower = repeaterId.toLowerCase(); + final matches = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(idLower)) + .map((r) => r.name) + .toList(); + return (names: matches, ambiguous: matches.length > 1); + } - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time and Power - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + /// Whether a hex ID has ambiguous name matches (maps to multiple repeaters). + static bool _isAmbiguousId(String repeaterId, List repeaters) { + return _resolveRepeaterNames(repeaterId, repeaters).ambiguous; + } + + /// True if the query looks like a hex string (only 0-9, a-f). + static bool _isHexQuery(String query) { + return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); + } + + /// Whether an entry matches the current search query. + bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty) return true; + final query = _searchQuery.toLowerCase(); + + switch (entry.type) { + case PingLogType.tx: + final tx = entry.asTx; + for (final event in tx.events) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.rx: + final rx = entry.asRx; + if (rx.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(rx.repeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + case PingLogType.disc: + final disc = entry.asDisc; + for (final node in disc.discoveredNodes) { + if (node.repeaterId.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.trace: + final trace = entry.asTrace; + if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + } + } + + /// Whether an entry should show the ambiguity indicator. + /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. + bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; + + switch (entry.type) { + case PingLogType.tx: + return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + case PingLogType.rx: + return _isAmbiguousId(entry.asRx.repeaterId, repeaters); + case PingLogType.disc: + return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + case PingLogType.trace: + return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); + } + } + + @override + Widget build(BuildContext context) { + final filtered = widget.allEntries + .where((e) => _activeFilters.contains(e.type)) + .where((e) => _matchesSearch(e, widget.repeaters)) + .toList(); + _filteredEntries = filtered; + + final hasEntries = widget.allEntries.isNotEmpty; + final hasResults = filtered.isNotEmpty; + + return Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: SizedBox( + height: 36, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: 'Search by repeater name or ID', + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + prefixIconConstraints: const BoxConstraints(minWidth: 36), + suffixIcon: _searchQuery.isNotEmpty + ? GestureDetector( + onTap: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: const Icon(Icons.close, size: 18), + ) + : null, + suffixIconConstraints: const BoxConstraints(minWidth: 36), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - const Spacer(), - // Power indicator (watts) - Text( - '${entry.power.toStringAsFixed(1)} W', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - ], + ), + onChanged: (value) => setState(() => _searchQuery = value.trim()), ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], + ), + ), + // Filter segmented row + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - - // Repeaters table - if (entry.events.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + children: [ + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _segmentDivider(context), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _segmentDivider(context), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _segmentDivider(context), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + ], + ), + ), + ), + ), + // List + Expanded( + child: !hasResults + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasEntries ? Icons.search_off : Icons.list_alt, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.events.map((event) => _buildRepeaterRow(context, event)), - ], + const SizedBox(height: 16), + Text( + hasEntries && _searchQuery.isNotEmpty + ? 'No results for \'$_searchQuery\'' + : 'No pings logged yet', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + itemCount: filtered.length, + itemBuilder: (context, index) { + final unified = filtered[index]; + final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + return switch (unified.type) { + PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + }; + }, ), - ), - ] else ...[ - const SizedBox(height: 8), + ), + ], + ); + } + + Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + final active = _activeFilters.contains(type); + return Expanded( + child: GestureDetector( + onTap: () => _toggleFilter(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7), + color: active ? color.withValues(alpha: 0.12) : Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - 'No repeaters heard', + label, style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), ), + if (count > 0) ...[ + const SizedBox(width: 4), + Container( + constraints: const BoxConstraints(minWidth: 18, minHeight: 16), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.0, + ), + ), + ), + ], ], - ], + ), ), ), - ), ); } - /// Build a table row for a repeater event - Widget _buildRepeaterRow(BuildContext context, RxEvent event) { - Color snrColor; - switch (event.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (event.rssi >= -70) { - rssiColor = Colors.green; - } else if (event.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + static Widget _segmentDivider(BuildContext context) { + return VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ); + } - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildTxChip(event.snr.toStringAsFixed(1), snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildTxChip('${event.rssi}', rssiColor), - ), - ), - ], + // --------------------------------------------------------------------------- + // Type badge + // --------------------------------------------------------------------------- + + static Widget _buildTypeBadge(PingLogType type) { + final (label, color) = switch (type) { + PingLogType.tx => ('TX', Colors.green), + PingLogType.rx => ('RX', Colors.blue), + PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), + PingLogType.trace => ('TRC', Colors.cyan), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, ), ), ); } - /// Build a small colored chip for TX table cells - Widget _buildTxChip(String value, Color color) { + // --------------------------------------------------------------------------- + // Shared chip builder + // --------------------------------------------------------------------------- + + static Widget _buildChip(String value, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -538,7 +586,7 @@ class _TxLogTab extends StatelessWidget { child: Text( value, style: TextStyle( - fontSize: 10, + fontSize: 11, fontWeight: FontWeight.w600, color: color, fontFamily: 'monospace', @@ -546,326 +594,172 @@ class _TxLogTab extends StatelessWidget { ), ); } -} - -/// RX Log Tab -class _RxLogTab extends StatelessWidget { - final List entries; - - const _RxLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No RX observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildRxEntry(context, entry); - }, - ); - } + // --------------------------------------------------------------------------- + // TX Card + // --------------------------------------------------------------------------- - Widget _buildRxEntry(BuildContext context, RxLogEntry entry) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); - - Color snrColor; - switch (entry.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (entry.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (entry.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } - return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time badge only - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + // Repeaters table + if (entry.events.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildRepeaterTable(context, entry.events), + ] else ...[ + const SizedBox(height: 8), Text( - entry.locationString, + 'No repeaters heard', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + fontStyle: FontStyle.italic, ), ), ], - ), - const SizedBox(height: 10), - - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildRxChip(entry.snr.toStringAsFixed(1), snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildRxChip('${entry.rssi}', rssiColor), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], + ], + ), ), ), - ), ); } - /// Build a small colored chip for RX table cells - Widget _buildRxChip(String value, Color color) { + Widget _buildRepeaterTable(BuildContext context, List events) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', - ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) => _buildTxRepeaterRow(context, event)), + ], ), ); } -} -/// DISC Log Tab (Discovery observations from Passive Mode) -class _DiscLogTab extends StatelessWidget { - final List entries; - - const _DiscLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( children: [ - Icon(Icons.radar_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No discovery observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text('Enable Passive Mode to discover nearby nodes', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12)), + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), ], ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; // Already sorted most recent first - return _buildDiscEntry(context, entry); - }, + ), ); } - Widget _buildDiscEntry(BuildContext context, DiscLogEntry entry) { + // --------------------------------------------------------------------------- + // RX Card + // --------------------------------------------------------------------------- + + Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); + final snrColor = _snrColor(entry.severity); + final rssiColor = _rssiColor(entry.rssi); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: Time and node count (matching TX log style) - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + const SizedBox(height: 10), + // Repeater table (single row) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], ), ), - ), - const Spacer(), - // Node count indicator (like power indicator in TX log) - Text( - '${entry.nodeCount} node${entry.nodeCount == 1 ? '' : 's'}', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + Divider(height: 1, color: Theme.of(context).dividerColor), + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), + Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + ], + ), + ), ), - ), - ], + ], + ), ), - const SizedBox(height: 8), + ], + ), + ), + ), + ); + } - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), + // --------------------------------------------------------------------------- + // DISC Card + // --------------------------------------------------------------------------- + + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + final appState = context.read(); + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), @@ -877,61 +771,19 @@ class _DiscLogTab extends StatelessWidget { ), child: Column( children: [ - // Header row Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) => _buildNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -940,7 +792,7 @@ class _DiscLogTab extends StatelessWidget { Text( 'No nodes discovered', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -953,20 +805,9 @@ class _DiscLogTab extends StatelessWidget { ); } - /// Build a table row for a discovered node - Widget _buildNodeRow(BuildContext context, DiscoveredNodeEntry node) { - // Color for RX SNR (what we received) - Color rxSnrColor; - switch (node.severity) { - case SnrSeverity.poor: - rxSnrColor = Colors.red; - case SnrSeverity.fair: - rxSnrColor = Colors.orange; - case SnrSeverity.good: - rxSnrColor = Colors.green; - } - - // TX SNR color (what they received from us) + Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + final rxSnrColor = _snrColorFromValue(node.localSnr); + final rssiColor = _rssiColor(node.localRssi); Color txSnrColor; if (node.remoteSnr <= -1) { txSnrColor = Colors.red; @@ -976,32 +817,21 @@ class _DiscLogTab extends StatelessWidget { txSnrColor = Colors.green; } - // RSSI color based on signal strength - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - // Node ID with type SizedBox( - width: 50, + width: 70, child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), + Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: const TextStyle( - fontSize: 9, + fontSize: 11, fontWeight: FontWeight.w500, color: Color(0xFF7B68EE), ), @@ -1009,53 +839,181 @@ class _DiscLogTab extends StatelessWidget { ], ), ), - // RX SNR - Expanded( - child: Center( - child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildChip('${node.localRssi}', rssiColor), - ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor), - ), - ), + Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), ); } - /// Build a small colored chip for table cells - Widget _buildChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + // --------------------------------------------------------------------------- + // Trace Card + // --------------------------------------------------------------------------- + + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + final colorScheme = Theme.of(context).colorScheme; + final appState = context.read(); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + // Results table + if (entry.success) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + _buildTraceNodeRow(context, entry), + ], + ), + ), + ] else ...[ + const SizedBox(height: 8), + Text( + 'No response', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', + ); + } + + Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); + final rssiColor = _rssiColor(entry.localRssi); + final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), + Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + return Row( + children: [ + _buildTypeBadge(type), + if (showAmbiguity) ...[ + const SizedBox(width: 2), + Tooltip( + message: 'Repeater ID matches multiple nodes', + child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + ), + ], + const SizedBox(width: 6), + Text( + timeString, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 2), + Text( + locationString, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), ), + ], + ); + } + + static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + return Text( + text, + textAlign: center ? TextAlign.center : TextAlign.left, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } + + static Color _snrColor(SnrSeverity? severity) { + return switch (severity) { + SnrSeverity.poor => Colors.red, + SnrSeverity.fair => Colors.orange, + SnrSeverity.good => Colors.green, + null => Colors.grey, + }; + } + + static Color _snrColorFromValue(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + + static Color _snrColorFromNullableValue(double? snr) { + if (snr == null) return Colors.grey; + return _snrColorFromValue(snr); + } + + static Color _rssiColor(int? rssi) { + if (rssi == null) return Colors.grey; + if (rssi >= -70) return Colors.green; + if (rssi >= -100) return Colors.orange; + return Colors.red; + } } -/// Error Log Tab +// ============================================================================= +// Error Log Tab (unchanged) +// ============================================================================= + class _ErrorLogTab extends StatelessWidget { final List entries; @@ -1080,7 +1038,7 @@ class _ErrorLogTab extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: entries.length, itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) + // Most recent first final entry = entries[entries.length - 1 - index]; return _buildErrorEntry(context, entry); }, diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index b0bc9034..87c2c435 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -26,6 +26,7 @@ class MainScaffold extends StatefulWidget { class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; + bool _hasShownLocationSettingsPrompt = false; final List _screens = [ const HomeScreen(), @@ -59,18 +60,15 @@ class _MainScaffoldState extends State { // Check if disclosure was already shown final hasShown = await PermissionDisclosureService.hasShownDisclosure(); - if (hasShown) { - debugLog('[DISCLOSURE] Already shown, skipping'); - return; + if (!hasShown) { + // Show the disclosure dialog + if (!mounted) return; + debugLog('[DISCLOSURE] Showing location disclosure dialog'); + await PermissionDisclosureService.showLocationDisclosure(context); } - // Show the disclosure dialog - if (!mounted) return; - debugLog('[DISCLOSURE] Showing location disclosure dialog'); - await PermissionDisclosureService.showLocationDisclosure(context); - - debugLog('[DISCLOSURE] User acknowledged, requesting permissions'); - await _requestPermissionsAfterDisclosure(); + debugLog('[DISCLOSURE] Ensuring location permission after disclosure'); + await _ensureLocationPermission(); } /// Request GPS permission on web (triggers browser's native prompt) @@ -93,8 +91,10 @@ class _MainScaffoldState extends State { } } - /// Request permissions after user accepts disclosure - Future _requestPermissionsAfterDisclosure() async { + /// Ensure location permission after disclosure has been shown. + /// Requests when possible, restarts GPS when granted, and surfaces a settings CTA + /// when the permission has been permanently denied. + Future _ensureLocationPermission() async { bool granted = false; if (Platform.isIOS) { @@ -104,12 +104,23 @@ class _MainScaffoldState extends State { permission = await Geolocator.requestPermission(); } debugLog('[DISCLOSURE] iOS location permission: $permission'); + if (permission == LocationPermission.deniedForever) { + _showLocationSettingsPrompt(); + return; + } granted = permission == LocationPermission.always || permission == LocationPermission.whileInUse; } else { - // Android: Request location via permission_handler - final status = await Permission.locationWhenInUse.request(); + // Android: only request if needed so previously granted permission just restarts GPS. + var status = await Permission.locationWhenInUse.status; + if (status.isDenied) { + status = await Permission.locationWhenInUse.request(); + } debugLog('[DISCLOSURE] Android location permission: $status'); + if (status.isPermanentlyDenied) { + _showLocationSettingsPrompt(); + return; + } granted = status.isGranted; } @@ -121,6 +132,21 @@ class _MainScaffoldState extends State { } } + void _showLocationSettingsPrompt() { + if (!mounted || _hasShownLocationSettingsPrompt) return; + _hasShownLocationSettingsPrompt = true; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission is disabled in system settings.'), + action: SnackBarAction( + label: 'Settings', + onPressed: Geolocator.openAppSettings, + ), + ), + ); + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -149,6 +175,18 @@ class _MainScaffoldState extends State { }); } + // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) + if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _selectedIndex = 3; // Switch to Connect tab + }); + appState.clearConnectionTabSwitchRequest(); + } + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b0132083..c99446f8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -14,17 +14,18 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../utils/web_file_helpers_stub.dart' if (dart.library.html) '../utils/web_file_helpers.dart'; +import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../models/user_preferences.dart'; import '../services/debug_file_logger.dart'; -import '../services/debug_submit_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/offline_session_service.dart'; import '../services/permission_disclosure_service.dart'; import '../utils/constants.dart'; import '../widgets/bug_report_dialog.dart'; +import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; @@ -41,93 +42,19 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - // Debug log upload tracking - String? _uploadingFilePath; - final Set _uploadedFiles = {}; + Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + final result = await showUploadLogsDialog(context, appState); - Future _uploadSingleLogFile(AppStateProvider appState, File file) async { - final filename = file.path.split('/').last; - - setState(() { - _uploadingFilePath = file.path; - }); - - try { - final service = DebugSubmitService(); - - // Use persistent device info - final publicKey = appState.devicePublicKey ?? - appState.lastConnectedPublicKey ?? - 'not-connected'; - final deviceName = appState.lastConnectedDeviceName ?? 'not-connected'; - - final success = await service.uploadDebugFileOnly( - file: file, - deviceId: deviceName, - publicKey: publicKey, - appVersion: AppConstants.appVersion, - devicePlatform: DebugSubmitService.getDevicePlatform(), - userNotes: 'Direct debug log upload from $deviceName', - ); - - service.dispose(); - - if (!mounted) return; - - if (success) { - setState(() { - _uploadedFiles.add(file.path); - _uploadingFilePath = null; - }); - AppToast.success(context, 'Uploaded $filename'); - } else { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Failed to upload $filename'); - } - } catch (e) { - debugError('[SETTINGS] Log file upload error: $e'); - if (mounted) { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Upload error: $e'); - } - } - } - - /// Upload the current (active) log file by rotating it first - Future _uploadCurrentLogFile(AppStateProvider appState, File currentFile) async { - final originalPath = currentFile.path; - - setState(() { - _uploadingFilePath = originalPath; - }); - - try { - // Rotate the log: closes current file, starts a new one - // The original file is now closed and safe to upload - await appState.prepareDebugLogsForUpload(); - - if (!mounted) return; - - // The original file still exists at the same path, now closed - final closedFile = File(originalPath); - if (!closedFile.existsSync()) { - throw Exception('Log file not found after rotation'); - } + if (!context.mounted || result == null) return; - // Now upload the closed file - await _uploadSingleLogFile(appState, closedFile); - } catch (e) { - debugError('[SETTINGS] Current log upload error: $e'); - if (mounted) { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Upload error: $e'); + if (result.success) { + String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + if (result.failedCount > 0) { + message += ' (${result.failedCount} failed)'; } + AppToast.success(context, message); + } else if (result.errorMessage != null) { + AppToast.error(context, result.errorMessage!); } } @@ -175,633 +102,807 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Settings'), + toolbarHeight: 40, + title: const Text('Settings', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ - // Appearance section - _buildSectionHeader(context, 'Appearance'), - ListTile( - leading: Icon( - prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + // Lock indicator + if (isAutoMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: const Row( + children: [ + Icon(Icons.lock, size: 16, color: Colors.amber), + SizedBox(width: 8), + Text( + 'Some settings locked during auto-ping', + style: TextStyle(fontSize: 12, color: Colors.amber), + ), + ], + ), + ), ), - title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), - trailing: Switch( + + // General + _buildSection(context, 'General', [ + SwitchListTile( + secondary: Icon( + prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + ), + title: const Text('Theme'), + subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - ), - ListTile( - leading: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - trailing: Switch( + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); }, ), - ), - - const Divider(), - - // Wardriving Settings section - _buildSectionHeader(context, 'Wardriving Settings'), - - // Auto-Ping Interval Selector - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Auto-Ping Interval'), - subtitle: Text(prefs.autoPingIntervalDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), - ), - - // Hybrid Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.compare_arrows), - title: Row( - children: [ - const Text('Hybrid Mode'), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHybridModeInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + SwitchListTile( + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, ), - subtitle: const Text('Combines Active and Passive modes'), - value: prefs.hybridModeEnabled, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, - ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + ]), - // Carpeater Ignore Setting - SwitchListTile( - secondary: const Icon(Icons.filter_alt), - title: const Text('Ignore Carpeater'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Filtering repeater 0x${prefs.ignoreRepeaterId}' - : 'Tap to set repeater ID to ignore'), - value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - // Show dialog to set repeater ID when enabling - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, - ), - - // Repeater ID to Ignore - show when enabled - if (prefs.ignoreCarpeater) + // Ping Settings + _buildSection(context, 'Ping Settings', [ + SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: const Text('Anonymous Mode'), + subtitle: Text(prefs.anonymousMode + ? 'Device broadcasts as "Anonymous"' + : 'Device uses its real name'), + value: prefs.anonymousMode, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, + ), ListTile( - leading: const SizedBox(width: 24), // Indent - title: const Text('Repeater ID'), - subtitle: Text(prefs.ignoreRepeaterId != null - ? '0x${prefs.ignoreRepeaterId}' - : 'Not set'), + leading: const Icon(Icons.timer), + title: const Text('Auto-Ping Interval'), + subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), ), + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Min Ping Distance'), + subtitle: Text(prefs.minPingDistanceDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + SwitchListTile( + secondary: const Icon(Icons.timer_off), + title: const Text('Auto-Stop After Idle'), + subtitle: const Text('Stops auto-ping after 30 min without movement'), + value: prefs.autoStopAfterIdle, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); + }, + ), + ]), - // Lock indicator - if (isAutoMode) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + // Modes + _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.compare_arrows), + title: Row( children: [ - Icon(Icons.lock, size: 16, color: Colors.orange.shade700), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Settings locked during auto-ping mode', - style: TextStyle( - fontSize: 12, - color: Colors.orange.shade700, - fontStyle: FontStyle.italic, - ), + const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHybridModeInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), + subtitle: appState.enforceHybrid + ? const Text( + 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Combines Active and Passive modes'), + value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, + onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { + appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); + }, ), - - // Background Mode - for "Always" location permission (iOS and Android) - if (!kIsWeb) ...[ - const Divider(), - _buildSectionHeader(context, 'Background Mode'), - _BackgroundModeToggle(appState: appState), - ], - - const Divider(), - - // Data Management section - _buildSectionHeader(context, 'Data Management'), - ListTile( - leading: const Icon(Icons.cloud_queue), - title: const Text('Queued Pings'), - subtitle: Text('${appState.queueSize} items waiting'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: appState.queueSize > 0 - ? () => appState.forceUploadQueue() - : null, - tooltip: 'Force upload', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: appState.queueSize > 0 - ? () => _confirmClearQueue(context, appState) - : null, - tooltip: 'Clear queue', - ), - ], - ), - ), - - // Clear Map Markers - ListTile( - leading: const Icon(Icons.delete_sweep), - title: const Text('Clear Map Markers'), - subtitle: const Text('Remove all TX/RX markers from map'), - onTap: () => _confirmClearPings(context, appState), - ), - - // Offline Sessions - if (appState.offlineSessions.isNotEmpty) ...[ - _buildSectionHeader(context, 'Offline Sessions'), - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), - ], - - const Divider(), - - // Device Info section - _buildSectionHeader(context, 'Device'), - ListTile( - leading: const Icon(Icons.perm_identity), - title: const Text('Device ID'), - subtitle: Text(appState.deviceId), - ), - - const Divider(), - - // About section - _buildSectionHeader(context, 'About'), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text(AppConstants.appName), - ), - GestureDetector( - onTap: () => _onVersionTap(appState), - child: ListTile( - leading: const Icon(Icons.new_releases_outlined), - title: const Text('Version'), - subtitle: Text(AppConstants.appVersion), - ), - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: const Text('Submit Feedback'), - subtitle: const Text('Report bugs or request features'), - onTap: () => _showBugReportDialog(context, appState), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.github), - title: const Text('GitHub'), - subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: const Text('Discord'), - subtitle: const Text('Join our community chat'), - onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), - ), - ListTile( - leading: const Icon(Icons.groups), - title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), - onTap: () => _launchUrl('https://ottawamesh.ca/'), - ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee ☕'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), - - // Exit Options (Android only) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ...[ - const Divider(), - _buildSectionHeader(context, 'Exit Options'), - - // Auto-close toggle SwitchListTile( - secondary: const Icon(Icons.exit_to_app), - title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), - value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + secondary: const Icon(Icons.signal_wifi_off), + title: Row( + children: [ + const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDiscDropInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: appState.enforceDiscDrop + ? const Text( + 'Set by Regional Admin — reports dead zones for network analysis.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Count failed discoveries as failed pings'), + value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, + onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); + } + }, ), + ]), - // Manual close button - ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), - title: const Text('Close App'), - subtitle: const Text('Exit the app completely'), - onTap: () => _showCloseAppConfirmation(context, appState), + // Filtering + _buildSection(context, 'Filtering', [ + SwitchListTile( + secondary: const Icon(Icons.filter_alt), + title: const Text('CARpeater Filter'), + subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), + value: prefs.ignoreCarpeater, + onChanged: isAutoMode ? null : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); + } + }, ), - ], - - // Developer Tools section - only visible when developer mode is enabled - if (appState.developerModeEnabled) ...[ - const Divider(), - - _buildSectionHeader(context, 'Developer Tools'), - - // Developer mode toggle (to disable) + if (prefs.ignoreCarpeater) + ListTile( + leading: const SizedBox(width: 24), + title: const Text('CARpeater ID'), + subtitle: Text(prefs.ignoreRepeaterId != null + ? '0x${prefs.ignoreRepeaterId}' + : 'Not set'), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + ), SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: const Text('Developer Mode'), - subtitle: const Text('Disable to hide developer tools'), - value: appState.developerModeEnabled, - onChanged: (value) { - appState.setDeveloperMode(value); + secondary: const Icon(Icons.shield_outlined), + title: const Text('Disable RSSI Filter'), + subtitle: Text(prefs.disableRssiFilter + ? 'Allows all signal strengths' + : 'Drops signals stronger than -30 dBm'), + value: prefs.disableRssiFilter, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); + } }, ), + ]), - // GPS Simulator Toggle - SwitchListTile( - secondary: Icon( - Icons.gps_fixed, - color: appState.isGpsSimulatorEnabled ? Colors.orange : null, - ), - title: Row( - children: [ - const Text('GPS Simulator'), - if (appState.isGpsSimulatorEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SIMULATED', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + // Radio Settings + _buildSection(context, 'Radio', [ + ListTile( + leading: const Icon(Icons.linear_scale), + title: Row( + children: [ + const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHopBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], - ], - ), - subtitle: Text(appState.isGpsSimulatorEnabled - ? 'Smooth simulated movement active' - : 'Use simulated GPS for testing'), - value: appState.isGpsSimulatorEnabled, - onChanged: (value) { - if (value) { - appState.enableGpsSimulator(); - } else { - appState.disableGpsSimulator(); - } - }, - ), - - // Simulator Settings (only when enabled) - if (appState.isGpsSimulatorEnabled) ...[ - // Speed Slider - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Simulation Speed'), - subtitle: Slider( - value: appState.gpsSimulatorSpeed, - min: 10, - max: 120, - divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - onChanged: (value) { - appState.setGpsSimulatorSpeed(value); - }, ), - trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - style: const TextStyle(fontWeight: FontWeight.bold), + subtitle: appState.enforceHopBytes + ? const Text( + 'Set by Regional Admin — larger IDs reduce collisions in your region.', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in TX/RX path hops'), + trailing: DropdownButton( + value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + ], + onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setHopBytes(value); + }, ), ), - - // Pattern Selector ListTile( - leading: const SizedBox(width: 24), - title: const Text('Movement Pattern'), - trailing: SizedBox( - width: 180, - child: DropdownButton( - value: appState.gpsSimulatorPattern, - underline: const SizedBox(), - isExpanded: true, - items: [ - const DropdownMenuItem( - value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + leading: const Icon(Icons.gps_fixed), + title: Row( + children: [ + const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showTraceBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - const DropdownMenuItem( - value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in trace path'), + trailing: DropdownButton( + value: appState.traceHopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 4, child: Text('4')), + ], + onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setTraceHopBytes(value); + }, + ), + ), + SwitchListTile( + secondary: const Icon(Icons.delete_sweep), + title: Row( + children: [ + const Flexible(child: Text('Delete Channel on Disconnect')), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDeleteChannelInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - if (appState.hasSimulatorRoute) - DropdownMenuItem( - value: SimulatorPattern.route, - child: Text( - 'Route: ${appState.simulatorRouteName ?? "Loaded"}', - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: (pattern) { - if (pattern != null) { - appState.setGpsSimulatorPattern(pattern); - } - }, - ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), + subtitle: Text(prefs.deleteChannelOnDisconnect + ? 'Removes #wardriving channel from device' + : 'Keeps #wardriving channel on device'), + value: prefs.deleteChannelOnDisconnect, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + }, ), + ]), - // Load Route File + // Data Management + _buildSection(context, 'Data', [ ListTile( - leading: const SizedBox(width: 24), - title: const Text('Load Route File'), - subtitle: Text(appState.hasSimulatorRoute - ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' - : 'KML or GPX file'), + leading: const Icon(Icons.cloud_queue), + title: const Text('Queued Pings'), + subtitle: Text('${appState.queueSize} items waiting'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () => _pickRouteFile(context, appState), - tooltip: 'Load route file', + icon: const Icon(Icons.cloud_upload), + onPressed: appState.queueSize > 0 + ? () => appState.forceUploadQueue() + : null, + tooltip: 'Force upload', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: appState.queueSize > 0 + ? () => _confirmClearQueue(context, appState) + : null, + tooltip: 'Clear queue', ), - if (appState.hasSimulatorRoute) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - appState.clearSimulatorRoute(); - AppToast.info(context, 'Route cleared'); - }, - tooltip: 'Clear route', - ), ], ), ), - - // Reset Position Button ListTile( - leading: const SizedBox(width: 24), - title: const Text('Reset Position'), - subtitle: Text(appState.hasSimulatorRoute - ? 'Reset to route start' - : 'Reset to Ottawa downtown'), - trailing: IconButton( - icon: const Icon(Icons.restart_alt), - onPressed: () { - appState.resetGpsSimulator(); - AppToast.info( - context, - appState.hasSimulatorRoute - ? 'Reset to route start' - : 'GPS simulator reset to Ottawa downtown', - ); - }, - ), + leading: const Icon(Icons.delete_sweep), + title: const Text('Clear Map Markers'), + subtitle: const Text('Remove all TX/RX markers from map'), + onTap: () => _confirmClearPings(context, appState), ), - ], - ], // Close developerModeEnabled conditional - - // Debug section (always visible on mobile) - if (!kIsWeb) ...[ - const Divider(), + ]), - _buildSectionHeader(context, 'Debug'), + // Offline Sessions + _buildSection(context, 'Offline Sessions', [ + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession(context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), + onDownload: () => _downloadOfflineSession(context, appState, session.filename), + )), + ]), + + // About + _buildSection(context, 'About', [ + const ListTile( + leading: Icon(Icons.info_outline), + title: Text(AppConstants.appName), + subtitle: Text('Mesh network coverage mapper'), + ), + ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: const Text('Version'), + subtitle: Text(AppConstants.appVersion), + onTap: () => _onVersionTap(appState), + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('Submit Feedback'), + subtitle: const Text('Report bugs or request features'), + onTap: () => _showBugReportDialog(context, appState), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.github), + title: const Text('GitHub'), + subtitle: const Text('View issues and source code'), + onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Discord'), + subtitle: const Text('Join our community chat'), + onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), + ), + ListTile( + leading: const Icon(Icons.groups), + title: const Text('Community'), + subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + onTap: () => _launchUrl('https://ottawamesh.ca/'), + ), + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), + ]), - SwitchListTile( - secondary: Icon( - Icons.bug_report, - color: appState.debugLogsEnabled ? Colors.orange : null, + // Exit Options (Android only) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + _buildSection(context, 'Exit', [ + SwitchListTile( + secondary: const Icon(Icons.exit_to_app), + title: const Text('Close App After Disconnect'), + subtitle: const Text('Automatically exit the app when disconnecting'), + value: prefs.closeAppAfterDisconnect, + onChanged: (value) => appState.setCloseAppAfterDisconnect(value), ), - title: Row( - children: [ - const Text('Debug Logs'), - if (appState.debugLogsEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LOGGING', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, + ListTile( + leading: const Icon(Icons.power_settings_new, color: Colors.red), + title: const Text('Close App'), + subtitle: const Text('Exit the app completely'), + onTap: () => _showCloseAppConfirmation(context, appState), + ), + ]), + + // Developer Tools - only visible when developer mode is enabled + if (appState.developerModeEnabled) + _buildSection(context, 'Developer Tools', [ + SwitchListTile( + secondary: const Icon(Icons.developer_mode), + title: const Text('Developer Mode'), + subtitle: const Text('Disable to hide developer tools'), + value: appState.developerModeEnabled, + onChanged: (value) { + appState.setDeveloperMode(value); + }, + ), + SwitchListTile( + secondary: Icon( + Icons.gps_fixed, + color: appState.isGpsSimulatorEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('GPS Simulator'), + if (appState.isGpsSimulatorEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SIMULATED', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), - ), + ], ], - ], - ), - subtitle: Text( - appState.debugLogsEnabled - ? AppConstants.isDevelopmentBuild - ? 'Auto-enabled for development build' - : 'Writing logs to file' - : 'Enable to save debug logs to device', + ), + subtitle: Text(appState.isGpsSimulatorEnabled + ? 'Smooth simulated movement active' + : 'Use simulated GPS for testing'), + value: appState.isGpsSimulatorEnabled, + onChanged: (value) { + if (value) { + appState.enableGpsSimulator(); + } else { + appState.disableGpsSimulator(); + } + }, ), - value: appState.debugLogsEnabled, - onChanged: (value) async { - if (value) { - await appState.enableDebugLogs(); - } else { - await appState.disableDebugLogs(); - } - }, - ), + if (appState.isGpsSimulatorEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Simulation Speed'), + subtitle: Slider( + value: appState.gpsSimulatorSpeed, + min: 10, + max: 120, + divisions: 11, + label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + onChanged: (value) { + appState.setGpsSimulatorSpeed(value); + }, + ), + trailing: Text( + formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Movement Pattern'), + trailing: SizedBox( + width: 180, + child: DropdownButton( + value: appState.gpsSimulatorPattern, + underline: const SizedBox(), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: SimulatorPattern.straight, + child: Text('Straight Line', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.circle, + child: Text('Circle', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.randomWalk, + child: Text('Random Walk', overflow: TextOverflow.ellipsis), + ), + if (appState.hasSimulatorRoute) + DropdownMenuItem( + value: SimulatorPattern.route, + child: Text( + 'Route: ${appState.simulatorRouteName ?? "Loaded"}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (pattern) { + if (pattern != null) { + appState.setGpsSimulatorPattern(pattern); + } + }, + ), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Load Route File'), + subtitle: Text(appState.hasSimulatorRoute + ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' + : 'KML or GPX file'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () => _pickRouteFile(context, appState), + tooltip: 'Load route file', + ), + if (appState.hasSimulatorRoute) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + appState.clearSimulatorRoute(); + AppToast.info(context, 'Route cleared'); + }, + tooltip: 'Clear route', + ), + ], + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Reset Position'), + subtitle: Text(appState.hasSimulatorRoute + ? 'Reset to route start' + : 'Reset to Ottawa downtown'), + trailing: IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + appState.resetGpsSimulator(); + AppToast.info( + context, + appState.hasSimulatorRoute + ? 'Reset to route start' + : 'GPS simulator reset to Ottawa downtown', + ); + }, + ), + ), + ], + ]), - // Log Files List (show when toggle is ON or when files exist) - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ - // Section header with Delete All button - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( + // Debug section (always visible on mobile) + if (!kIsWeb) + _buildSection(context, 'Debug', [ + SwitchListTile( + secondary: Icon( + Icons.bug_report, + color: appState.debugLogsEnabled ? Colors.orange : null, + ), + title: Row( children: [ - Text( - 'Debug Log Files', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.grey.shade600, + const Text('Debug Logs'), + if (appState.debugLogsEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LOGGING', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, ), - ), - const Spacer(), - if (appState.debugLogFiles.isNotEmpty) - TextButton.icon( - icon: const Icon(Icons.delete_sweep, size: 18), - label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + ), ), + ], ], ), - ), - - // Log files list or empty message - if (appState.debugLogFiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), - ), - ) - else - ...appState.debugLogFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isUploading = _uploadingFilePath == file.path; - final wasUploaded = _uploadedFiles.contains(file.path); - // First file (index 0) is the most recent/active log - can't upload - final isCurrentLog = index == 0; - // Parse unix timestamp from filename (meshmapper-debug-{timestamp}.txt) - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); - final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) - : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; + subtitle: Text( + appState.debugLogsEnabled + ? 'Writing logs to file' + : 'Enable to save debug logs to device', + ), + value: appState.debugLogsEnabled, + onChanged: (value) async { + if (value) { + await appState.enableDebugLogs(); } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - if (isCurrentLog) { - sizeDisplay = '$sizeDisplay (current)'; + await appState.disableDebugLogs(); } - - return ListTile( - leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), - subtitle: Text( - sizeDisplay, - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Upload button - if (wasUploaded) - Container( - width: 40, - height: 40, - alignment: Alignment.center, - child: const Icon( - Icons.check_circle, - size: 20, - color: Colors.green, - ), - ) - else if (isUploading) - Container( - width: 40, - height: 40, - alignment: Alignment.center, - child: const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + }, + ), + if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Text( + 'Log Files', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, ), - ) - else - IconButton( - icon: const Icon(Icons.cloud_upload, size: 20), - onPressed: _uploadingFilePath != null - ? null - : () => isCurrentLog - ? _uploadCurrentLogFile(appState, file) - : _uploadSingleLogFile(appState, file), - tooltip: isCurrentLog - ? 'Close log and upload' - : 'Upload to developer', - ), - // View button - IconButton( - icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), - tooltip: 'View', + ), + const Spacer(), + if (appState.debugLogFiles.isNotEmpty) ...[ + TextButton.icon( + icon: const Icon(Icons.cloud_upload, size: 18), + label: const Text('Upload'), + onPressed: () => _showUploadLogsDialog(context, appState), ), - // Share button - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + onPressed: () => _confirmDeleteAllLogs(context, appState), ), ], + ], + ), + ), + if (appState.debugLogFiles.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No debug logs yet', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), ), - ); - }), - ], - ], + ) + else + ...appState.debugLogFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isCurrentLog = index == 0; + final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final fileDate = timestampMatch != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + : null; + final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + if (isCurrentLog) { + sizeDisplay = '$sizeDisplay (current)'; + } - const SizedBox(height: 32), + return ListTile( + leading: const Icon(Icons.description, size: 20), + title: Text(dateStr, style: const TextStyle(fontSize: 13)), + subtitle: Text( + sizeDisplay, + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + onPressed: () => _showLogViewer(context, appState, file), + tooltip: 'View', + ), + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file), + tooltip: 'Share', + ), + ], + ), + ); + }), + ], + ]), ], ), ); } - Widget _buildSectionHeader(BuildContext context, String title) { + Widget _buildSection(BuildContext context, String title, List children) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + 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), + ], + ), ), ); } @@ -846,13 +947,104 @@ class _SettingsScreenState extends State { } } - void _confirmClearQueue(BuildContext context, AppStateProvider appState) { + void _confirmClearQueue(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Queue?'), + content: Text( + 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _confirmClearPings(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Map Markers?'), + content: const Text( + 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearPings(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Disable RSSI Filter?'), + content: const Text( + 'By disabling this filter, you are confirming that you are not operating ' + 'a carpeater (a repeater co-located with your device).\n\n' + 'If this filter is disabled while a carpeater is present, your device will ' + 'report false coverage data to the MeshMapper community map. This degrades ' + 'map accuracy for everyone.\n\n' + 'Only disable this if you are certain no co-located repeater is within range.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.updatePreferences( + appState.preferences.copyWith(disableRssiFilter: true), + ); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Disable Filter'), + ), + ], + ), + ); + } + + void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Queue?'), + title: const Text('Enable Anonymous Mode?'), content: Text( - 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', + 'Your device will be renamed to "Anonymous" for all mesh pings. ' + 'Other mesh users will not see your companion name.\n\n' + 'Your public key is still used to authenticate your session, but ' + 'neither your sessions nor your pings are linked to it on the server.\n\n' + '${isConnected ? 'Your device will disconnect and reconnect automatically.\n\n' : ''}' + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may remain named "Anonymous" until you reconnect and properly disconnect. ' + 'Always use the Disconnect button to restore your device name.', ), actions: [ TextButton( @@ -861,23 +1053,24 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { - appState.clearQueue(); Navigator.pop(context); + appState.setAnonymousMode(true); }, - child: const Text('Clear'), + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('Enable'), ), ], ), ); } - void _confirmClearPings(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Map Markers?'), + title: const Text('Disable Anonymous Mode?'), content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + 'This will disconnect and reconnect your device to restore your companion name. Continue?', ), actions: [ TextButton( @@ -886,10 +1079,10 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { - appState.clearPings(); Navigator.pop(context); + appState.setAnonymousMode(false); }, - child: const Text('Clear'), + child: const Text('Continue'), ), ], ), @@ -951,8 +1144,232 @@ class _SettingsScreenState extends State { ); } + void _showDiscDropInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith(discDropEnabled: true), + ); + }, + child: const Text('Enable'), + ), + ], + ), + ); + } + + void _showDeleteChannelInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.delete_sweep, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Delete Channel on Disconnect')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, the #wardriving channel is removed from your radio when you disconnect. ' + 'This keeps your radio\'s channel list clean.\n\n' + 'When disabled, the channel remains on the radio after disconnect.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may retain the #wardriving channel until you reconnect and properly disconnect.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showHopBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.linear_scale, size: 24), + SizedBox(width: 8), + Text('TX Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used to identify each repeater in TX/RX packet paths. ' + 'More bytes = more unique IDs, reducing collisions in large networks.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + '\u2022 1 byte: 256 unique IDs (default)\n' + '\u2022 2 bytes: 65,536 unique IDs\n' + '\u2022 3 bytes: 16 million unique IDs', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + 'Requires MeshCore firmware v1.14.0+. ' + 'RX always auto-detects the sender\'s byte size.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showTraceBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.gps_fixed, size: 24), + SizedBox(width: 8), + Text('Trace Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used for the repeater ID in trace path requests. ' + 'This is separate from TX Bytes because traces use a different encoding.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'TX/RX uses a simple counter:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 3 bytes\n\n' + 'Trace uses bitshift encoding:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 4 bytes', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + '3-byte traces are not supported by the MeshCore protocol. ' + 'When your region uses 3-byte TX paths, set Trace Bytes to 4.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showIntervalSelector(BuildContext context, AppStateProvider appState) { - final currentInterval = appState.preferences.autoPingInterval; + final minInterval = appState.minModeInterval; + var currentInterval = appState.preferences.autoPingInterval; + + // Auto-bump if current interval is below the admin minimum + if (currentInterval < minInterval) { + currentInterval = minInterval; + appState.updatePreferences( + appState.preferences.copyWith(autoPingInterval: minInterval), + ); + } showDialog( context: context, @@ -971,18 +1388,46 @@ class _SettingsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: AutoPingInterval.values.map((interval) { - final isSelected = interval == currentInterval; - - return RadioListTile( - title: Text('$interval seconds'), - subtitle: Text(interval == 15 - ? 'Fast (More coverage, causes more mesh load)' - : interval == 30 - ? 'Normal (Balanced coverage and mesh load)' - : 'Slow (Less coverage, little mesh load)'), + final isDisabled = interval < minInterval; + + String description; + if (interval == 15) { + description = 'Fast (More coverage, causes more mesh load)'; + } else if (interval == 30) { + description = 'Normal (Balanced coverage and mesh load)'; + } else { + description = 'Slow (Less coverage, little mesh load)'; + } + + final tile = RadioListTile( + title: Text( + '$interval seconds', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + subtitle: isDisabled + ? const Text( + 'Set by Regional Admin — slower intervals reduce congestion in your region', + style: TextStyle(fontSize: 12, color: Colors.amber), + ) + : Text( + description, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), value: interval, - selected: isSelected, ); + + if (isDisabled) { + return IgnorePointer( + child: Opacity( + opacity: 0.5, + child: tile, + ), + ); + } + return tile; }).toList(), ), ), @@ -996,7 +1441,49 @@ class _SettingsScreenState extends State { ); } + void _showDistanceSelector(BuildContext context, AppStateProvider appState) { + final currentDistance = appState.preferences.minPingDistanceMeters; + final controller = TextEditingController(text: currentDistance.toString()); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Min Ping Distance'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + suffixText: 'meters', + helperText: 'Minimum ${MinPingDistance.min}m', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final value = int.tryParse(controller.text); + if (value != null && value >= MinPingDistance.min) { + appState.updatePreferences( + appState.preferences.copyWith(minPingDistanceMeters: value), + ); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { + const maxHexChars = 6; + const hintText = 'FFFFFF'; + final controller = TextEditingController( text: appState.preferences.ignoreRepeaterId ?? '', ); @@ -1004,22 +1491,22 @@ class _SettingsScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Ignore Repeater ID'), + title: const Text('CARpeater Repeater ID'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Enter the repeater ID to ignore (2 hex digits):'), + const Text('Enter the full 3-byte repeater ID (6 hex digits):'), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( - labelText: 'Repeater ID', - hintText: 'FF', + labelText: 'CARpeater ID', + hintText: hintText, prefixText: '0x', border: OutlineInputBorder(), ), - maxLength: 2, + maxLength: maxHexChars, textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters @@ -1034,7 +1521,9 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Enter 2-character hex ID (e.g., FF) to ignore a specific repeater.\nLeave empty to disable.', + 'Enter all 6 hex digits of your CARpeater\'s ID. ' + 'The app will automatically truncate to match your region\'s hop byte size (1, 2, or 3 bytes). ' + 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater.', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1051,7 +1540,8 @@ class _SettingsScreenState extends State { onPressed: () { final value = controller.text.trim().toUpperCase(); final isValidHex = value.isEmpty || - (value.length == 2 && RegExp(r'^[0-9A-F]{2}$').hasMatch(value)); + (value.length == maxHexChars && + RegExp(r'^[0-9A-F]+$').hasMatch(value)); if (isValidHex) { // Enable ignoreCarpeater when setting a repeater ID @@ -1064,7 +1554,7 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Invalid hex value. Use 2 digits (00-FF).'); + AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1150,31 +1640,61 @@ class _SettingsScreenState extends State { } Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { - // Show loading indicator - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ), - SizedBox(width: 12), - Text('Uploading session...'), - ], + // Progress text notifier for updating dialog without rebuilding screen + final progressNotifier = ValueNotifier('Authenticating...'); + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + 'Uploading session...', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + filename, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, status, __) => Text( + status, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ], + ), ), - duration: Duration(seconds: 30), // Will be dismissed when upload completes ), ); - final result = await appState.uploadOfflineSessionWithAuth(filename); + final result = await appState.uploadOfflineSessionWithAuth( + filename, + onProgress: (status) => progressNotifier.value = status, + ); + // Close progress dialog if (context.mounted) { - // Dismiss loading indicator - ScaffoldMessenger.of(context).hideCurrentSnackBar(); + Navigator.of(context).pop(); + } - // Show result + progressNotifier.dispose(); + + if (context.mounted) { + // Show result via SnackBar String message; Color backgroundColor; @@ -1199,6 +1719,10 @@ class _SettingsScreenState extends State { message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.uploadInProgress: + message = 'Another upload is already in progress'; + backgroundColor = Colors.orange; + break; } ScaffoldMessenger.of(context).showSnackBar( @@ -1511,29 +2035,7 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Icons.location_on, color: _hasAlwaysPermission ? Colors.green : null, ), - title: Row( - children: [ - const Text('Background Location'), - if (_hasAlwaysPermission) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'ALWAYS', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), + title: const Text('Background Location'), subtitle: Text( _hasAlwaysPermission ? 'Location tracking works in background' @@ -1557,12 +2059,14 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> /// Widget for displaying an offline session in the list class _OfflineSessionTile extends StatelessWidget { final OfflineSession session; + final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; final VoidCallback onDownload; const _OfflineSessionTile({ required this.session, + this.uploadEnabled = true, required this.onUpload, required this.onDelete, required this.onDownload, @@ -1609,9 +2113,9 @@ class _OfflineSessionTile extends StatelessWidget { if (!isUploaded) IconButton( icon: const Icon(Icons.cloud_upload), - onPressed: onUpload, + onPressed: uploadEnabled ? onUpload : null, tooltip: 'Upload session', - color: Colors.green, + color: uploadEnabled ? Colors.green : Colors.grey, ), // Delete button - always available IconButton( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index d55fdca1..2cb45faf 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -25,9 +25,13 @@ class ApiQueueService { final ApiService _apiService; Box? _box; Timer? _batchTimer; + Timer? _pingFlushTimer; bool _isUploading = false; bool _isRecovering = false; + // In-memory fallback when Hive is corrupted/unavailable + final List _memoryQueue = []; + // Offline mode bool offlineMode = false; final List> _offlinePings = []; @@ -78,6 +82,7 @@ class ApiQueueService { debugError('[API QUEUE] Failed to clear stale items: $e - recovering'); await _recoverBox(); } + _memoryQueue.clear(); _rxBuffer.clear(); _offlinePings.clear(); @@ -206,8 +211,8 @@ class ApiQueueService { } } - /// Get current queue size - int get queueSize => _safeRead((box) => box.length, 0); + /// Get current queue size (Hive + in-memory fallback) + int get queueSize => _safeRead((box) => box.length, 0) + _memoryQueue.length; /// Enqueue a TX ping /// heardRepeats format: "4e(12.25),77(12.25)" or "None" @@ -235,10 +240,20 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); - _checkBatchUpload(); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); } /// Enqueue an RX observation @@ -316,10 +331,106 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a TRACE ping result (targeted zero-hop trace) + Future enqueueTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromTrace( + latitude: latitude, + longitude: longitude, + repeaterId: repeaterId, + localSnr: localSnr, + localRssi: localRssi, + remoteSnr: remoteSnr, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] TRACE enqueued (offline): $repeaterId'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); - _checkBatchUpload(); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a failed DISC discovery (no nodes responded) + Future enqueueDiscDrop({ + required double latitude, + required double longitude, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromDiscDrop( + latitude: latitude, + longitude: longitude, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] DISC drop enqueued (offline)'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); } // Guard to prevent concurrent RX buffer flushes @@ -341,10 +452,12 @@ class ApiQueueService { final bufferSize = _rxBuffer.length; _rxBuffer.clear(); - // Now add items to the box + // Now add items to the box (or memory fallback) for (final item in itemsToFlush) { final ok = await _safeWrite((box) => box.add(item)); - if (!ok) break; + if (!ok) { + _memoryQueue.add(item); + } } debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); @@ -373,25 +486,23 @@ class ApiQueueService { }); } - void _checkBatchUpload() { - if (queueSize >= _batchSize) { - _uploadBatch(); - } - } - /// Manually flush queue (called by TX-triggered flush timer) Future flushQueue() async { await _flushRxBuffer(); await _uploadBatch(); } - /// Upload batch of queued items + /// Upload batch of queued items (from Hive box or in-memory fallback) Future _uploadBatch() async { if (_isUploading) { debugLog('[API QUEUE] Upload skipped: already uploading'); return; } - if (_safeRead((box) => box.isEmpty, true)) { + + final hiveEmpty = _safeRead((box) => box.isEmpty, true); + final memoryEmpty = _memoryQueue.isEmpty; + + if (hiveEmpty && memoryEmpty) { debugLog('[API QUEUE] Upload skipped: queue empty'); return; } @@ -399,15 +510,8 @@ class ApiQueueService { _isUploading = true; try { - // Log if TX items are waiting in hold period - final pendingTx = _safeRead((box) => box.values.where((item) => - item.type == 'TX' && !item.isUploadEligible).length, 0); - if (pendingTx > 0) { - debugLog('[API QUEUE] $pendingTx TX items still in hold period'); - } - - // Get items ready for upload (must pass retry, retry delay, AND upload eligibility checks) - final items = _safeRead((box) => box.values + // Collect items from both Hive and memory queue + final hiveItems = _safeRead((box) => box.values .where((item) => item.retryCount < _maxRetries && item.isReadyForRetry && @@ -415,6 +519,16 @@ class ApiQueueService { .take(_batchSize) .toList(), []); + final memoryItems = _memoryQueue + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize - hiveItems.length) + .toList(); + + final items = [...hiveItems, ...memoryItems]; + if (items.isEmpty) { debugLog('[API QUEUE] Upload skipped: no items ready for upload'); _isUploading = false; @@ -430,30 +544,47 @@ class ApiQueueService { debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } - debugLog('[API QUEUE] Uploading ${items.length} items...'); + final memoryCount = memoryItems.length; + if (memoryCount > 0) { + debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + } else { + debugLog('[API QUEUE] Uploading ${items.length} items...'); + } // Attempt upload final result = await _apiService.uploadBatch(pings); if (result == UploadResult.success) { final uploadedCount = items.length; - // Remove successful items - for (final item in items) { - await item.delete(); + // Remove successful Hive items + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + // Remove successful memory items + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); onUploadSuccess?.call(uploadedCount); } else if (result == UploadResult.nonRetryable) { - // Data is permanently invalid (bad GPS, invalid request, etc.) — discard - for (final item in items) { - await item.delete(); + // Data is permanently invalid — discard + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried - for (final item in items) { + for (final item in hiveItems) { item.markRetried(); } + // Memory items: update retry fields directly (no Hive save) + for (final item in memoryItems) { + item.retryCount++; + item.lastRetryAt = DateTime.now(); + } debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } @@ -472,44 +603,18 @@ class ApiQueueService { await _uploadBatch(); } - /// Force upload after waiting for any TX items in hold period + /// Force upload all queued items immediately /// Used during BLE disconnect to ensure all data is uploaded before session release Future forceUploadWithHoldWait() async { + _pingFlushTimer?.cancel(); await _flushRxBuffer(); - - // Check if any TX items are still in hold period - try { - if (_box != null && _box!.isNotEmpty) { - final now = DateTime.now().millisecondsSinceEpoch; - int maxWaitMs = 0; - - for (final item in _box!.values) { - if (item.type == 'TX' && !item.isUploadEligible) { - final waitMs = item.canUploadAfter - now; - if (waitMs > maxWaitMs) { - maxWaitMs = waitMs; - } - } - } - - if (maxWaitMs > 0) { - // Cap wait time at 6 seconds (slightly more than 5s hold period) - final cappedWaitMs = maxWaitMs.clamp(0, 6000); - debugLog('[API QUEUE] Waiting ${cappedWaitMs}ms for TX hold period to expire'); - await Future.delayed(Duration(milliseconds: cappedWaitMs)); - } - } - } catch (e) { - debugError('[API QUEUE] Failed to check hold period: $e - skipping wait'); - await _recoverBox(); - } - await _uploadBatch(); } /// Clear all queued items Future clear() async { await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -518,16 +623,19 @@ class ApiQueueService { /// Called when device disconnects to ensure no stale pings remain /// Also stops the batch timer to prevent upload attempts without a session Future clearOnDisconnect() async { - // Stop the batch timer to prevent upload attempts without session + // Stop timers to prevent upload attempts without session _batchTimer?.cancel(); _batchTimer = null; - debugLog('[API QUEUE] Batch timer stopped on disconnect'); + _pingFlushTimer?.cancel(); + _pingFlushTimer = null; + debugLog('[API QUEUE] Timers stopped on disconnect'); final count = queueSize + _rxBuffer.length; if (count > 0) { debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -541,6 +649,7 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count stale items before connect'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); @@ -553,10 +662,18 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { - return _safeRead( + final hiveItems = _safeRead( (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); + final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + return [...hiveItems, ...memoryItems]; + } + + /// Get a snapshot of accumulated offline pings without clearing. + /// Used for periodic auto-saves to persist data without losing the in-memory accumulator. + List> getOfflinePingsSnapshot() { + return List>.from(_offlinePings); } /// Get accumulated offline pings and clear the accumulator @@ -572,9 +689,28 @@ class ApiQueueService { _offlinePings.clear(); } + /// Extract all queued items as API JSON without clearing the queue. + /// Used to preserve data before session-expiry disconnect. + Future>> extractAllAsJson() async { + // Flush RX buffer first so all items are in the main queue + await _flushRxBuffer(); + + final hiveItems = _safeRead( + (box) => box.values.toList(), + [], + ); + + final allItems = [...hiveItems, ..._memoryQueue]; + + if (allItems.isEmpty) return []; + + return allItems.map((item) => item.toApiJson()).toList(); + } + /// Dispose of resources void dispose() { _batchTimer?.cancel(); + _pingFlushTimer?.cancel(); _box?.close(); } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 717b232b..064c25a5 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:http/http.dart' as http; @@ -38,8 +39,17 @@ class ApiService { bool _rxAllowed = false; int? _sessionExpiresAt; Timer? _heartbeatTimer; + Timer? _heartbeatRetryTimer; + Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; + static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; List _channels = []; + List _scopes = []; + bool _enforceHybrid = false; + bool _enforceDiscDrop = false; + int _minModeInterval = 15; + int _apiHopBytes = 1; /// Callback to get current GPS coordinates for heartbeat /// Returns (lat, lon) or null if GPS is not available @@ -48,6 +58,24 @@ class ApiService { /// Regional channels from auth response (e.g., ['public', 'ottawa', 'testing']) List get channels => List.unmodifiable(_channels); + /// Regional scopes from auth response (e.g., ['ottawa']) + List get scopes => List.unmodifiable(_scopes); + + /// Whether hybrid mode is enforced by regional admin + bool get enforceHybrid => _enforceHybrid; + + /// Whether discovery drop is enforced by regional admin + bool get enforceDiscDrop => _enforceDiscDrop; + + /// Minimum auto-ping interval enforced by regional admin (seconds) + int get minModeInterval => _minModeInterval; + + /// Path hop bytes enforced by regional admin (1, 2, or 3) + int get apiHopBytes => _apiHopBytes; + + /// Whether hop bytes are enforced by regional admin (only 2 or 3 enforces) + bool get enforceHopBytes => _apiHopBytes > 1; + ApiService({http.Client? client}) : _client = client ?? http.Client(); /// Sanitize payload by removing sensitive fields for logging @@ -196,6 +224,8 @@ class ApiService { /// @param publicKey Device public key (for existing auth flow) /// @param contactUri Signed contact URI (for registration flow) /// @param offlineMode Set to true when uploading offline session data + /// @param skipSessionStore When true, does not write to shared _sessionId/_txAllowed/etc. Caller manages session locally. + /// @param sessionId Explicit session ID for disconnect. When provided, disconnect uses this instead of _sessionId and skips _clearSession(). /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, @@ -210,6 +240,8 @@ class ApiService { double? lon, double? accuracyMeters, bool offlineMode = false, + bool skipSessionStore = false, + String? sessionId, }) async { final stopwatch = Stopwatch()..start(); try { @@ -250,8 +282,8 @@ class ApiService { 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, }; } else { - // For disconnect: add session_id - payload['session_id'] = _sessionId; + // For disconnect: use explicit sessionId if provided, otherwise shared _sessionId + payload['session_id'] = sessionId ?? _sessionId; } final response = await _client.post( @@ -288,25 +320,71 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) if ((reason == 'connect' || reason == 'register') && data['success'] == true) { - _sessionId = data['session_id'] as String?; - _txAllowed = data['tx_allowed'] == true; - _rxAllowed = data['rx_allowed'] == true; - _sessionExpiresAt = data['expires_at'] as int?; - - // Parse channels array from auth response - final channelsData = data['channels']; - if (channelsData is List) { - _channels = channelsData.cast().toList(); - debugLog('[API] Regional channels: $_channels'); - } else { - _channels = []; - } + if (!skipSessionStore) { + _sessionId = data['session_id'] as String?; + _txAllowed = data['tx_allowed'] == true; + _rxAllowed = data['rx_allowed'] == true; + _sessionExpiresAt = data['expires_at'] as int?; + + // Parse channels array from auth response + final channelsData = data['channels']; + if (channelsData is List) { + _channels = channelsData.cast().toList(); + debugLog('[API] Regional channels: $_channels'); + } else { + _channels = []; + } + + // Parse scopes array from auth response + final scopesData = data['scopes']; + if (scopesData is List && scopesData.isNotEmpty) { + _scopes = scopesData.cast().toList(); + debugLog('[API] Regional scopes: $_scopes'); + } else { + _scopes = []; + } + + // Parse enforce_hybrid flag from auth response + _enforceHybrid = data['enforce_hybrid'] == true; + if (_enforceHybrid) { + debugLog('[API] Regional admin enforces hybrid mode'); + } + + // Parse disc_drop flag from auth response + _enforceDiscDrop = data['disc_drop'] == true; + if (_enforceDiscDrop) { + debugLog('[API] Regional admin enforces discovery drop'); + } + + // Parse min_mode_interval from auth response + final minInterval = data['min_mode_interval']; + if (minInterval is int && minInterval > 0) { + _minModeInterval = minInterval; + debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); + } else { + _minModeInterval = 15; + } - // Note: Heartbeat is enabled by AppStateProvider when auto mode starts - // (not on initial auth, since heartbeat is only for auto mode) + // Parse hop_bytes from auth response + final hopBytes = data['hop_bytes']; + if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { + _apiHopBytes = hopBytes; + if (_apiHopBytes > 1) { + debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + } + } else { + _apiHopBytes = 1; + } + + // Note: Heartbeat is enabled by AppStateProvider when auto mode starts + // (not on initial auth, since heartbeat is only for auto mode) + } } else if (reason == 'disconnect') { - // Clear session on disconnect - _clearSession(); + // Only clear shared session when no explicit sessionId was provided + // (explicit sessionId means caller manages its own session lifecycle) + if (sessionId == null) { + _clearSession(); + } } return data; @@ -526,6 +604,11 @@ class ApiService { _gpsProvider = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -533,9 +616,12 @@ class ApiService { /// Matches scheduleHeartbeat() in wardrive.js /// @param expiresAt Unix timestamp when session expires void scheduleHeartbeat(int expiresAt) { - // Cancel any existing heartbeat timer + // Cancel any existing heartbeat timer and reset retry state _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; if (!_heartbeatEnabled) return; @@ -548,15 +634,17 @@ class ApiService { // Session is about to expire or already expired - send heartbeat immediately debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); - return; - } + } else { + debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); + } - _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { - debugLog('[HEARTBEAT] Timer fired, sending keepalive'); - _sendScheduledHeartbeat(); - }); + // Schedule session deadline timer at exact expiry + _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -567,10 +655,22 @@ class ApiService { if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); + // Reset retry state on success + _heartbeatRetryCount = 0; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; // Next heartbeat will be scheduled when we get new expires_at } else if (result == null) { - // Network error — transient, trigger session expiring - debugWarn('[HEARTBEAT] Heartbeat failed: network error'); + // Network error — schedule retry with exponential backoff + if (_heartbeatRetryCount < _maxHeartbeatRetries) { + final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); + _heartbeatRetryCount++; + debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + } else { + debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + } _onSessionExpiring?.call(); } else { // Server returned an error — check if critical @@ -593,15 +693,51 @@ class ApiService { } } - /// Clear session data and cancel heartbeat timer + /// Schedule a hard deadline timer at the exact session expiry time. + /// If the server is unreachable and all heartbeat retries fail, this fires + /// and triggers the same disconnect flow as a server-returned session_expired. + void _scheduleSessionDeadline(int expiresAt) { + _sessionDeadlineTimer?.cancel(); + if (!_heartbeatEnabled) return; + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final secondsUntilExpiry = expiresAt - now; + + if (secondsUntilExpiry <= 0) { + _onSessionDeadlineReached(); + return; + } + + debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); + _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); + } + + /// Called when the session deadline timer fires — server was unreachable + void _onSessionDeadlineReached() { + debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); + _clearSession(); + onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); + } + + /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; _channels = []; + _scopes = []; + _enforceHybrid = false; + _enforceDiscDrop = false; + _minModeInterval = 15; + _apiHopBytes = 1; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -682,7 +818,7 @@ class ApiService { /// Returns a list of enabled repeaters for the given IATA zone code Future> fetchRepeaters(String iata) async { final stopwatch = Stopwatch()..start(); - const endpoint = '/repeaters.json'; + const endpoint = '/get_repeaters.php'; try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; @@ -734,10 +870,120 @@ class ApiService { } } + /// Submit wardrive data using an explicit session ID (for offline uploads) + /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state + Future?> submitWardriveDataWithSessionId( + List> entries, + String sessionId, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': entries, + }; + + final response = await _client.post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ).timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); + rethrow; + } + + final antennaSummary = entries.map((e) => + '${e['type']}:external_antenna=${e['external_antenna']}' + ).join(', '); + _logApiCall( + endpoint: '/wardrive-api.php/wardrive (offline)', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: {'data': '${entries.length} items', 'items': antennaSummary}, + response: data, + ); + + // Do NOT update shared _sessionExpiresAt or schedule heartbeat + return data; + } catch (e) { + stopwatch.stop(); + debugError('[API] POST /wardrive-api.php/wardrive (offline) failed: $e'); + return null; + } + } + + /// Upload batch using explicit session ID (for offline uploads) + /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode + Future uploadBatchWithSessionId( + List> pings, + String sessionId, + ) async { + if (pings.isEmpty) return UploadResult.success; + + try { + final result = await submitWardriveDataWithSessionId(pings, sessionId); + + if (result == null) { + debugError('[API] Offline upload batch failed: no response'); + return UploadResult.retryable; + } + + if (result['success'] == true) { + debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); + return UploadResult.success; + } + + final reason = result['reason'] as String?; + + // For offline uploads, session/auth errors are non-retryable but do NOT cascade + const criticalErrors = { + 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', + 'invalid_key', 'unauthorized', 'bad_key', + 'outside_zone', 'zone_full', 'zone_disabled', + }; + if (criticalErrors.contains(reason)) { + debugError('[API] Offline upload batch session error: $reason'); + return UploadResult.nonRetryable; + } + + const nonRetryableErrors = { + 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + }; + if (nonRetryableErrors.contains(reason)) { + debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + return UploadResult.nonRetryable; + } + + return UploadResult.retryable; + } catch (e) { + debugError('[API] Offline upload batch exception: $e'); + return UploadResult.retryable; + } + } + /// Dispose of resources void dispose() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 49a110c0..3ff0d174 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -249,6 +249,8 @@ class BackgroundServiceManager { String body; if (mode == 'Passive Mode') { body = 'RX: $rxCount | Queue: $queueSize'; + } else if (mode == 'Trace Mode') { + body = 'Trace: $txCount | RX: $rxCount | Queue: $queueSize'; } else { body = 'TX: $txCount | RX: $rxCount | Queue: $queueSize'; } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 6131f832..d279477d 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -46,6 +46,7 @@ class MobileBluetoothService implements BluetoothService { StreamSubscription? _connectionStateSubscription; StreamSubscription? _notificationSubscription; StreamSubscription? _scanSubscription; + StreamController? _scanController; // Store scanned device info for use in connect() // This preserves the device name from scan results @@ -183,10 +184,15 @@ class MobileBluetoothService implements BluetoothService { @override Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); + _scanController = controller; _updateStatus(ConnectionStatus.scanning); try { + // Ensure any previous scan stream is closed before starting a new one + await _scanSubscription?.cancel(); + _scanSubscription = null; + // Start scanning with filter for MeshCore service UUID await fbp.FlutterBluePlus.startScan( withServices: [fbp.Guid(BleUuids.serviceUuid)], @@ -208,14 +214,30 @@ class MobileBluetoothService implements BluetoothService { ); // Store device info for use in connect() - preserves name from scan _scannedDevices[device.id] = device; - controller.add(device); + if (!controller.isClosed) { + controller.add(device); + } } }); + // Complete stream when scan naturally stops (timeout or platform stop) + unawaited(() async { + await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + if (!controller.isClosed) { + await controller.close(); + } + }()); + yield* controller.stream; } finally { await _scanSubscription?.cancel(); _scanSubscription = null; + if (!controller.isClosed) { + await controller.close(); + } + if (identical(_scanController, controller)) { + _scanController = null; + } } } @@ -224,6 +246,11 @@ class MobileBluetoothService implements BluetoothService { await fbp.FlutterBluePlus.stopScan(); await _scanSubscription?.cancel(); _scanSubscription = null; + final scanController = _scanController; + if (scanController != null && !scanController.isClosed) { + await scanController.close(); + } + _scanController = null; // NOTE: Do NOT fire 'disconnected' here. Stopping a scan is not a disconnection. // The status will be updated by connect() when a connection starts. // Firing 'disconnected' here causes a race condition where the queued event diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 1535a629..753c67c2 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -28,6 +28,18 @@ class GpsService { /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; + /// Configured minimum ping distance (user-adjustable, clamped to minDistanceMeters floor) + double _configuredMinDistance = minDistanceMeters; + + /// Get the configured minimum ping distance + double get configuredMinDistance => _configuredMinDistance; + + /// Set the minimum ping distance (clamped to 25m floor) + void setMinPingDistance(double meters) { + _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + } + final _statusController = StreamController.broadcast(); final _positionController = StreamController.broadcast(); @@ -161,6 +173,14 @@ class GpsService { Future startWatching() async { debugLog('[GPS] startWatching() called, current status: $_status'); + // Ensure only one active position stream subscription exists. + // startWatching() can be called multiple times (e.g. after permission flow). + if (_positionSubscription != null) { + debugLog('[GPS] Existing position subscription found, restarting watcher'); + await _positionSubscription?.cancel(); + _positionSubscription = null; + } + // Check if location services are enabled first (system-level setting) final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); @@ -194,6 +214,12 @@ class GpsService { } debugLog('[GPS] Starting position stream listener...'); + + // Cancel any existing subscription to prevent orphaned listeners + // (e.g. restartGpsAfterPermission() racing with _initialize()) + await _positionSubscription?.cancel(); + _positionSubscription = null; + _updateStatus(GpsStatus.searching); // Configure location settings for position stream @@ -260,10 +286,10 @@ class GpsService { ); } - /// Check if current position is far enough from last ping (25m minimum) + /// Check if current position is far enough from last ping bool canPingAtPosition(Position position) { if (_lastPingPosition == null) return true; - return distanceFromLastPing(position) >= minDistanceMeters; + return distanceFromLastPing(position) >= _configuredMinDistance; } /// Mark current position as ping location diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 0292a289..07c08100 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,11 +17,15 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 + final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, required this.manufacturer, this.firmwareBuildDate, + this.firmwareVersionString, + this.pathHashMode, }); } @@ -66,6 +70,7 @@ class MeshCoreConnection { final _rawDataController = StreamController>.broadcast(); final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -120,6 +125,10 @@ class MeshCoreConnection { /// Stream of ControlData packets (for discovery responses) Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + /// Stream of TraceData packets (for trace path responses) + /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly + Stream get traceDataStream => _traceDataController.stream; + /// Stream of noise floor updates (dBm) Stream get noiseFloorStream => _noiseFloorController.stream; @@ -399,6 +408,9 @@ class MeshCoreConnection { case PushCodes.controlData: _onControlDataPush(reader); break; + case PushCodes.traceData: + _onTraceDataPush(reader); + break; case ResponseCodes.stats: _onStatsResponse(reader); break; @@ -438,17 +450,43 @@ class MeshCoreConnection { // Protocol v7+ format reader.readBytes(6); // skip reserved bytes final buildDate = reader.readCString(12); // e.g. "04-Jan-2026" - final manufacturerModel = reader.readString(); // remainder of frame - + + // Read manufacturer model as CString(40) — fixed-length null-terminated + final manufacturerModel = reader.readCString(40); + + // Parse additional fields from v9+ firmware + int? pathHashMode; + String? firmwareVersionString; + if (reader.remainingBytesCount > 0) { + // FIRMWARE_VERSION: 20-byte null-terminated C-string + if (reader.remainingBytesCount >= 20) { + firmwareVersionString = reader.readCString(20); + debugLog('[CONN] Firmware version string: $firmwareVersionString'); + } + + // client_repeat: 1 byte (v9+, skip) + if (reader.remainingBytesCount >= 1) { + reader.readByte(); // client_repeat + } + + // path_hash_mode: 1 byte (v10+) + if (reader.remainingBytesCount >= 1) { + pathHashMode = reader.readByte(); + debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + } + } + debugLog('[CONN] Build date: $buildDate'); debugLog('[CONN] Manufacturer model: $manufacturerModel'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturerModel, firmwareBuildDate: buildDate, + firmwareVersionString: firmwareVersionString, + pathHashMode: pathHashMode, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } else { @@ -584,6 +622,17 @@ class MeshCoreConnection { _controlDataController.add((raw: raw, snr: snr, rssi: rssi)); } + void _onTraceDataPush(BufferReader reader) { + // 0x89 TraceData has NO snr/rssi prefix (unlike 0x88 LogRxData). + // The entire remaining payload is the trace response: + // [reserved][path_len][flags][tag:4][auth:4][path_hashes][path_snrs] + final raw = reader.readRemainingBytes(); + + debugLog('[CONN] Received trace data: ${raw.length} bytes'); + + _traceDataController.add(raw); + } + void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // @@ -725,6 +774,14 @@ class MeshCoreConnection { await _sendToRadio(data); } + /// Set the companion advertised name + Future setAdvertName(String name) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setAdvertName); + data.writeString(name); + await _sendToRadio(data); + } + /// Set radio parameters Future setRadioParams(int freq, int bw, int sf, int cr) async { final data = BufferWriter(); @@ -771,6 +828,24 @@ class MeshCoreConnection { await _sendToRadio(data); } + /// Set flood scope for regional packet filtering + /// TransportKey is 16-byte SHA-256 derived key from scope name + Future setFloodScope(Uint8List transportKey) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setFloodScope); + data.writeByte(0); // reserved byte + data.writeBytes(transportKey); // 16-byte key + await _sendToRadio(data); + } + + /// Clear flood scope (return to unscoped global flood) + Future clearFloodScope() async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setFloodScope); + data.writeByte(0); // reserved byte — no key means clear + await _sendToRadio(data); + } + /// Delete channel by setting it to empty Future deleteChannel(int channelIdx) async { await setChannel(channelIdx, '', Uint8List(16)); @@ -904,6 +979,38 @@ class MeshCoreConnection { return tag; } + /// Send trace path to a specific repeater (targeted ping / zero-hop trace) + /// Returns the 4-byte tag used for matching the response + /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) + Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { + final random = Random.secure(); + final tag = Uint8List.fromList([ + random.nextInt(256), random.nextInt(256), + random.nextInt(256), random.nextInt(256), + ]); + + // Trace uses bitshift encoding: actual_bytes = 1 << path_sz + // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 + final int pathSz; + switch (hopBytes) { + case 4: pathSz = 2; break; + case 2: pathSz = 1; break; + default: pathSz = 0; break; + } + final int flags = pathSz & 0x03; + + debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + + final data = BufferWriter(); + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID + await _sendToRadio(data); + return tag; + } + /// Get battery voltage Future getBatteryVoltage() async { final data = BufferWriter(); @@ -1028,6 +1135,17 @@ class MeshCoreConnection { debugLog('[CONN] Stopped battery polling'); } + /// Set path hash mode on the radio + /// mode: 0=1-byte, 1=2-byte, 2=3-byte (persisted in radio prefs) + Future setPathHashMode(int mode) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setPathHashMode); // 61 (0x3D) + data.writeByte(0); // reserved + data.writeByte(mode); // 0=1-byte, 1=2-byte, 2=3-byte + await _sendToRadio(data); + debugLog('[CONN] Sent setPathHashMode: mode=$mode (${mode + 1}-byte hops)'); + } + /// Reboot device Future reboot() async { final data = BufferWriter(); @@ -1046,6 +1164,7 @@ class MeshCoreConnection { _rawDataController.close(); _logRxDataController.close(); _controlDataController.close(); + _traceDataController.close(); _noiseFloorController.close(); _batteryController.close(); } diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index cf736dd2..30da8862 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -55,6 +55,20 @@ class CryptoService { return channelKey; } + /// Derive a 16-byte TransportKey from a scope/region name + /// Same algorithm as channel key derivation: SHA-256(name)[0:16] + /// API returns names without '#' prefix (e.g., "ottawa") — we prepend it + /// to match MeshCore's implicit hashtag region convention + static Uint8List deriveScopeKey(String scopeName) { + final name = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final normalizedName = name.toLowerCase(); + final bytes = utf8.encode(normalizedName); + final digest = sha256.convert(bytes); + final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); + debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + return scopeKey; + } + /// Get channel key for any channel (handles both Public and hashtag channels) /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index a836f3a3..5796d6f1 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -20,6 +20,9 @@ class DiscTracker { /// Callback to check if a repeater should be ignored (carpeater filter) final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// When true, skip RSSI carpeater check (user setting) + final bool disableRssiFilter; + /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when a discovery response is dropped due to carpeater detection void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -28,7 +31,10 @@ class DiscTracker { /// Parameters: (node, isNew) - isNew is true for first time seeing this node void Function(DiscoveredNode node, bool isNew)? onNodeDiscovered; - DiscTracker({this.shouldIgnoreRepeater}); + /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. + final int hopBytes; + + DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -39,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 7), + Duration windowDuration = const Duration(seconds: 5), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); @@ -131,8 +137,8 @@ class DiscTracker { final pubkey = rawBytes.sublist(7, 39); final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - // Get repeater ID (first 2 hex chars = first byte) - final repeaterId = pubkeyHex.substring(0, 2); + // Get repeater ID (first N hex chars based on hopBytes setting) + final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { @@ -141,7 +147,9 @@ class DiscTracker { } // Check RSSI (carpeater failsafe) - if (PacketValidator.isCarpeater(localRssi)) { + if (disableRssiFilter) { + debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + } else if (PacketValidator.isCarpeater(localRssi)) { debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); @@ -204,7 +212,7 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index b3fc7430..c65a6ba1 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -8,31 +8,31 @@ import 'protocol_constants.dart'; class PacketMetadata { /// Original raw packet bytes final Uint8List raw; - + /// Packet header byte (contains route type, payload type, version) final int header; - + /// Route type (FLOOD=0x01, DIRECT=0x02) final int routeType; - - /// Number of hops in path - final int pathLength; - - /// Raw path bytes (repeater IDs) + + /// Raw pathLen byte (encodes both hash size and hop count) + final int pathLenRaw; + + /// Number of bytes per hop hash (1, 2, 3, or 4) + final int pathHashSize; + + /// Number of hops in path (0-63) + final int pathHashCount; + + /// Raw path bytes (repeater IDs, length = pathHashCount * pathHashSize) final Uint8List pathBytes; - - /// First hop (first repeater ID) - used for TX tracking - final int? firstHop; - - /// Last hop (last repeater ID) - used for RX logging - final int? lastHop; - + /// Signal-to-noise ratio (dB) final double snr; - + /// Received signal strength indicator (dBm) final int rssi; - + /// Encrypted payload (after header + path) final Uint8List encryptedPayload; @@ -40,17 +40,17 @@ class PacketMetadata { required this.raw, required this.header, required this.routeType, - required this.pathLength, + required this.pathLenRaw, + required this.pathHashSize, + required this.pathHashCount, required this.pathBytes, - required this.firstHop, - required this.lastHop, required this.snr, required this.rssi, required this.encryptedPayload, }); /// Parse packet metadata from BLE LogRxData event - /// + /// /// Event structure: /// ```dart /// { @@ -61,21 +61,21 @@ class PacketMetadata { /// ``` factory PacketMetadata.fromLogRxData(Map data) { debugLog('[RX PARSE] Starting metadata parsing'); - + final Uint8List raw = data['raw'] as Uint8List; final double snr = (data['lastSnr'] as num).toDouble(); final int rssi = data['lastRssi'] as int; - + // Dump raw packet for debugging final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); - + // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - + debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); - + // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 int pathLengthOffset = 1; @@ -83,42 +83,44 @@ class PacketMetadata { // TransportFlood or TransportDirect - has 4-byte transport codes pathLengthOffset = 5; } - - // Extract path length from calculated offset - final int pathLength = raw[pathLengthOffset]; - - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, Path length: $pathLength'); - + + // Extract raw pathLen byte and decode multi-byte path encoding + // pathHashSize = (pathLen >> 6) + 1 → 1, 2, 3, or 4 + // pathHashCount = pathLen & 63 → 0-63 hops + final int pathLenRaw = raw[pathLengthOffset]; + final int pathHashSize = (pathLenRaw >> 6) + 1; + final int pathHashCount = pathLenRaw & 63; + final int pathByteLen = pathHashCount * pathHashSize; + + debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); + // Path data starts after path length byte final int pathDataOffset = pathLengthOffset + 1; final Uint8List pathBytes = raw.sublist( pathDataOffset, - (pathDataOffset + pathLength).clamp(0, raw.length), + (pathDataOffset + pathByteLen).clamp(0, raw.length), ); - - // Derive first and last hops - final int? firstHop = pathBytes.isNotEmpty ? pathBytes.first : null; - final int? lastHop = pathBytes.isNotEmpty ? pathBytes.last : null; - + // Extract encrypted payload after path data - final int payloadOffset = pathDataOffset + pathLength; + final int payloadOffset = pathDataOffset + pathByteLen; final Uint8List encryptedPayload = raw.sublist(payloadOffset); - + debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathLength=$pathLength, ' - 'firstHop=${firstHop != null ? '0x${firstHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' - 'lastHop=${lastHop != null ? '0x${lastHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' + 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' + 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' + 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' 'SNR=$snr, RSSI=$rssi, ' 'payload=${encryptedPayload.length} bytes'); - + return PacketMetadata( raw: raw, header: header, routeType: routeType, - pathLength: pathLength, + pathLenRaw: pathLenRaw, + pathHashSize: pathHashSize, + pathHashCount: pathHashCount, pathBytes: pathBytes, - firstHop: firstHop, - lastHop: lastHop, snr: snr, rssi: rssi, encryptedPayload: encryptedPayload, @@ -160,20 +162,49 @@ class PacketMetadata { return payloadType == PayloadType.advert; } + /// Check if packet is TRACE (trace path response, header 0x26) + bool get isTrace { + final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + return payloadType == PayloadType.trace; + } + /// Get first hop as hex string (for TX tracking keys) + /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get firstHopHex { - return firstHop?.toRadixString(16).padLeft(2, '0'); + if (pathHashCount == 0) return null; + return _bytesToHex(pathBytes.sublist(0, pathHashSize)); } /// Get last hop as hex string (for RX logging keys) + /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get lastHopHex { - return lastHop?.toRadixString(16).padLeft(2, '0'); + if (pathHashCount == 0) return null; + return _bytesToHex(pathBytes.sublist(pathBytes.length - pathHashSize)); + } + + /// Get any hop by index as hex string + /// @param hopIndex 0-based hop index (0 = first hop) + String? getHopHex(int hopIndex) { + if (hopIndex < 0 || hopIndex >= pathHashCount) return null; + final offset = hopIndex * pathHashSize; + return _bytesToHex(pathBytes.sublist(offset, offset + pathHashSize)); + } + + /// Convert N bytes to uppercase hex string + String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + /// Static version for use in factory constructor + static String _bytesToHexStatic(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); } @override String toString() { return 'PacketMetadata(header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathLength=$pathLength, firstHop=$firstHopHex, lastHop=$lastHopHex, ' + 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' + 'firstHop=$firstHopHex, lastHop=$lastHopHex, ' 'SNR=$snr, RSSI=$rssi)'; } } diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index 7e9b7847..e9cec944 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -21,11 +21,15 @@ class PacketValidator { /// Allowed channels for validation final Map allowedChannels; - PacketValidator({required this.allowedChannels}); + /// When true, skip RSSI carpeater check (user setting) + final bool disableRssiFilter; + + PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason - Future validate(PacketMetadata metadata) async { + /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) + Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw @@ -34,15 +38,20 @@ class PacketValidator { debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' - 'PathLength: ${metadata.pathLength} | SNR: ${metadata.snr}'); + 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) - if (isCarpeater(metadata.rssi)) { + if (skipRssiCheck) { + debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); + } else if (disableRssiFilter) { + debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + } else if (isCarpeater(metadata.rssi)) { debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); + } else { + debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); // VALIDATION 2: Check packet type if (metadata.isGroupText) { @@ -163,6 +172,18 @@ class PacketValidator { return rssi >= maxRssiThreshold; } + /// Check if a hop hex string matches a stored CARpeater ID using prefix truncation. + /// The stored ID is always 3-byte (6 hex chars). Incoming hop IDs vary by the + /// packet's path hash size (1, 2, or 3 bytes). Compares the shorter prefix. + /// Also handles legacy shorter stored IDs from before the 3-byte requirement. + static bool isCarpeaterIdMatch(String hopHex, String storedId) { + final hop = hopHex.toUpperCase(); + final stored = storedId.toUpperCase(); + final compareLen = hop.length < stored.length ? hop.length : stored.length; + if (compareLen == 0) return false; + return hop.substring(0, compareLen) == stored.substring(0, compareLen); + } + /// Calculate ratio of printable ASCII characters (32-126) static double getPrintableRatio(String text) { if (text.isEmpty) return 0.0; diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 537d229b..3dee89f4 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -66,8 +66,10 @@ class CommandCodes { static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; + static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE static const int getStats = 56; // 0x38 static const int sendBinaryReq = 50; + static const int setPathHashMode = 61; // 0x3D - CMD_SET_PATH_HASH_MODE } /// Response codes received from device diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 64b3950e..0451fffc 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -34,12 +34,17 @@ class RxLogger { /// Called with repeater ID and reason when a packet is dropped due to carpeater detection final void Function(String repeaterId, String reason)? onCarpeaterDrop; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped + /// to report the underlying repeater with null SNR/RSSI + String? carpeaterPrefix; + RxLogger({ required this.onRxEntry, this.onObservation, required this.getGpsLocation, this.shouldIgnoreRepeater, this.onCarpeaterDrop, + this.carpeaterPrefix, }); /// Start passive RX wardriving @@ -68,16 +73,35 @@ class RxLogger { // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info - if (metadata.pathLength == 0) { + if (metadata.pathHashCount == 0) { debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } + bool carpeaterStripped = false; + String repeaterId; + double? reportedSnr = metadata.snr; + int? reportedRssi = metadata.rssi; + // Extract LAST hop from path (the repeater that directly delivered to us) - // Do this early so we have repeater ID for carpeater logging - // Mask to last byte only (0xFF) for consistent 2-character display - final lastHopId = metadata.lastHop! & 0xFF; - final repeaterId = lastHopId.toRadixString(16).padLeft(2, '0').toUpperCase(); + final lastHopHex = metadata.lastHopHex!; + + // CARpeater check: the carpeater is co-located with us, so it only + // appears as the last hop (the delivery repeater) on RX packets + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { + if (metadata.pathHashCount < 2) { + debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); + return false; + } + // Second-to-last hop = the real repeater that forwarded to our carpeater + repeaterId = metadata.getHopHex(metadata.pathHashCount - 2)!; + carpeaterStripped = true; + reportedSnr = null; + reportedRssi = null; + debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + } else { + repeaterId = lastHopHex; + } // Get current GPS location final gpsLocation = getGpsLocation(); @@ -89,13 +113,15 @@ class RxLogger { // Check if this repeater should be ignored (user carpeater filter) // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore - if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { + // Skip for CARpeater pass-through (CARpeater itself was already handled) + if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging - final validation = await validator.validate(metadata); + // Skip RSSI check for CARpeater pass-through + final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -110,17 +136,17 @@ class RxLogger { return false; } - debugLog('[RX LOG] Packet heard via last hop: $repeaterId, ' - 'SNR=${metadata.snr}, path_length=${metadata.pathLength}'); + debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, - snr: metadata.snr, - rssi: metadata.rssi, - pathLength: metadata.pathLength, + snr: reportedSnr, + rssi: reportedRssi, + pathLength: metadata.pathHashCount, header: metadata.header, lat: gpsLocation.lat, lon: gpsLocation.lon, @@ -132,9 +158,9 @@ class RxLogger { // Returns true if this observation updated the batch (new repeater or better SNR) final wasKept = await _handleRxBatching( repeaterId: repeaterId, - snr: metadata.snr, - rssi: metadata.rssi, - pathLength: metadata.pathLength, + snr: reportedSnr, + rssi: reportedRssi, + pathLength: metadata.pathHashCount, header: metadata.header, currentLocation: gpsLocation, metadata: metadata, @@ -149,10 +175,10 @@ class RxLogger { final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' - 'snr=${batchedObservation.snr}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); + 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' - 'snr=${metadata.snr}, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); + 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } return true; @@ -168,8 +194,8 @@ class RxLogger { /// Returns true if this observation was kept (new repeater or better SNR) Future _handleRxBatching({ required String repeaterId, - required double snr, - required int rssi, + required double? snr, + required int? rssi, required int pathLength, required int header, required ({double lat, double lon}) currentLocation, @@ -207,9 +233,14 @@ class RxLogger { debugLog('[RX BATCH] Started 30s timeout timer for repeater $repeaterId'); } else { // Already tracking this repeater - check if new SNR is better - if (snr > buffer.bestObservation.snr) { + // Null SNR never replaces non-null; non-null always replaces null + final existingSnr = buffer.bestObservation.snr; + final shouldUpdate = snr != null && existingSnr != null + ? snr > existingSnr + : snr != null && existingSnr == null; + if (shouldUpdate) { debugLog('[RX BATCH] Better SNR for repeater $repeaterId: ' - '${buffer.bestObservation.snr} -> $snr'); + '$existingSnr -> $snr'); // IMPORTANT: Keep the FIRST location where we heard this repeater, // only update the SNR/RSSI/metadata. This ensures the map pin stays // at the original location and doesn't follow the user. @@ -423,8 +454,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double snr; - final int rssi; + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -434,8 +465,8 @@ class RxObservation { RxObservation({ required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.lat, @@ -450,8 +481,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double snr; - final int rssi; + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; @@ -461,8 +492,8 @@ class RxApiEntry { required this.repeaterId, required this.lat, required this.lon, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.timestamp, diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart new file mode 100644 index 00000000..ad7b5297 --- /dev/null +++ b/lib/services/meshcore/trace_tracker.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Result of a trace path probe to a specific repeater +class TraceResult { + final String targetRepeaterId; + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final bool success; + + const TraceResult({ + required this.targetRepeaterId, + required this.localSnr, + required this.localRssi, + required this.remoteSnr, + required this.success, + }); +} + +/// Trace path tracker for targeted ping (zero-hop trace) +/// Sends CMD_SEND_TRACE_PATH (0x24) to a specific repeater and listens +/// for the trace response (PUSH_CODE_TRACE_DATA, 0x89). +/// +/// Follows the DiscTracker pattern but simpler: expects exactly 1 response. +class TraceTracker { + bool isListening = false; + Uint8List? _expectedTag; + String _targetRepeaterId = ''; + TraceResult? _result; + Timer? _windowTimer; + + /// BLE metadata from the 0x88 LogRxData event that arrives before the 0x89 TraceData. + /// Set by UnifiedRxHandler when it sees a trace packet in the LogRxData stream. + double pendingBleSnr = 0.0; + int pendingBleRssi = 0; + + /// Fired when a trace response is received during the window + void Function(TraceResult)? onTraceReceived; + + /// Fired when the listening window ends (result is null if no response) + void Function(TraceResult?)? onWindowComplete; + + TraceTracker(); + + /// Start tracking trace responses + void startTracking({ + required Uint8List tag, + required String targetRepeaterId, + Duration windowDuration = const Duration(seconds: 7), + }) { + debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); + debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + + isListening = true; + _expectedTag = tag; + _targetRepeaterId = targetRepeaterId; + _result = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + // Start window timer + _windowTimer?.cancel(); + _windowTimer = Timer(windowDuration, _endWindow); + + debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + } + + /// Handle incoming trace data packet (0x89) + /// Returns true if the packet was a valid trace response + /// + /// Trace response format (per meshcore_py reference): + /// Byte 0: reserved (skip) + /// Byte 1: path_len (raw byte count of path hashes, NOT LogRxData encoding) + /// Byte 2: flags → hashSize = 1 << (flags & 3) + /// Bytes 3-6: tag → match against _expectedTag + /// Bytes 7-10: auth_code (skip) + /// Bytes 11 to 11+path_len: path_hashes → extract repeater ID + /// Next hopCount+1 bytes: path_snrs → each is signedInt8 / 4.0 for dB + /// For zero-hop (hopCount=1): remoteSnr = snrs[0], localSnr = snrs[1] + bool handlePacket(Uint8List rawBytes, double bleSnr, int bleRssi) { + if (!isListening) return false; + + try { + // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes + if (rawBytes.length < 11) { + debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + return false; + } + + // Skip byte 0 (reserved) + final pathLen = rawBytes[1]; // Raw byte count of path hashes + final flags = rawBytes[2]; + + // Decode per 0x89 trace format (meshcore_py reference): + // hash size from flags, hop count from path_len / hash_size + final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop + final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; + + debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + + // Extract tag (bytes 3-6) + final tag = rawBytes.sublist(3, 7); + + // Match tag against expected + final expectedTag = _expectedTag; + if (expectedTag != null) { + bool tagMatch = true; + for (int i = 0; i < 4; i++) { + if (tag[i] != expectedTag[i]) { + tagMatch = false; + break; + } + } + if (!tagMatch) { + debugLog('[TRACE] Tag mismatch, ignoring packet'); + return false; + } + } + + // Skip auth_code (bytes 7-10) + + // Extract path hashes (bytes 11 to 11 + hopCount*hashSize) + const pathStart = 11; + final pathEnd = pathStart + (hopCount * hashSize); + + if (rawBytes.length < pathEnd) { + debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + return false; + } + + // Extract repeater ID from first hop in path + String repeaterId = ''; + if (hopCount > 0) { + final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); + repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + // Extract path SNRs (hopCount+1 bytes after path hashes) + final snrStart = pathEnd; + final snrEnd = snrStart + hopCount + 1; + + double remoteSnr = 0.0; + double localSnr = bleSnr; // Default to BLE metadata SNR + + if (rawBytes.length >= snrEnd && hopCount >= 1) { + // Each SNR byte is signed int8, divide by 4.0 for dB + remoteSnr = rawBytes[snrStart].toSigned(8) / 4.0; + if (hopCount + 1 >= 2) { + localSnr = rawBytes[snrStart + 1].toSigned(8) / 4.0; + } + } + + debugLog('[TRACE] Trace response from $repeaterId: ' + 'localSnr=${localSnr.toStringAsFixed(2)}, ' + 'remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' + 'bleRssi=$bleRssi'); + + _result = TraceResult( + targetRepeaterId: _targetRepeaterId, + localSnr: localSnr, + localRssi: bleRssi, + remoteSnr: remoteSnr, + success: true, + ); + + // Notify callback + onTraceReceived?.call(_result!); + + return true; + } catch (e, stackTrace) { + debugError('[TRACE] Error processing trace response: $e'); + debugError('[TRACE] Stack trace: $stackTrace'); + return false; + } + } + + /// Stop tracking and return result + TraceResult? stopTracking() { + debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + + final result = _result; + isListening = false; + _windowTimer?.cancel(); + _windowTimer = null; + _expectedTag = null; + + return result; + } + + /// Handle trace window completion + void _endWindow() { + debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + + final result = _result; + isListening = false; + _windowTimer = null; + _expectedTag = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + onWindowComplete?.call(result); + } + + /// Dispose of resources + void dispose() { + stopTracking(); + } +} diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 999a16dd..50b1d2ca 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,7 +7,7 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; -/// TX echo tracker for repeater detection during 7-second window +/// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { bool isListening = false; @@ -22,9 +22,14 @@ class TxTracker { Timer? _windowTimer; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped + /// to report the underlying repeater with null SNR/RSSI + String? carpeaterPrefix; + /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater - void Function(String repeaterId, double snr, int rssi, bool isNew)? onEchoReceived; + /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -34,6 +39,9 @@ class TxTracker { /// Returns true if the repeater should be filtered out bool Function(String repeaterId)? shouldIgnoreRepeater; + /// When true, skip RSSI carpeater check (user setting) + bool disableRssiFilter = false; + /// Start tracking echoes for a sent ping /// /// @param payload - The message text sent (for content verification) @@ -46,7 +54,7 @@ class TxTracker { required int channelIdx, required int channelHash, required Uint8List channelKey, - Duration windowDuration = const Duration(seconds: 7), + Duration windowDuration = const Duration(seconds: 5), }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); @@ -78,7 +86,7 @@ class TxTracker { // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr}, seen=${entry.value.seenCount}x'); + debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); } } } @@ -104,31 +112,55 @@ class TxTracker { // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops - if (metadata.pathLength == 0) { + if (metadata.pathHashCount == 0) { debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } // Extract first hop (first repeater) for use in validation and logging - final firstHopId = metadata.firstHop!; - final pathHex = firstHopId.toRadixString(16).padLeft(2, '0'); + var pathHex = metadata.firstHopHex!; + + // CARpeater pass-through: strip CARpeater hop and report underlying repeater + bool carpeaterStripped = false; + double? reportedSnr = metadata.snr; + int? reportedRssi = metadata.rssi; + + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { + if (metadata.pathHashCount < 2) { + debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); + return false; + } + // Multi-hop: strip CARpeater, report underlying repeater (second hop) + final underlyingHex = metadata.getHopHex(1)!; + debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + pathHex = underlyingHex; + carpeaterStripped = true; + reportedSnr = null; + reportedRssi = null; + } // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { + if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - if (PacketValidator.isCarpeater(metadata.rssi)) { + // Skip for CARpeater pass-through (user explicitly identified their CARpeater) + if (carpeaterStripped) { + debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); + } else if (disableRssiFilter) { + debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + } else if (PacketValidator.isCarpeater(metadata.rssi)) { debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) + } else { + debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { @@ -204,50 +236,53 @@ class TxTracker { // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=${metadata.snr}, ' - 'full_path_length=${metadata.pathLength}'); + debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; if (repeaters.containsKey(pathHex)) { final existing = repeaters[pathHex]!; debugLog('[PING] Deduplication: path $pathHex already seen ' - '(existing SNR=${existing.snr}, new SNR=${metadata.snr})'); + '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); - // Keep the best (highest) SNR - if (metadata.snr > existing.snr) { + // Keep the best (highest) SNR — null SNR never replaces non-null + final shouldUpdate = reportedSnr != null && existing.snr != null + ? reportedSnr > existing.snr! + : reportedSnr != null && existing.snr == null; + if (shouldUpdate) { debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' - '${existing.snr} -> ${metadata.snr}'); + '${existing.snr} -> $reportedSnr'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: metadata.snr, - rssi: metadata.rssi, + snr: reportedSnr, + rssi: reportedRssi, seenCount: existing.seenCount + 1, ); } else { debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' - '(existing ${existing.snr} >= new ${metadata.snr})'); + '(existing ${existing.snr} >= new $reportedSnr)'); // Still increment seen count existing.seenCount++; } } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: metadata.snr, - rssi: metadata.rssi, + snr: reportedSnr, + rssi: reportedRssi, seenCount: 1, ); } // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; - final rssi = repeaters[pathHex]!.rssi; + final bestRssi = repeaters[pathHex]!.rssi; debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, rssi, isNewRepeater); + onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); } @@ -269,14 +304,14 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { final String repeaterId; // Hex string - double snr; // Best SNR seen - int rssi; // RSSI value (dBm) + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, this.seenCount = 1, }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index f78c9629..1c95dd2c 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -4,6 +4,7 @@ import '../../utils/debug_logger_io.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; import 'rx_logger.dart'; +import 'trace_tracker.dart'; import 'tx_tracker.dart'; /// Unified RX handler - orchestrates TX echo tracking and passive RX logging @@ -18,6 +19,9 @@ class UnifiedRxHandler { /// Get current validator PacketValidator get validator => _validator; + /// Trace tracker for targeted ping mode (set by PingService when active) + TraceTracker? traceTracker; + /// Channel key for message decryption (injected for TX validation) Uint8List? channelKey; @@ -71,9 +75,22 @@ class UnifiedRxHandler { debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' - 'pathLength=${metadata.pathLength}'); - - // Route to TX tracking if active (during 7s echo window) + 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); + + // Store BLE metadata from 0x88 LogRxData for trace packets. + // The actual trace payload arrives separately via 0x89 TraceData stream. + // We only store RSSI/SNR here — the 0x89 handler combines them. + if (metadata.isTrace) { + final tt = traceTracker; + if (tt != null && tt.isListening) { + debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + tt.pendingBleSnr = metadata.snr; + tt.pendingBleRssi = metadata.rssi; + } + return; // Trace packets don't go to TX tracker or RX logger + } + + // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); final wasEcho = await txTracker.handlePacket(metadata); diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 282d911d..d37cd8db 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -12,6 +12,7 @@ class OfflineSession { final Map data; final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload final bool uploaded; // Track upload status OfflineSession({ @@ -21,6 +22,7 @@ class OfflineSession { required this.data, this.devicePublicKey, this.deviceName, + this.contactUri, this.uploaded = false, }); @@ -33,6 +35,7 @@ class OfflineSession { data: json['data'] as Map, devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, + contactUri: json['contactUri'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -46,6 +49,7 @@ class OfflineSession { 'data': data, 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, + 'contactUri': contactUri, 'uploaded': uploaded, }; } @@ -59,6 +63,7 @@ class OfflineSession { data: data, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, uploaded: uploaded ?? this.uploaded, ); } @@ -78,6 +83,10 @@ class OfflineSessionService { SharedPreferences? _prefs; List _sessions = []; + /// Tracks the filename of the session currently being accumulated via periodic auto-save. + /// When set, `updateCurrentSession()` updates this session in-place instead of creating a new one. + String? _currentSessionFilename; + /// Callback when sessions list changes void Function(List sessions)? onSessionsUpdated; @@ -141,6 +150,7 @@ class OfflineSessionService { List> pings, { String? devicePublicKey, String? deviceName, + String? contactUri, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -167,6 +177,7 @@ class OfflineSessionService { data: sessionData, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -175,6 +186,70 @@ class OfflineSessionService { debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } + /// Update the current in-progress session with the latest pings snapshot. + /// If no current session exists, creates a new one and tracks it. + /// This allows periodic saves to update the same file instead of creating duplicates. + Future updateCurrentSession( + List> pings, { + String? devicePublicKey, + String? deviceName, + String? contactUri, + }) async { + if (pings.isEmpty) { + debugLog('[OFFLINE] No pings to auto-save, skipping'); + return; + } + + // If we have a tracked session, update it in-place + if (_currentSessionFilename != null) { + final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + if (index != -1) { + final existing = _sessions[index]; + final updatedData = Map.from(existing.data); + updatedData['pings'] = pings; + updatedData['ping_count'] = pings.length; + + _sessions[index] = OfflineSession( + filename: existing.filename, + createdAt: existing.createdAt, + pingCount: pings.length, + data: updatedData, + devicePublicKey: devicePublicKey ?? existing.devicePublicKey, + deviceName: deviceName ?? existing.deviceName, + contactUri: contactUri ?? existing.contactUri, + ); + await _saveSessions(); + debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + return; + } + // Session was deleted externally — fall through to create new + debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + _currentSessionFilename = null; + } + + // No current session — create a new one and track it + await saveSession( + pings, + devicePublicKey: devicePublicKey, + deviceName: deviceName, + contactUri: contactUri, + ); + // saveSession inserts at index 0 (newest first) + if (_sessions.isNotEmpty) { + _currentSessionFilename = _sessions.first.filename; + debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + } + } + + /// Clear the current session tracker so the next save creates a fresh session file. + /// Called after final saves (mode switch, disconnect) to create a clean break. + void finalizeCurrentSession() { + if (_currentSessionFilename != null) { + debugLog('[OFFLINE] Finalized session: $_currentSessionFilename'); + _currentSessionFilename = null; + } + } + /// Mark a session as uploaded without deleting it Future markAsUploaded(String filename) async { final index = _sessions.indexWhere((s) => s.filename == filename); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 685cdbea..d7822824 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:geolocator/geolocator.dart'; @@ -12,7 +13,9 @@ import 'countdown_timer_service.dart'; import 'gps_service.dart'; import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; +import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; /// Ping service for TX/RX ping orchestration @@ -22,7 +25,7 @@ import 'wakelock_service.dart'; /// 1. Validate GPS lock, 25m min distance (zone validation handled server-side) /// 2. Start TxTracker to monitor for repeater echoes /// 3. Send @[MapperBot] LAT, LON [POWERw] to #wardriving channel -/// 4. Start 6-second RX listening window (matches JS RX_LOG_LISTEN_WINDOW_MS) +/// 4. Start 5-second RX listening window /// 5. Post to API queue with type "TX" /// /// RX Flow (via TxTracker): @@ -33,21 +36,24 @@ import 'wakelock_service.dart'; /// /// Discovery Flow (Passive Mode only): /// 1. In Passive Mode, send discovery request instead of TX ping -/// 2. Start 7-second listening window via DiscTracker +/// 2. Start 5-second listening window via DiscTracker /// 3. Collect discovery responses (0x8E packets) /// 4. After window ends, create log entry and queue DISC API payloads class PingService { - /// RX listening window duration (7 seconds - matches cooldown duration) - static const Duration _rxListeningWindow = Duration(seconds: 7); - /// Cooldown period between pings (7 seconds - matches JS COOLDOWN_MS = 7000) - static const Duration _autoPingCooldown = Duration(seconds: 7); - /// Discovery listening window duration (7 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// RX listening window duration (5 seconds - matches cooldown duration) + static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) + static const Duration _autoPingCooldown = Duration(seconds: 5); + /// Discovery listening window duration (5 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 5); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); + /// Current configured min ping distance (for validation messages) + static int currentMinDistance = 25; + final GpsService _gpsService; final MeshCoreConnection _connection; final ApiQueueService _apiQueue; @@ -61,6 +67,24 @@ class PingService { final AudioService? _audioService; final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. + int _hopBytes; + + /// Number of bytes for trace path IDs (1, 2, or 4). Uses bitshift encoding, separate from TX. + int _traceHopBytes; + + /// Update hop bytes at runtime (e.g. when user changes path mode while connected) + set hopBytes(int value) => _hopBytes = value; + + /// Update trace hop bytes at runtime (e.g. when user changes trace byte setting) + set traceHopBytes(int value) => _traceHopBytes = value; + + /// When true, skip RSSI carpeater check in DiscTracker (user setting) + bool disableRssiFilter; + + /// Unified RX handler reference for routing trace packets + UnifiedRxHandler? unifiedRxHandler; + PingStats _stats = const PingStats(); DateTime? _lastTxTime; Timer? _rxWindowTimer; @@ -77,9 +101,17 @@ class PingService { bool _autoPingEnabled = false; bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; + bool _targetedModeEnabled = false; bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; + // Targeted mode tracking + TraceTracker? _traceTracker; + StreamSubscription? _traceDataSubscription; + Timer? _targetedTimer; + String? _targetRepeaterId; + Position? _lastTargetedPosition; + // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; @@ -105,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to check if discovery drop is enabled (failed discoveries → API) + bool Function()? getDiscDropEnabled; + /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; @@ -132,6 +167,13 @@ class PingService { /// Parameters: (bool success) - true if any nodes discovered, false if none void Function(bool success)? onDiscoveryWindowComplete; + /// Callback when trace window ends (for noise floor graph + log) + /// Parameters: (TraceResult? result) - null if no response + void Function(TraceResult? result)? onTraceWindowComplete; + + /// Callback when trace ping is sent (for log entry creation) + void Function(TraceLogEntry)? onTracePing; + /// Callback when pingInProgress changes (for immediate UI refresh) void Function()? onPingProgressChanged; @@ -159,6 +201,9 @@ class PingService { TxTracker? txTracker, AudioService? audioService, this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + int hopBytes = 1, + int traceHopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -169,7 +214,9 @@ class PingService { _discoveryWindowCountdown = discoveryWindowTimer, _deviceId = deviceId, _txTracker = txTracker, - _audioService = audioService; + _audioService = audioService, + _hopBytes = hopBytes, + _traceHopBytes = traceHopBytes; /// Get current ping statistics PingStats get stats => _stats; @@ -186,6 +233,9 @@ class PingService { /// Check if Hybrid Mode is active (alternates discovery + TX) bool get isHybridMode => _hybridModeEnabled; + /// Check if Targeted Mode is active (zero-hop trace to specific repeater) + bool get isTargetedMode => _targetedModeEnabled; + /// Check if discovery tracker is currently listening (for Passive Mode UI) bool get isDiscoveryListening => _discTracker?.isListening ?? false; @@ -265,7 +315,7 @@ class PingService { return PingValidation.tooCloseToLastPing; } - // Check cooldown (7 seconds between pings) + // Check cooldown (5 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -374,7 +424,7 @@ class PingService { // NOTE: Skip distance check (tooCloseToLastPing) intentionally // Auto mode handles this by setting skipReason='too close' and scheduling next ping - // Check cooldown (7 seconds between pings) + // Check cooldown (5 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -447,7 +497,7 @@ class PingService { return false; } } else { - // Auto ping: 7-second cooldown, 25m distance check + // Auto ping: 5-second cooldown, 25m distance check // This fixes a race condition where disabling Active Mode during cooldown // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { @@ -582,7 +632,7 @@ class PingService { // Manual ping: 15-second cooldown, no distance check _manualPingCooldownTimer.start(_manualPingCooldown.inMilliseconds); } else { - // Auto ping: 7-second cooldown + // Auto ping: 5-second cooldown _cooldownTimer.start(_autoPingCooldown.inMilliseconds); } @@ -610,14 +660,13 @@ class PingService { } } - /// Start the 6-second RX listening window after TX + /// Start the 5-second RX listening window after TX /// Note: TxTracker handles the actual echo tracking, we just manage the countdown UI - /// Reference: RX_LOG_LISTEN_WINDOW_MS = 6000 in wardrive.js void _startRxListeningWindow(Position txPosition) { // Cancel previous timer _rxWindowTimer?.cancel(); - // Start RX window countdown display (6 seconds - matches JS RX_LOG_LISTEN_WINDOW_MS) + // Start RX window countdown display (5 seconds) _rxWindowCountdown.start(_rxListeningWindow.inMilliseconds); // Set timer for window end @@ -647,8 +696,10 @@ class PingService { for (final entry in txTracker.repeaters.entries) { final repeaterId = entry.key; final echo = entry.value; - // Format SNR with 2 decimal places - repeaterStrings.add('$repeaterId(${echo.snr.toStringAsFixed(2)})'); + // Format SNR with 2 decimal places, or "null" for CARpeater pass-through + repeaterStrings.add(echo.snr != null + ? '$repeaterId(${echo.snr!.toStringAsFixed(2)})' + : '$repeaterId(null)'); debugLog('[PING] Heard repeater: $repeaterId, SNR=${echo.snr}'); } heardRepeats = repeaterStrings.join(','); @@ -691,9 +742,11 @@ class PingService { debugLog('[PING] Executing pending disable after RX window'); _pendingDisable = false; final wasHybrid = _hybridModeEnabled; + final wasTargeted = _targetedModeEnabled; _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _autoTimer?.cancel(); _autoTimer = null; @@ -701,6 +754,10 @@ class PingService { if (wasHybrid) { _stopDiscoveryMode(); } + // Clean up targeted infrastructure if targeted was enabled + if (wasTargeted) { + _stopTargetedMode(); + } // Start cooldown immediately _cooldownTimer.start(_autoPingCooldown.inMilliseconds); debugLog('[PING] Pending disable complete, cooldown started'); @@ -793,20 +850,34 @@ class PingService { } } - /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), or Hybrid Mode + /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), + /// Hybrid Mode, or Targeted Mode (zero-hop trace) /// Reference: startAutoPing() in wardrive.js /// @param passiveMode - If true, only listens for RX (no TX pings) - this is Passive Mode /// @param hybridMode - If true, alternates discovery + TX pings each interval - Future enableAutoPing({bool passiveMode = false, bool hybridMode = false}) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode)'); + /// @param targetedMode - If true, sends trace path to specific repeater + /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) + Future enableAutoPing({ + bool passiveMode = false, + bool hybridMode = false, + bool targetedMode = false, + String? targetRepeaterId, + }) async { + debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); return false; } + // Targeted mode requires a repeater ID + if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + debugLog('[AUTO] Targeted mode requires a repeater ID'); + return false; + } + // Check if we're in cooldown (can't start during cooldown) - // Hybrid and Active modes are blocked by cooldown, Passive is not + // Hybrid, Active, and Targeted modes are blocked by cooldown, Passive is not // Reference: isInCooldown() check in startAutoPing() in wardrive.js if (!passiveMode && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); @@ -824,14 +895,23 @@ class PingService { _autoPingEnabled = true; _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; + _targetedModeEnabled = targetedMode; _nextPingIsDiscovery = true; // Always start hybrid with discovery + if (targetedMode) { + _targetRepeaterId = targetRepeaterId; + } + // Enable wake lock to keep screen on during auto mode // Reference: acquireWakeLock() in wardrive.js debugLog('[AUTO] Acquiring wake lock for auto mode'); await _wakelockService.enable(); - if (hybridMode) { + if (targetedMode) { + // Targeted Mode: send trace path to specific repeater + debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + await _startTargetedMode(); + } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); @@ -884,14 +964,20 @@ class PingService { // Clear skip reason _skipReason = null; - // Clean up discovery infrastructure if hybrid was enabled - if (_hybridModeEnabled) { + // Clean up discovery infrastructure if passive or hybrid was enabled + if (_passiveModeEnabled || _hybridModeEnabled) { _stopDiscoveryMode(); } + // Clean up targeted mode infrastructure + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; // Disable wake lock when auto mode stops @@ -912,8 +998,10 @@ class PingService { _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _stopDiscoveryMode(); + _stopTargetedMode(); await _wakelockService.disable(); } @@ -932,7 +1020,11 @@ class PingService { debugLog('[DISC] Starting discovery mode'); // Create and configure discovery tracker - final tracker = DiscTracker(shouldIgnoreRepeater: shouldIgnoreRepeater); + final tracker = DiscTracker( + shouldIgnoreRepeater: shouldIgnoreRepeater, + disableRssiFilter: disableRssiFilter, + hopBytes: _hopBytes, + ); _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { @@ -1012,8 +1104,8 @@ class PingService { position.latitude, position.longitude, ); - if (distance < GpsService.minDistanceMeters) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < 25m), skipping'); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1064,7 +1156,7 @@ class PingService { windowDuration: _discoveryListeningWindow, ); - // Start discovery window countdown display (7 seconds) + // Start discovery window countdown display (5 seconds) _discoveryWindowCountdown.start(_discoveryListeningWindow.inMilliseconds); // Clear pingInProgress now that discovery window is active @@ -1121,6 +1213,18 @@ class PingService { onStatsUpdated?.call(_stats); } else { debugLog('[DISC] No nodes discovered'); + + // Queue failed discovery to API if disc drop is enabled + if (getDiscDropEnabled?.call() == true) { + _apiQueue.enqueueDiscDrop( + latitude: position.latitude, + longitude: position.longitude, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + debugLog('[DISC] Discovery drop queued (no response)'); + } } // Entry already added to log via onDiscPing - no need to fire onDiscoveryComplete @@ -1171,8 +1275,8 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 7000 = 8000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 7000 + // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. + final listenMs = _rxListeningWindow.inMilliseconds; // 5000 final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1199,6 +1303,219 @@ class PingService { }); } + // ============================================ + // Targeted Mode (Zero-Hop Trace) + // ============================================ + + /// Start targeted mode - subscribes to trace data and sends first trace + Future _startTargetedMode() async { + debugLog('[TRACE] Starting targeted mode for repeater $_targetRepeaterId'); + + // Create trace tracker + final tracker = TraceTracker(); + _traceTracker = tracker; + tracker.onTraceReceived = (result) { + debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + }; + tracker.onWindowComplete = (result) { + debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + _handleTraceWindowComplete(result); + }; + + // Wire trace tracker into UnifiedRxHandler so 0x88 BLE metadata + // gets stored for the 0x89 handler + unifiedRxHandler?.traceTracker = tracker; + + // Subscribe to 0x89 TraceData stream for actual trace payloads + _traceDataSubscription = _connection.traceDataStream.listen((raw) { + final tt = _traceTracker; + if (tt != null && tt.isListening) { + tt.handlePacket(raw, tt.pendingBleSnr, tt.pendingBleRssi); + } + }); + + // Send first trace immediately + await _sendTargetedPing(); + } + + /// Stop targeted mode - cleans up tracker and subscription + void _stopTargetedMode() { + debugLog('[TRACE] Stopping targeted mode'); + _targetedTimer?.cancel(); + _targetedTimer = null; + _traceDataSubscription?.cancel(); + _traceDataSubscription = null; + unifiedRxHandler?.traceTracker = null; + _traceTracker?.dispose(); + _traceTracker = null; + _lastTargetedPosition = null; + } + + /// Send a targeted ping (trace path) and start listening window + Future _sendTargetedPing() async { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, skipping trace'); + return; + } + + final targetId = _targetRepeaterId; + if (targetId == null || targetId.isEmpty) { + debugLog('[TRACE] No target repeater ID, skipping trace'); + _scheduleNextTargetedPing(); + return; + } + + // Check GPS + final position = _gpsService.lastPosition; + if (position == null) { + debugLog('[TRACE] No GPS position, skipping trace'); + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + + // Check minimum distance from last trace (25m) + final lastPos = _lastTargetedPosition; + if (lastPos != null) { + final distance = Geolocator.distanceBetween( + lastPos.latitude, lastPos.longitude, + position.latitude, position.longitude, + ); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + _skipReason = 'too close'; + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + } + + // Clear skip reason since we're proceeding + _skipReason = null; + + // Signal "Sending..." to UI + _pingInProgress = true; + onPingProgressChanged?.call(); + + // Capture noise floor + final noiseFloor = _connection.lastNoiseFloor; + _pendingTxNoiseFloor = noiseFloor; + + // Create trace log entry immediately + final traceEntry = TraceLogEntry( + timestamp: DateTime.now(), + latitude: position.latitude, + longitude: position.longitude, + targetRepeaterId: targetId, + noiseFloor: noiseFloor, + success: false, // Will be updated after window completes + ); + onTracePing?.call(traceEntry); + + debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + + try { + // Play transmit sound + _audioService?.playTransmitSound(); + + // Convert hex repeater ID to bytes (trace uses separate byte size: 1, 2, or 4) + final traceBytes = _traceHopBytes; + final repeaterIdBytes = Uint8List(traceBytes); + for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { + repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + } + + // Send trace path and get tag + final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); + + // Start tracking with the tag + _traceTracker?.startTracking( + tag: tag, + targetRepeaterId: targetId, + windowDuration: _rxListeningWindow, + ); + + // Start listening window countdown display + _discoveryWindowCountdown.start(_rxListeningWindow.inMilliseconds); + + // Clear pingInProgress now that trace window is active + _pingInProgress = false; + + // Update last targeted position for 25m check + _lastTargetedPosition = position; + + } catch (e) { + _pingInProgress = false; + debugError('[TRACE] Failed to send trace: $e'); + _scheduleNextTargetedPing(); + } + } + + /// Handle trace window completion + void _handleTraceWindowComplete(TraceResult? result) { + _discoveryWindowCountdown.stop(); + final position = _lastTargetedPosition; + final targetId = _targetRepeaterId ?? ''; + + if (result != null && result.success && position != null) { + debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + + // Queue to API (only successful traces) + _apiQueue.enqueueTrace( + latitude: position.latitude, + longitude: position.longitude, + repeaterId: targetId, + localSnr: result.localSnr, + localRssi: result.localRssi, + remoteSnr: result.remoteSnr, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + + // Update stats + _stats = _stats.copyWith(traceCount: _stats.traceCount + 1); + onStatsUpdated?.call(_stats); + + // Play receive sound for successful trace + _audioService?.playReceiveSound(); + } else { + debugLog('[TRACE] Trace failed: no response from $targetId'); + // Failed traces are NOT posted to API (local visual only) + } + + // Notify for noise floor graph and log updates + onTraceWindowComplete?.call(result); + + _scheduleNextTargetedPing(); + } + + /// Schedule next targeted ping after interval + void _scheduleNextTargetedPing() { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, not scheduling next trace'); + return; + } + + _targetedTimer?.cancel(); + _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { + debugLog('[TRACE] Targeted ping timer fired'); + if (_autoPingEnabled && _targetedModeEnabled) { + if (_pingInProgress) { + debugLog('[TRACE] Ping already in progress, skipping'); + return; + } + _skipReason = null; + _sendTargetedPing(); + } + }); + + // Notify callback for countdown display + onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + + debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + } + /// Stop any active TX echo tracking window /// Called when disabling auto mode to prevent late timer callbacks from /// triggering pings during cooldown (race condition fix) @@ -1221,6 +1538,7 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; _stopDiscoveryMode(); + _stopTargetedMode(); _wakelockService.dispose(); } } @@ -1255,7 +1573,7 @@ enum PingValidation { /// Too close to last ping (< 25m) tooCloseToLastPing, - /// Cooldown period active (< 7s since last ping) + /// Cooldown period active (< 5s since last ping) cooldownActive, /// Manual ping cooldown period active (< 15s since last manual ping) @@ -1285,9 +1603,9 @@ extension PingValidationExtension on PingValidation { case PingValidation.outsideGeofence: return 'Outside service area'; case PingValidation.tooCloseToLastPing: - return 'Move 25m before next ping'; + return 'Move ${PingService.currentMinDistance}m before next ping'; case PingValidation.cooldownActive: - return 'Wait 7 seconds between pings'; + return 'Wait 5 seconds between pings'; case PingValidation.manualCooldownActive: return 'Wait 15 seconds between manual pings'; case PingValidation.txNotAllowed: diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index b7e96ba3..4dc5a98d 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -20,8 +20,8 @@ class DebugLogger { if (_initialized) return; _initialized = true; - // On mobile, enable debug logging in debug mode - _debugEnabled = kDebugMode; + // Enable debug logging by default on all builds + _debugEnabled = true; if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 82dab78d..7d2e2ef5 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -80,6 +80,18 @@ extension MapStyleExtension on MapStyle { return null; // ArcGIS doesn't use subdomains } } + + /// Whether this style supports retina tiles via {r} placeholder + bool get supportsRetina { + switch (this) { + case MapStyle.dark: + return true; // Carto supports @2x via {r} + case MapStyle.light: + return false; // OSM has no retina support + case MapStyle.satellite: + return false; // ArcGIS has no retina support + } + } } /// Custom tile provider that silently handles HTTP errors (404, 503, etc.) @@ -557,11 +569,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map _buildMap(appState, center), - // GPS Info overlay (top-left, respects dynamic island in landscape) + // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( top: topPadding, left: leftPadding, - child: _buildGpsInfoOverlay(appState), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], + ], + ), ), // Map controls - top-right in both orientations, collapsible @@ -616,7 +637,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { initialCenter: center, initialZoom: _defaultZoom, minZoom: 3, - maxZoom: 18, + maxZoom: 17, interactionOptions: InteractionOptions( flags: _rotationLocked ? InteractiveFlag.all & ~InteractiveFlag.rotate @@ -639,9 +660,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { urlTemplate: mapStyle.urlTemplate, subdomains: mapStyle.subdomains ?? const [], userAgentPackageName: 'com.meshmapper.app', - maxZoom: 19, - retinaMode: RetinaMode.isHighDensity(context), // Enable high-res tiles on retina displays - tileProvider: SilentCancellableNetworkTileProvider(), // Silently handles tile errors + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), ); }, ), @@ -649,9 +670,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // MeshMapper coverage overlay (only when zone code available and overlay enabled) if (appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}', + urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', - maxZoom: 19, + minZoom: 3, + maxZoom: 17, + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, // Keep old tile visible until new one loads + ), tileProvider: SilentCancellableNetworkTileProvider(), ), @@ -667,12 +692,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // DISC markers (purple circles for discovery observations) MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries), + markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), + ), + + // Trace markers (cyan/red circles for targeted ping results) + MarkerLayer( + markers: _buildTraceMarkers(appState.traceLogEntries), ), - // Repeater markers (magenta circles with ID) + // Repeater markers (magenta with ID, rotate with map) MarkerLayer( - markers: _buildRepeaterMarkers(appState.repeaters), + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), ), // Current position marker (car icon) @@ -695,7 +729,128 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Color for the overlay ping-type dot + static Color _overlayTypeColor(OverlayPingType type) { + return switch (type) { + OverlayPingType.tx => Colors.green, + OverlayPingType.disc => Colors.purple, + OverlayPingType.trace => Colors.cyan, + OverlayPingType.rx => Colors.blue, + }; + } + + /// Build a single overlay table row with colored dot, repeater ID, and SNR + TableRow _overlayRow(String repeaterId, double snr, Color dotColor) { + return TableRow( + children: [ + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + snr.toStringAsFixed(1), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(snr), + ), + ), + ), + ], + ); + } + /// GPS info overlay (top-left corner) + /// Top heard repeaters overlay (bottom-right of map) + Widget _buildTopRepeatersOverlay(AppStateProvider appState) { + final topRepeaters = appState.topRepeatersBySnr; + final rxSlot = appState.rxOverlaySlot; + final isEmpty = topRepeaters.isEmpty && rxSlot == null; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Top Heard', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Colors.white54, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + if (isEmpty) + const Text( + '---', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.white38, + ), + ), + if (!isEmpty) + Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: const { + 0: IntrinsicColumnWidth(), // dot + 1: IntrinsicColumnWidth(), // ID + 2: FixedColumnWidth(8), // spacer + 3: IntrinsicColumnWidth(), // SNR + }, + children: [ + for (final r in topRepeaters) + _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), + if (rxSlot != null) + _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + ], + ), + ], + ), + ); + } + + /// SNR color: green > 5, orange -1..5, red <= -1 + static Color _snrColor(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; final hasGps = position != null; @@ -1100,12 +1255,26 @@ class _MapWidgetState extends State with TickerProviderStateMixin { description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: Colors.cyan, + label: 'TRC', + description: 'Location where a trace reached the repeater', + ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, color: Colors.grey, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: Colors.grey, + label: 'TRC', + description: 'Location where a trace got no response', + ), ], ), ), @@ -1550,7 +1719,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } - List _buildDiscMarkers(List entries) { + List _buildDiscMarkers(List entries, bool discDropEnabled) { return entries.map((entry) { return Marker( point: LatLng(entry.latitude, entry.longitude), @@ -1560,7 +1729,36 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showDiscPingDetails(entry), child: Container( decoration: BoxDecoration( - color: entry.nodeCount == 0 ? Colors.grey : _discMarkerColor, + color: entry.nodeCount == 0 + ? (discDropEnabled ? Colors.red : Colors.grey) + : _discMarkerColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + ), + ); + }).toList(); + } + + List _buildTraceMarkers(List entries) { + return entries.map((entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: Container( + decoration: BoxDecoration( + color: entry.success ? Colors.cyan : Colors.grey, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ @@ -1577,6 +1775,257 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } + void _showTraceDetails(TraceLogEntry entry) { + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.cyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Target repeater section header + Text( + entry.success + ? 'Target Repeater' + : 'No response from target repeater', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (entry.success) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + Builder(builder: (context) { + final localSnr = entry.localSnr ?? 0; + final localRssi = entry.localRssi ?? 0; + final remoteSnr = entry.remoteSnr ?? 0; + + Color rxSnrColor; + if (localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (localRssi >= -70) { + rssiColor = Colors.green; + } else if (localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RX RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '$localRssi', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + /// DISC marker color (#7B68EE - medium slate blue/purple) static const Color _discMarkerColor = Color(0xFF7B68EE); @@ -1616,23 +2065,39 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers(List repeaters) { + List _buildRepeaterMarkers(List repeaters, int? regionHopBytesOverride) { final duplicateIds = _getDuplicateRepeaterIds(repeaters); return repeaters.map((repeater) { final isDuplicate = duplicateIds.contains(repeater.id); final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + // Display hex ID based on per-repeater hop_bytes (or regional admin override) + final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; + final isLongId = displayId.length > 2; + final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; + + // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded + final borderRadius = effectiveBytes >= 3 + ? BorderRadius.circular(8) + : effectiveBytes == 2 + ? BorderRadius.circular(6) + : BorderRadius.circular(4); + return Marker( point: LatLng(repeater.lat, repeater.lon), - width: 28, + width: markerWidth, height: 28, child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate), + onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), child: Container( + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 4) + : EdgeInsets.zero, decoration: BoxDecoration( color: markerColor, - shape: BoxShape.circle, + borderRadius: borderRadius, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ BoxShadow( @@ -1644,11 +2109,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), alignment: Alignment.center, child: Text( - repeater.id, - style: const TextStyle( - fontSize: 10, + displayId, + style: TextStyle( + fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, fontWeight: FontWeight.bold, color: Colors.white, + fontFamily: 'monospace', ), ), ), @@ -1672,6 +2138,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Compute node column width based on hop byte count. + /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). + double _nodeColumnWidth({double extraPadding = 0}) { + final appState = context.read(); + final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + switch (hopBytes) { + case 2: + return 70 + extraPadding; + case 3: + return 80 + extraPadding; + default: + return 60 + extraPadding; + } + } + /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing @@ -1680,209 +2161,221 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Icon badge + // Header with icon badge + Row( + children: [ + // Icon badge + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TX Ping', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(ping.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(ping.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + const SizedBox(height: 16), + + // Repeaters section header + Text( + heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, ), - ], - ), - ), - const SizedBox(height: 16), - - // Repeaters section header - Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // Repeaters table - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + + if (heardRepeaters.isNotEmpty) ...[ + const SizedBox(height: 12), + // Repeaters table + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: 60), - // SNR + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), Expanded( - child: Center( - child: _buildStatChip( - value: repeater.snr.toStringAsFixed(1), - color: snrColor, + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${repeater.rssi}', - color: rssiColor, + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), - ); - }), - ], - ), - ), - ], - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...heardRepeaters.map((repeater) { + // Calculate SNR chip color + Color snrColor; + if (repeater.snr == null) { + snrColor = Colors.grey; + } else if (repeater.snr! <= -1) { + snrColor = Colors.red; + } else if (repeater.snr! <= 5) { + snrColor = Colors.orange; + } else { + snrColor = Colors.green; + } + + // Calculate RSSI chip color + Color rssiColor; + if (repeater.rssi == null) { + rssiColor = Colors.grey; + } else if (repeater.rssi! >= -70) { + rssiColor = Colors.green; + } else if (repeater.rssi! >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + // SNR + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null ? '${repeater.rssi}' : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), ), ), ); @@ -2021,7 +2514,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: 60, + width: _nodeColumnWidth(), child: Text( 'Node', style: TextStyle( @@ -2065,7 +2558,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: 60), + RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -2102,254 +2595,262 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Icon badge + // Header with icon badge + Row( + children: [ + // Icon badge + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _discMarkerColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Disc Request', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: _discMarkerColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(entry.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + const SizedBox(height: 16), + + // Discovered nodes section header + Text( + entry.discoveredNodes.isEmpty + ? 'No nodes discovered' + : 'Discovered Nodes (${entry.discoveredNodes.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, ), - ], - ), - ), - const SizedBox(height: 16), - - // Discovered nodes section header - Text( - entry.discoveredNodes.isEmpty - ? 'No nodes discovered' - : 'Discovered Nodes (${entry.discoveredNodes.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (entry.discoveredNodes.isNotEmpty) ...[ - const SizedBox(height: 12), - // Table with headers - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + + if (entry.discoveredNodes.isNotEmpty) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Node ID with type SizedBox( - width: 60, - child: Row( - children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), - Text( - node.nodeTypeLabel, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _discMarkerColor, - ), - ), - ], + width: _nodeColumnWidth(extraPadding: 20), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), - // RX SNR Expanded( - child: Center( - child: _buildStatChip( - value: node.localSnr.toStringAsFixed(1), - color: rxSnrColor, + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${node.localRssi}', - color: rssiColor, + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // TX SNR Expanded( - child: Center( - child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), - color: txSnrColor, + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), - ); - }), - ], - ), - ), - ], - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.discoveredNodes.map((node) { + // Calculate colors + Color rxSnrColor; + if (node.localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (node.localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (node.localRssi >= -70) { + rssiColor = Colors.green; + } else if (node.localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (node.remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (node.remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Node ID with type + SizedBox( + width: _nodeColumnWidth(extraPadding: 20), + child: Row( + children: [ + RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + Text( + node.nodeTypeLabel, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _discMarkerColor, + ), + ), + ], + ), + ), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: node.localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${node.localRssi}', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: node.remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), ), ), ); @@ -2376,7 +2877,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false}) { + void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -2410,25 +2911,33 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Header with icon badge (containing ID) and name Row( children: [ - // Icon badge with ID (mirrors map marker) - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text( - repeater.id, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, + // Icon badge with hex ID (mirrors map marker) + Builder(builder: (context) { + final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final isLongId = displayId.length > 2; + return Container( + constraints: const BoxConstraints(minWidth: 44), + height: 44, + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: iconColor, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white, width: 2), ), - ), - ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: isLongId ? 13 : 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + }), const SizedBox(width: 12), Expanded( child: Text( diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 31ba9332..aa760b46 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -221,6 +221,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Failed', }; final eventDescription = switch (marker.type) { @@ -229,6 +231,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', + PingEventType.traceSuccess => 'Trace got response from target', + PingEventType.traceFail => 'Trace got no response from target', }; final hasLocation = marker.latitude != null && marker.longitude != null; @@ -848,7 +852,8 @@ class InteractiveNoiseFloorChartState extends State _legendItem(context, Colors.red, 'TX Fail'), _legendItem(context, Colors.blue, 'RX'), _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.grey, 'DISC Fail'), + _legendItem(context, Colors.cyan, 'Trace Success'), + _legendItem(context, Colors.grey, 'No Response'), ], ); } diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart new file mode 100644 index 00000000..b54aec58 --- /dev/null +++ b/lib/widgets/offline_mode_toggle.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; + +/// Shared offline mode toggle widget +/// Compact button style — icon + label, tappable to toggle with confirmation +class OfflineModeToggle extends StatelessWidget { + const OfflineModeToggle({super.key}); + + /// Handle offline mode toggle with progress dialog when connected + static Future handleOfflineModeToggle( + BuildContext context, + AppStateProvider appState, + bool currentOfflineMode, + bool isConnected, + ) async { + final newMode = !currentOfflineMode; + + // Always show confirmation dialog + final confirmed = await _showConfirmDialog(context, newMode); + if (confirmed != true || !context.mounted) return; + + // If connected, show progress dialog during mode switch + if (isConnected) { + final statusText = newMode + ? 'Switching to offline mode...' + : 'Switching to online mode...'; + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + + // Perform the mode switch + final result = await appState.setOfflineMode(newMode); + + // Close the progress dialog (check if context is still valid) + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show error dialog if switch failed + if (!result.success && context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Mode Switch Failed'), + content: Text( + result.error ?? 'An unknown error occurred', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } else { + // Not connected - simple toggle without dialog + await appState.setOfflineMode(newMode); + } + } + + /// Show confirmation dialog explaining what the mode does + static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { + final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; + final iconColor = switchingToOffline ? Colors.orange : Colors.green; + final description = switchingToOffline + ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' + : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' + 'This requires an active internet connection.'; + final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 8), + Flexible(child: Text(title)), + ], + ), + content: Text( + description, + style: const TextStyle(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: switchingToOffline + ? FilledButton.styleFrom(backgroundColor: Colors.orange) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final appState = context.watch(); + final offlineMode = appState.offlineMode; + final isConnected = appState.isConnected; + + final color = offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : (isDark ? Colors.green.shade400 : Colors.green.shade700); + final bgColor = offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15); + final borderColor = offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : Colors.green.withValues(alpha: 0.4); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: color, + ), + const SizedBox(width: 8), + Text( + offlineMode ? 'Go Online' : 'Go Offline', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index ce7c6709..805417de 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; +import 'repeater_picker_sheet.dart'; /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -22,6 +23,7 @@ class PingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode @@ -34,6 +36,7 @@ class PingControls extends StatelessWidget { final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -108,7 +111,7 @@ class PingControls extends StatelessWidget { ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow onPressed: () => _sendPing(context, appState), @@ -145,7 +148,7 @@ class PingControls extends StatelessWidget { : rxWindowActive ? 'Listening ${rxWindowRemaining}s' // TX RX window : autoPingWaiting - ? 'Next ping ${autoPingRemaining}s' + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') : rxWindowActive ? 'Cooldown ${rxWindowRemaining}s' @@ -157,7 +160,7 @@ class PingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), showCooldown: false, @@ -179,7 +182,7 @@ class PingControls extends StatelessWidget { ? (discoveryWindowActive ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? 'Next Disc ${autoPingRemaining}s' // Waiting for next discovery + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping @@ -191,7 +194,7 @@ class PingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases @@ -224,15 +227,11 @@ class PingControls extends StatelessWidget { else const SizedBox(height: 8), - // Offline Mode and Sound toggles row - const Row( - children: [ - // Offline Mode toggle (expanded) - Expanded(child: _OfflineModeToggle()), - SizedBox(width: 8), - // Sound toggle (compact, right side) - _SoundToggle(), - ], + // Targeted Ping controls + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, ), ], ); @@ -445,370 +444,245 @@ class _ActionButtonState extends State<_ActionButton> } } -/// Compact sound notification toggle - matches height of _OfflineModeToggle -class _SoundToggle extends StatelessWidget { - const _SoundToggle(); +/// Targeted Ping controls - hex text field + start/stop button +class _TargetedPingSection extends StatefulWidget { + final bool isAnyModeRunning; + final bool cooldownActive; + final int cooldownRemaining; + final bool compact; + + const _TargetedPingSection({ + required this.isAnyModeRunning, + required this.cooldownActive, + required this.cooldownRemaining, + this.compact = false, + }); @override - Widget build(BuildContext context) { - final appState = context.watch(); - final soundEnabled = appState.isSoundEnabled; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => appState.toggleSoundEnabled(), - borderRadius: BorderRadius.circular(10), - child: Container( - // Match _OfflineModeToggle: padding 10v + icon container (6+18+6) + text adds more height - // _OfflineModeToggle content: icon 30px, text column ~34px, toggle 26px - // Use same padding and let IntrinsicHeight from Row handle matching - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - soundEnabled ? Icons.volume_up : Icons.volume_off, - size: 18, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - // Add text column to match offline mode toggle height - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sound', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: soundEnabled - ? (isDark ? Colors.blue.shade300 : Colors.blue.shade800) - : colorScheme.onSurface, - ), - ), - Text( - soundEnabled ? 'On' : 'Off', - style: TextStyle( - fontSize: 11, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade600) - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } + State<_TargetedPingSection> createState() => _TargetedPingSectionState(); } -/// Compact offline mode toggle matching app design language -class _OfflineModeToggle extends StatelessWidget { - const _OfflineModeToggle(); - - // Offline mode is now enabled - static const bool _isEnabled = true; - - /// Handle offline mode toggle with progress dialog when connected - static Future _handleOfflineModeToggle( - BuildContext context, - AppStateProvider appState, - bool currentOfflineMode, - bool isConnected, - ) async { - final newMode = !currentOfflineMode; - - // If connected, show progress dialog during mode switch - if (isConnected) { - final statusText = newMode - ? 'Switching to offline mode...' - : 'Switching to online mode...'; - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ); - - // Perform the mode switch - final result = await appState.setOfflineMode(newMode); +class _TargetedPingSectionState extends State<_TargetedPingSection> { + final _controller = TextEditingController(); + bool _isStarting = false; - // Close the progress dialog (check if context is still valid) - if (context.mounted) { - Navigator.of(context).pop(); + @override + void initState() { + super.initState(); + // Restore any previously set target ID + WidgetsBinding.instance.addPostFrameCallback((_) { + final appState = context.read(); + final existing = appState.targetRepeaterId; + if (existing != null && existing.isNotEmpty && _controller.text != existing) { + _controller.text = existing; } + }); + } - // Show error dialog if switch failed - if (!result.success && context.mounted) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Mode Switch Failed'), - content: Text( - result.error ?? 'An unknown error occurred', - style: const TextStyle(fontSize: 14), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } else { - // Not connected - simple toggle without dialog - await appState.setOfflineMode(newMode); - } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showRepeaterPicker() async { + final appState = context.read(); + final repeater = await showRepeaterPicker(context); + if (repeater == null || !mounted) return; + + final maxLen = appState.traceHopBytes * 2; + final trimmed = repeater.hexId.length >= maxLen + ? repeater.hexId.substring(0, maxLen).toUpperCase() + : repeater.hexId.toUpperCase(); + _controller.text = trimmed; + appState.setTargetRepeaterId(trimmed); + setState(() {}); } @override Widget build(BuildContext context) { + final appState = context.watch(); + final isTargetedRunning = appState.isTargetedModeRunning; + final maxLen = appState.traceHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - // When disabled, always show as "off" state - if (!_isEnabled) { - return Opacity( - opacity: 0.5, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.cloud_queue, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and "Coming soon" - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - 'Coming soon', - style: TextStyle( - fontSize: 11, - fontStyle: FontStyle.italic, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // Toggle indicator (always off) - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1), // slate-600/300 - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); + + // Sync controller when provider clears target (e.g. trace bytes changed) + if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { + _controller.clear(); } - // Original implementation when enabled - final appState = context.watch(); - final offlineMode = appState.offlineMode; - final offlinePingCount = appState.offlinePingCount; - final isConnected = appState.isConnected; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _handleOfflineModeToggle(context, appState, offlineMode, isConnected), + // Determine if the start button should be enabled + final hexText = _controller.text.trim(); + final isValidHex = hexText.isNotEmpty && + hexText.length == maxLen && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); + final canStart = isValidHex && + !widget.isAnyModeRunning && + !isTargetedRunning && + !widget.cooldownActive && + appState.isConnected; + + // Status text for when targeted mode is running + String? statusText; + if (isTargetedRunning) { + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + + if (discoveryWindowActive) { + statusText = 'Listening ${discoveryRemaining}s'; + } else if (autoPingWaiting) { + statusText = appState.autoPingTimer.skipReason != null + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s'; + } + } + + final isEnabled = (canStart || isTargetedRunning) && !_isStarting; + final buttonColor = (isTargetedRunning || _isStarting) + ? const Color(0xFF22C55E) // green-500 when running/starting + : Colors.cyan; + final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + + return Container( + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: offlineMode - ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) - : colorScheme.onSurface, - ), - ), - if (offlineMode && offlinePingCount > 0) - Text( - '$offlinePingCount pings saved locally', - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, - ), - ) - else - Text( - offlineMode - ? 'Data saved locally' - : 'Uploads immediately', + border: Border.all( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + width: isTargetedRunning ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Targeted button + Expanded( + child: GestureDetector( + onTap: isEnabled + ? () async { + HapticFeedback.lightImpact(); + if (!isTargetedRunning) { + setState(() => _isStarting = true); + appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + } + await appState.toggleAutoPing(AutoMode.targeted); + if (mounted) setState(() => _isStarting = false); + } + : null, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Icon( + Icons.route, + size: 18, + color: effectiveColor, + ), + if (!widget.compact) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, + fontSize: 13, + fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, + color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), + overflow: TextOverflow.ellipsis, ), + ), ], - ), + ], ), - // Toggle indicator - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: offlineMode - ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) - : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), // slate-600/300 + ), + ), + const SizedBox(width: 8), + // Hex text field + SizedBox( + width: 80, + child: TextField( + controller: _controller, + enabled: !isTargetedRunning, + maxLength: maxLen, + textCapitalization: TextCapitalization.characters, + style: TextStyle( + fontSize: 14, + fontFamily: 'monospace', + color: isTargetedRunning + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintStyle: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: offlineMode - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), + counterText: '', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), ), - ], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), + _UpperCaseTextFormatter(), + ], + onChanged: (value) { + appState.setTargetRepeaterId(value.trim().toUpperCase()); + setState(() {}); + }, + ), ), - ), + const SizedBox(width: 6), + // Choose repeater button + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: Icon( + Icons.list, + size: 18, + color: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + onPressed: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? _showRepeaterPicker + : null, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + tooltip: 'Choose repeater', + ), + ), + ], ), ); } } +/// Text formatter that converts input to uppercase +class _UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return TextEditingValue( + text: newValue.text.toUpperCase(), + selection: newValue.selection, + ); + } +} + /// Compact ping controls for minimized panel view /// Shows 3 small horizontal pill buttons in a row /// Active button expands to show context (e.g., "Listening 5s") @@ -820,7 +694,7 @@ class CompactPingControls extends StatefulWidget { } /// Tracks which button should stay expanded during cooldown -enum _LastActiveButton { none, sendPing, activeMode, passiveMode } +enum _LastActiveButton { none, sendPing, activeMode, passiveMode, targeted } class _CompactPingControlsState extends State { // Static so it persists across widget rebuilds (e.g., expand/minimize panel) @@ -837,6 +711,7 @@ class _CompactPingControlsState extends State { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -849,6 +724,7 @@ class _CompactPingControlsState extends State { final isPingInProgress = appState.isPingInProgress; final autoPingWaiting = appState.autoPingTimer.isRunning; final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -873,9 +749,11 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.activeMode; } else if (passiveModeCurrentlyActive) { _lastActiveButton = _LastActiveButton.passiveMode; + } else if (isTargetedRunning) { + _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive) { + if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } @@ -890,25 +768,36 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet); final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; + // Trace Mode (only relevant when a repeater ID has been entered) + final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final targetedCurrentlyActive = isTargetedRunning; + final traceModeExpanded = targetedCurrentlyActive || + (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); + final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && + !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && + !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeActive = isTargetedRunning; + final traceModeShowColor = traceModeEnabled || traceModeActive; + // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded; + final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor; + final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -953,6 +842,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: activeModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, discoveryWindowActive: discoveryWindowActive, discoveryWindowRemaining: discoveryWindowRemaining, ), @@ -986,6 +876,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, ), color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 @@ -1003,6 +894,40 @@ class _CompactPingControlsState extends State { onPressed: () => _toggleRxAuto(context, appState), ); + // Build trace mode button (only used when hasTargetRepeaterId) + final traceModeButton = _CompactActionButton( + icon: Icons.route, + label: _getTraceModeLabel( + isTargetedRunning: isTargetedRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: traceModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isTargetedRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF06B6D4), // cyan-500 + enabled: traceModeEnabled || isTargetedRunning, + isActive: traceModeActive, + isExpanded: traceModeExpanded, + progress: discoveryWindowActive && isTargetedRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isTargetedRunning + ? appState.autoPingTimer.progress + : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + ? appState.cooldownTimer.progress + : null, + onPressed: () { + HapticFeedback.lightImpact(); + appState.toggleAutoPing(AutoMode.targeted); + }, + ); + // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally @@ -1034,6 +959,17 @@ class _CompactPingControlsState extends State { Expanded(child: passiveModeButton) else passiveModeButton, + + // Trace Mode (only shown when a repeater ID has been entered) + if (hasTargetRepeaterId) ...[ + const SizedBox(width: 6), + if (traceModeExpanded) + Expanded(child: traceModeButton) + else if (!anyExpanded && (traceModeShowColor || allDisabled)) + Expanded(child: traceModeButton) + else + traceModeButton, + ], ], ); } @@ -1074,6 +1010,7 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, bool discoveryWindowActive = false, int discoveryWindowRemaining = 0, }) { @@ -1086,7 +1023,7 @@ class _CompactPingControlsState extends State { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1107,10 +1044,37 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, }) { if (isPassiveModeRunning) { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } + // Show cooldown if this button caused it + if (cooldownActive && isExpandedDuringCooldown) { + return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + } + return null; + } + + /// Get label for Trace Mode button + /// When showFullText is true: "Listening 5s", when false: "5s" + String? _getTraceModeLabel({ + required bool isTargetedRunning, + required bool discoveryWindowActive, + required int discoveryWindowRemaining, + required bool autoPingWaiting, + required int autoPingRemaining, + required bool showFullText, + required bool cooldownActive, + required int cooldownRemaining, + required bool isExpandedDuringCooldown, + required bool isSkipped, + }) { + if (isTargetedRunning) { + if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1163,6 +1127,7 @@ class LandscapePingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -1186,11 +1151,6 @@ class LandscapePingControls extends StatelessWidget { final prefs = appState.preferences; final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Antenna selector - final soundEnabled = appState.isSoundEnabled; - final offlineMode = appState.offlineMode; - final isConnected = appState.isConnected; - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1213,7 +1173,7 @@ class LandscapePingControls extends StatelessWidget { icon: Icons.cell_tower, tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, countdown: isPingSending @@ -1242,7 +1202,7 @@ class LandscapePingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, countdown: isTxModeRunning ? (discoveryWindowActive @@ -1268,7 +1228,7 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), @@ -1286,34 +1246,12 @@ class LandscapePingControls extends StatelessWidget { ), const SizedBox(height: 10), - // Compact row for toggles - Row( - children: [ - Expanded( - child: _LandscapeToggle( - icon: offlineMode ? Icons.cloud_off : Icons.cloud_queue, - label: 'Offline', - isOn: offlineMode, - color: Colors.orange, - onTap: () => _OfflineModeToggle._handleOfflineModeToggle( - context, - appState, - offlineMode, - isConnected, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _LandscapeToggle( - icon: soundEnabled ? Icons.volume_up : Icons.volume_off, - label: 'Sound', - isOn: soundEnabled, - color: Colors.blue, - onTap: () => appState.toggleSoundEnabled(), - ), - ), - ], + // Targeted Ping controls (Trace Mode) + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + compact: true, ), ], ); @@ -1612,70 +1550,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> } /// Compact toggle button for landscape panel -class _LandscapeToggle extends StatelessWidget { - final IconData icon; - final String label; - final bool isOn; - final Color color; - final VoidCallback onTap; - - const _LandscapeToggle({ - required this.icon, - required this.label, - required this.isOn, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final activeColor = isOn ? color : colorScheme.onSurfaceVariant; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isOn - ? activeColor.withValues(alpha: 0.12) - : colorScheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isOn - ? activeColor.withValues(alpha: 0.35) - : colorScheme.outline.withValues(alpha: 0.15), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 16, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isOn ? FontWeight.w600 : FontWeight.w500, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} - /// Compact action button for minimized panel - horizontal pill layout /// Supports expanding to show label when active class _CompactActionButton extends StatefulWidget { diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 4c504837..10a909e1 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -6,18 +6,26 @@ class RegionalConfigCard extends StatelessWidget { final String? zoneName; final String? zoneCode; final List channels; + final String? scope; final bool isOfflineMode; + final bool compact; const RegionalConfigCard({ super.key, this.zoneName, this.zoneCode, this.channels = const [], + this.scope, this.isOfflineMode = false, + this.compact = false, }); @override Widget build(BuildContext context) { + if (compact) { + return _buildCompact(context); + } + // When offline mode is enabled, show "-" for zone fields final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; @@ -72,6 +80,16 @@ class RegionalConfigCard extends StatelessWidget { isOffline: isOfflineMode), const SizedBox(height: 12), + // Scope + _buildInfoRow( + context, + Icons.filter_alt, + 'Scope', + isOfflineMode ? '-' : (scope ?? 'Global'), + isOffline: isOfflineMode, + ), + const SizedBox(height: 12), + // Channels header _buildInfoRow(context, Icons.tag, 'RX Channels', null), const SizedBox(height: 8), @@ -96,6 +114,93 @@ class RegionalConfigCard extends StatelessWidget { ); } + /// Compact mode: "Regional Settings" header, scope row, channel chips + Widget _buildCompact(BuildContext context) { + final displayZone = isOfflineMode ? null : zoneName; + final displayScope = isOfflineMode ? '-' : (scope ?? 'Global'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.public, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Regional Settings', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (displayZone != null) + Text( + displayZone, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Scope row + _buildCompactRow(context, 'Scope', [ + _buildChannelChip(context, displayScope, isDefault: true), + ]), + const SizedBox(height: 8), + + // Channels row + _buildCompactRow(context, 'Channels', [ + _buildChannelChip(context, 'Public', isDefault: true), + _buildChannelChip(context, '#wardriving', isDefault: true), + if (!isOfflineMode) + ...channels.map((c) => _buildChannelChip(context, c)), + ]), + ], + ), + ), + ); + } + + /// Compact labeled row: small label on left, chips on right + Widget _buildCompactRow(BuildContext context, String label, List chips) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 70, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: chips, + ), + ), + ], + ); + } + Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( @@ -118,8 +223,12 @@ class RegionalConfigCard extends StatelessWidget { } Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { - // Public channel doesn't use # prefix + // Public channel doesn't use # prefix; scope/plain values pass through as-is final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + // If it doesn't look like a channel name, show raw value (e.g. scope "Global") + final isChannel = name.startsWith('#') || name == 'Public'; + final label = isChannel ? displayName : name; + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -134,11 +243,10 @@ class RegionalConfigCard extends StatelessWidget { ), ), child: Text( - displayName, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - // Use onPrimaryContainer for proper contrast on primaryContainer background color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, ), ), diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index b6c61bae..0088da57 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -10,11 +10,11 @@ import '../utils/distance_formatter.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// -/// Displays the 2-char hex repeater ID in monospace style. Use together with -/// [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so the -/// entire row is the tap target. +/// Displays the hex repeater ID (2/4/6 chars) in monospace style. Use together +/// with [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so +/// the entire row is the tap target. class RepeaterIdChip extends StatelessWidget { - /// The 2-char hex repeater ID (e.g., "4e") + /// The hex repeater ID (e.g., "4E", "4F5D", "4F5D82") final String repeaterId; /// Font size for the ID text (11 for log screens, 13 for map popups) @@ -32,13 +32,22 @@ class RepeaterIdChip extends StatelessWidget { @override Widget build(BuildContext context) { + // Scale font size down for longer IDs + final effectiveFontSize = repeaterId.length > 4 + ? fontSize - 2.0 // 6-char IDs (3-byte) + : repeaterId.length > 2 + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) + final child = Row( mainAxisSize: MainAxisSize.min, children: [ Text( repeaterId, + softWrap: false, + overflow: TextOverflow.clip, style: TextStyle( - fontSize: fontSize, + fontSize: effectiveFontSize, fontWeight: FontWeight.w600, fontFamily: 'monospace', color: Theme.of(context).colorScheme.onSurface, @@ -124,10 +133,11 @@ class RepeaterIdChip extends StatelessWidget { }); } + final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, position: position)) + .map((r) => _buildRepeaterRow(context, r, position: position, regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -188,6 +198,7 @@ class RepeaterIdChip extends StatelessWidget { BuildContext context, Repeater repeater, { Position? position, + int? regionHopBytesOverride, }) { final isActive = repeater.isActive; final badgeColor = isActive ? Colors.green : Colors.grey; @@ -218,27 +229,8 @@ class RepeaterIdChip extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - // Colored circle badge - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: badgeColor, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - repeater.hexId.length >= 2 - ? repeater.hexId.substring(0, 2).toUpperCase() - : repeater.hexId.toUpperCase(), - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), - ), + // Colored badge — circle for short IDs, pill for longer + _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -302,4 +294,31 @@ class RepeaterIdChip extends StatelessWidget { ), ); } + + /// Build a hex ID badge — circle for 2-char, pill for longer IDs + static Widget _buildHexBadge(String displayId, Color color) { + final isLong = displayId.length > 2; + + return Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: isLong + ? const EdgeInsets.symmetric(horizontal: 5) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: displayId.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + } } diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart new file mode 100644 index 00000000..a10fa2da --- /dev/null +++ b/lib/widgets/repeater_picker_sheet.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; + +import '../models/repeater.dart'; +import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; +import '../utils/distance_formatter.dart'; + +/// Show a bottom sheet repeater picker and return the selected repeater. +Future showRepeaterPicker(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.95, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _RepeaterPickerBody( + scrollController: scrollController, + ), + ), + ); +} + +class _RepeaterPickerBody extends StatefulWidget { + final ScrollController scrollController; + + const _RepeaterPickerBody({required this.scrollController}); + + @override + State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); +} + +class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterAndSort(List repeaters, Position? position) { + List filtered; + if (_query.isNotEmpty) { + final q = _query.toLowerCase(); + filtered = repeaters + .where((r) => + r.name.toLowerCase().contains(q) || + r.hexId.toLowerCase().startsWith(q)) + .toList(); + } else { + filtered = List.of(repeaters); + } + + // Sort: active first, then by distance (if GPS), then alphabetically + filtered.sort((a, b) { + // Active first + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + // By distance if GPS available + if (position != null) { + final distA = GpsService.distanceBetween( + position.latitude, position.longitude, a.lat, a.lon, + ); + final distB = GpsService.distanceBetween( + position.latitude, position.longitude, b.lat, b.lon, + ); + return distA.compareTo(distB); + } + // Alphabetically + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + return filtered; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final repeaters = appState.repeaters; + final position = appState.currentPosition; + final isImperial = appState.preferences.isImperial; + final colorScheme = Theme.of(context).colorScheme; + + final filtered = _filterAndSort(repeaters, position); + + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.cell_tower, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Select Repeater', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or hex ID...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onChanged: (value) => setState(() => _query = value), + ), + ), + // Count label + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Showing ${filtered.length} of ${repeaters.length} repeaters', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + // List + Expanded( + child: filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + repeaters.isEmpty + ? 'No repeaters available' + : 'No repeaters match your search', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.builder( + controller: widget.scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final r = filtered[index]; + return _RepeaterTile( + repeater: r, + position: position, + isImperial: isImperial, + onTap: () => Navigator.pop(context, r), + ); + }, + ), + ), + ], + ); + } +} + +class _RepeaterTile extends StatelessWidget { + final Repeater repeater; + final Position? position; + final bool isImperial; + final VoidCallback onTap; + + const _RepeaterTile({ + required this.repeater, + required this.position, + required this.isImperial, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isActive = repeater.isActive; + final badgeColor = isActive ? Colors.green : Colors.grey; + + // Distance text + String? distanceText; + if (position != null) { + final meters = GpsService.distanceBetween( + position!.latitude, position!.longitude, repeater.lat, repeater.lon, + ); + if (meters < 1000) { + distanceText = formatMeters(meters, isImperial: isImperial); + } else { + distanceText = + formatKilometers(meters / 1000, isImperial: isImperial); + } + } + + // Always show 4-byte (8-char) hex ID for identification + final displayHex = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Hex badge + Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayHex, + style: TextStyle( + fontSize: displayHex.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 12), + // Name + distance + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + if (distanceText != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.near_me, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Text( + distanceText, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Active/Stale chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + child: Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index c0904ff6..563d28f6 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -57,7 +57,7 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -158,6 +158,9 @@ class _StatusBarState extends State { case 'disc': return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -262,6 +265,16 @@ class _StatusBarState extends State { const SizedBox(width: 8), + // Trace count chip (animated) + _AnimatedStatChip( + icon: Icons.route, + value: appState.pingStats.traceCount, + color: Colors.cyan, + onTap: () => _showInfoPopup(context, 'trace'), + ), + + const SizedBox(width: 8), + // Uploaded count chip (animated) _AnimatedStatChip( icon: Icons.cloud_done, diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart new file mode 100644 index 00000000..4ba980ea --- /dev/null +++ b/lib/widgets/upload_logs_dialog.dart @@ -0,0 +1,825 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/debug_file_logger.dart'; +import '../services/debug_submit_service.dart'; +import '../utils/constants.dart'; +import '../utils/debug_logger_io.dart'; + +/// Result of a log upload operation +class UploadLogsResult { + final bool success; + final int uploadedCount; + final int failedCount; + final String? errorMessage; + + const UploadLogsResult({ + required this.success, + this.uploadedCount = 0, + this.failedCount = 0, + this.errorMessage, + }); +} + +/// Bottom sheet for uploading debug logs with a mandatory description +class UploadLogsSheet extends StatefulWidget { + final AppStateProvider appState; + final ScrollController scrollController; + + const UploadLogsSheet({ + super.key, + required this.appState, + required this.scrollController, + }); + + @override + State createState() => _UploadLogsSheetState(); +} + +class _UploadLogsSheetState extends State { + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + + final Set _selectedLogFiles = {}; + bool _isSubmitting = false; + String? _errorMessage; + + // Progress tracking + double _progress = 0.0; + String _progressStatus = ''; + int? _currentFile; + int? _totalFiles; + + // Uploadable log files + List _availableLogFiles = []; + bool _isLoadingFiles = true; + + @override + void initState() { + super.initState(); + _loadUploadableFiles(); + } + + Future _loadUploadableFiles() async { + try { + final files = await widget.appState.prepareDebugLogsForUpload(); + if (mounted) { + setState(() { + _availableLogFiles = files; + _isLoadingFiles = false; + // Select all by default + _selectedLogFiles.addAll(files.map((f) => f.path)); + }); + } + } catch (e) { + debugError('[DEBUG] Failed to load uploadable files: $e'); + if (mounted) { + setState(() { + _availableLogFiles = []; + _isLoadingFiles = false; + }); + } + } + } + + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + + void _toggleFile(String path) { + setState(() { + if (_selectedLogFiles.contains(path)) { + _selectedLogFiles.remove(path); + } else { + _selectedLogFiles.add(path); + } + }); + } + + void _onProgressUpdate(BugReportProgress progress) { + if (mounted) { + setState(() { + _progress = progress.progress; + _progressStatus = progress.status; + _currentFile = progress.currentFile; + _totalFiles = progress.totalFiles; + }); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + if (_selectedLogFiles.isEmpty) { + setState(() { + _errorMessage = 'Please select at least one log file to upload'; + }); + return; + } + + // Warn user about GPS data in debug logs + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Location Data Warning'), + content: const Text( + 'Debug logs may contain your approximate GPS coordinates ' + 'from your wardriving session. This location history will ' + 'be included in the uploaded files.\n\n' + 'Do you want to continue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Continue'), + ), + ], + ), + ); + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + _progress = 0.0; + _progressStatus = 'Preparing logs...'; + _currentFile = null; + _totalFiles = null; + }); + + try { + // Rotate the current log file now that the user has committed to uploading + final freshFiles = await widget.appState.prepareDebugLogsForUpload(); + + // Build the upload list using the user's selection applied to the freshly rotated files. + // Selected paths from before rotation still match, plus any newly rotated file is included. + final selectedPaths = Set.from(_selectedLogFiles); + final filesToUpload = freshFiles + .where((f) => selectedPaths.contains(f.path)) + .toList(); + + // If the rotation produced a new file that wasn't in the original selection + // (i.e. the previously-active log that just got rotated), include it too + // since the user selected "all" initially and this file has new content. + final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + filesToUpload.addAll(newFiles); + } + + if (filesToUpload.isEmpty) { + if (mounted) { + setState(() { + _errorMessage = 'No log files to upload after preparation'; + _isSubmitting = false; + _progress = 0.0; + _progressStatus = ''; + }); + } + return; + } + + final service = DebugSubmitService(); + + final publicKey = widget.appState.devicePublicKey ?? + widget.appState.lastConnectedPublicKey ?? + 'not-connected'; + final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final userNotes = _descriptionController.text.trim(); + + int uploadedCount = 0; + int failedCount = 0; + final totalFiles = filesToUpload.length; + + for (int i = 0; i < totalFiles; i++) { + final file = filesToUpload[i]; + final progressBase = i / totalFiles; + final progressPerFile = 1.0 / totalFiles; + + _onProgressUpdate(BugReportProgress( + status: 'Uploading file ${i + 1} of $totalFiles...', + progress: progressBase, + currentFile: i + 1, + totalFiles: totalFiles, + )); + + final success = await service.uploadDebugFileOnly( + file: file, + deviceId: deviceName, + publicKey: publicKey, + appVersion: AppConstants.appVersion, + devicePlatform: DebugSubmitService.getDevicePlatform(), + userNotes: userNotes, + onProgress: (p) { + _onProgressUpdate(BugReportProgress( + status: p.status, + progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + currentFile: i + 1, + totalFiles: totalFiles, + )); + }, + ); + + if (success) { + uploadedCount++; + } else { + failedCount++; + } + } + + service.dispose(); + + if (!mounted) return; + + final result = UploadLogsResult( + success: uploadedCount > 0, + uploadedCount: uploadedCount, + failedCount: failedCount, + errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + ); + + Navigator.of(context).pop(result); + } catch (e) { + debugError('[DEBUG] Upload logs error: $e'); + if (mounted) { + setState(() { + _errorMessage = 'Error: $e'; + _isSubmitting = false; + _progress = 0.0; + _progressStatus = ''; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isSubmitting) { + return _buildProgressView(theme); + } + + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Text('Upload Logs', style: theme.textTheme.titleLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + tooltip: 'Close', + ), + ], + ), + ), + + const Divider(height: 1), + + // Scrollable content + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.opaque, + child: Form( + key: _formKey, + child: ListView( + controller: widget.scrollController, + padding: const EdgeInsets.all(20), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Explanation text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Upload your debug logs directly to the MeshMapper developers. ' + 'This helps us diagnose issues and improve the app.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Description field (mandatory) + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Briefly describe why you\'re uploading these logs...', + alignLabelWithHint: true, + ), + maxLines: 3, + maxLength: 500, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'A description is required'; + } + if (value.trim().length < 10) { + return 'Please provide more detail (at least 10 characters)'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Log files section + _buildSectionLabel(theme, Icons.folder_open, 'Log Files'), + const SizedBox(height: 8), + + if (_isLoadingFiles) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else if (_availableLogFiles.isEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + 'No log files available to upload', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + // Select all / deselect all header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + '${_selectedLogFiles.length} of ${_availableLogFiles.length} selected', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setState(() { + if (_selectedLogFiles.length == _availableLogFiles.length) { + _selectedLogFiles.clear(); + } else { + _selectedLogFiles.clear(); + _selectedLogFiles.addAll( + _availableLogFiles.map((f) => f.path), + ); + } + }); + }, + child: Text( + _selectedLogFiles.length == _availableLogFiles.length + ? 'Deselect All' + : 'Select All', + ), + ), + ], + ), + ), + Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + // File list + ...List.generate(_availableLogFiles.length, (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = _selectedLogFiles.contains(file.path); + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), + ), + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + onTap: _isSubmitting ? null : () => _toggleFile(file.path), + ); + }), + ], + ), + ), + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ), + ], + + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), + ), + ), + ), + + // Sticky bottom action bar + Container( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + MediaQuery.of(context).padding.bottom + 12, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _isSubmitting || _availableLogFiles.isEmpty + ? null + : _submit, + icon: const Icon(Icons.cloud_upload, size: 18), + label: Text( + _selectedLogFiles.isEmpty + ? 'Upload' + : 'Upload ${_selectedLogFiles.length} Log${_selectedLogFiles.length == 1 ? '' : 's'}', + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildProgressView(ThemeData theme) { + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Text('Uploading...', style: theme.textTheme.titleLarge), + ], + ), + ), + + const Divider(height: 1), + + // Progress content + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + color: theme.colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 32), + + Text( + _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + if (_totalFiles != null && _currentFile != null) + Text( + 'File $_currentFile of $_totalFiles', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: 250, + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + color: theme.colorScheme.primary, + minHeight: 8, + ), + ), + const SizedBox(height: 8), + Text( + '${(_progress * 100).toInt()}%', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + Padding( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + MediaQuery.of(context).padding.bottom + 12, + ), + child: Text( + 'Please don\'t close this screen', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } + + Widget _buildSectionLabel(ThemeData theme, IconData icon, String label) { + return Row( + children: [ + Icon(icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + InputDecoration _buildInputDecoration( + ThemeData theme, { + String? hintText, + bool alignLabelWithHint = false, + }) { + return InputDecoration( + hintText: hintText, + alignLabelWithHint: alignLabelWithHint, + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.error, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ); + } +} + +/// Show the upload logs dialog and return the result +Future showUploadLogsDialog( + BuildContext context, + AppStateProvider appState, +) async { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => UploadLogsSheet( + appState: appState, + scrollController: scrollController, + ), + ), + ); +} From e4636731c0aca90cf1dfc16f7701400ed4fef93d Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:08:52 -0400 Subject: [PATCH 036/360] Revert "v1.2.0 Release merge (#13)" (#14) This reverts commit da9e08fad6eaab64822eeb58cdbc1ab07275a7bf. --- .build_version | 2 +- .gitignore | 6 +- .vscode/tasks.json | 21 + CODE_OF_CONDUCT.md | 104 - DEVELOPMENT.md | 13 +- SECURITY.md | 62 - lib/main.dart | 13 +- lib/models/api_queue_item.dart | 81 +- lib/models/log_entry.dart | 134 +- lib/models/noise_floor_session.dart | 11 - lib/models/ping_data.dart | 12 +- lib/models/repeater.dart | 17 - lib/models/user_preferences.dart | 88 +- lib/providers/app_state_provider.dart | 1399 ++---------- lib/screens/connection_screen.dart | 1061 ++++----- lib/screens/graph_screen.dart | 5 +- lib/screens/home_screen.dart | 31 +- lib/screens/log_screen.dart | 1476 +++++++------ lib/screens/main_scaffold.dart | 66 +- lib/screens/settings_screen.dart | 1930 ++++++----------- lib/services/api_queue_service.dart | 270 +-- lib/services/api_service.dart | 308 +-- lib/services/background_service.dart | 2 - lib/services/bluetooth/mobile_bluetooth.dart | 29 +- lib/services/gps_service.dart | 30 +- lib/services/meshcore/connection.dart | 127 +- lib/services/meshcore/crypto_service.dart | 14 - lib/services/meshcore/disc_tracker.dart | 20 +- lib/services/meshcore/packet_metadata.dart | 129 +- lib/services/meshcore/packet_validator.dart | 31 +- lib/services/meshcore/protocol_constants.dart | 2 - lib/services/meshcore/rx_logger.dart | 89 +- lib/services/meshcore/trace_tracker.dart | 211 -- lib/services/meshcore/tx_tracker.dart | 91 +- lib/services/meshcore/unified_rx_handler.dart | 23 +- lib/services/offline_session_service.dart | 75 - lib/services/ping_service.dart | 386 +--- lib/utils/debug_logger_stub.dart | 4 +- lib/widgets/map_widget.dart | 1371 ++++-------- lib/widgets/noise_floor_chart.dart | 7 +- lib/widgets/offline_mode_toggle.dart | 183 -- lib/widgets/ping_controls.dart | 790 ++++--- lib/widgets/regional_config_card.dart | 114 +- lib/widgets/repeater_id_chip.dart | 73 +- lib/widgets/repeater_picker_sheet.dart | 341 --- lib/widgets/status_bar.dart | 15 +- lib/widgets/upload_logs_dialog.dart | 825 ------- 47 files changed, 3295 insertions(+), 8797 deletions(-) create mode 100644 .vscode/tasks.json delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 SECURITY.md delete mode 100644 lib/services/meshcore/trace_tracker.dart delete mode 100644 lib/widgets/offline_mode_toggle.dart delete mode 100644 lib/widgets/repeater_picker_sheet.dart delete mode 100644 lib/widgets/upload_logs_dialog.dart diff --git a/.build_version b/.build_version index 26aaba0e..7dea76ed 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.2.0 +1.0.1 diff --git a/.gitignore b/.gitignore index d7c37fee..25d86f82 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,10 @@ migrate_working_dir/ *.iws .idea/ -.vscode/ +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ # Flutter/Dart/Pub related **/doc/api/ @@ -72,7 +75,6 @@ ios/Flutter/flutter_export_environment.sh *.g.dart # Debug logs -debug/ debuglog/ meshmapper-debug-*.txt diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..b7bd3c4d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Flutter: Build & Serve Web", + "type": "shell", + "command": " + ", + "isBackground": true, + "problemMatcher": [], + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false + } + } + ] +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 89cf71ba..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,104 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a welcoming experience for everyone, regardless of background or -identity. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment: - -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members - -Examples of unacceptable behavior: - -- Trolling, insulting or derogatory comments, and personal attacks -- Public or private harassment of any kind -- Publishing others' private information without explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. - -## Enforcement - -Instances of unacceptable behavior may be reported to the project maintainers at -the [MeshMapper Project issue tracker](https://github.com/MeshMapper/MeshMapper_Project/issues). - -All complaints will be reviewed and investigated promptly and fairly. All -community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels. -Violating these terms may lead to a temporary or permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, or targeted conduct -against an individual or group. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7b1ad2d..ac712180 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -295,21 +295,10 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s ### Packet Structure - Custom binary protocol with header byte (0x11 = GROUP_TEXT, 0x21 = ADVERT) -- Path encoding: `pathLen` byte encodes hash size (top 2 bits) + hop count (bottom 6 bits), followed by `hopCount * hashSize` path bytes - - `pathHashSize = (pathLen >> 6) + 1` → 1, 2, 3, or 4 bytes per hop - - `pathHashCount = pathLen & 63` → 0-63 hops +- Path encoding: hop count + repeater IDs (4 bytes each) - SNR/RSSI metadata in BLE event payload - Encrypted message payload (AES-ECB with channel key) -### Multi-Byte Path Support (v1.14.0+) -- **Purpose**: Expands repeater ID space from 256 (1-byte) to 65K (2-byte) or 16M (3-byte) unique IDs -- **TX mode**: Configured via `CMD_SET_PATH_HASH_MODE = 61 (0x3D)` — `[0x3D][0x00][mode]` where mode=0→1-byte, 1→2-byte, 2→3-byte -- **RX auto-detect**: Each received packet's `pathLen` byte is decoded to determine hash size, regardless of the user's TX setting -- **DeviceInfo**: v10+ firmware includes `path_hash_mode` byte after manufacturer + firmware version fields -- **API enforcement**: Auth response may include `hop_bytes` (1/2/3) to enforce regional path byte size -- **Lifecycle**: Radio mode is set during connection and restored to original on clean disconnect. Unclean disconnect leaves radio in configured mode. -- **Discovery pings**: NOT affected — multi-byte paths apply only to TX/RX channel messages - ## Platform-Specific Notes ### Web (Chrome/Edge only) diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 812bab3d..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,62 +0,0 @@ -# Security Policy - -## Supported Versions - -| Version | Supported | -| ------- | --------- | -| Latest | Yes | - -Only the latest release on the `main` branch receives security updates. - -## Reporting a Vulnerability - -If you discover a security vulnerability in MeshMapper Flutter App, please report it responsibly. **Do not open a public GitHub issue for security vulnerabilities.** - -### How to Report - -1. Open a **private security advisory** at: - https://github.com/MeshMapper/MeshMapper_Project/security/advisories/new - -2. Include the following information: - - Description of the vulnerability - - Steps to reproduce - - Potential impact - - Suggested fix (if any) - -### What to Expect - -- **Acknowledgment**: We will acknowledge receipt of your report within 72 hours. -- **Assessment**: We will assess the severity and impact of the vulnerability. -- **Fix timeline**: Critical issues will be addressed as quickly as possible. We aim to release a fix within 30 days of confirmation. -- **Disclosure**: We will coordinate with you on public disclosure timing after a fix is available. - -## Security Considerations - -### API Key Handling - -- API keys are injected at build time via `--dart-define=API_KEY=...` and are **never** hardcoded in source code. - -### Bluetooth Security - -- The app communicates with MeshCore devices over Bluetooth Low Energy (BLE) using the MeshCore companion protocol. -- Channel messages are encrypted using AES-ECB with SHA-256 derived channel keys (encryption mode is dictated by the MeshCore protocol). - -### Data Privacy - -- GPS location data is sent to the MeshMapper API for community mesh coverage mapping. -- Users must be within a valid geographic zone (server-side validation) to submit data. -- No personal information beyond device name/public key and location data is transmitted. - -## Scope - -The following are **in scope** for security reports: - -- Vulnerabilities in the Flutter application code -- API key or secret exposure -- Authentication or session management issues -- Data leakage or privacy concerns - -The following are **out of scope**: - -- Vulnerabilities in third-party dependencies (report to the upstream project) -- Issues with the MeshCore firmware or radio protocol (report to the MeshCore project) \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 08ef6efb..f29e80eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,14 +15,15 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; +import 'utils/constants.dart'; import 'utils/debug_logger_io.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Enable debug file logging FIRST on mobile to capture early logs + // Enable debug file logging FIRST on mobile for dev builds // This must happen before DebugLogger.initialize() to capture early logs - if (!kIsWeb) { + if (!kIsWeb && AppConstants.isDevelopmentBuild) { await DebugFileLogger.enable(); } @@ -247,14 +248,6 @@ class _ThemedAppState extends State<_ThemedApp> { return MaterialApp( title: 'MeshMapper', - builder: (context, child) { - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.noScaling, - ), - child: child!, - ); - }, theme: ThemeData( colorScheme: lightColorScheme, scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 337cab1e..f2e4bcd8 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -44,7 +44,7 @@ class ApiQueueItem extends HiveObject { final String heardRepeats; /// Earliest time this item can be uploaded (milliseconds since epoch) - /// All items are immediate; upload timing is controlled by flush timers + /// TX items have 5-second delay; RX/DISC are immediate @HiveField(13) final int canUploadAfter; @@ -81,7 +81,7 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing + canUploadAfter: DateTime.now().millisecondsSinceEpoch + 5000, // 5 seconds from now externalAntenna: externalAntenna, noiseFloor: noiseFloor, ); @@ -138,87 +138,10 @@ class ApiQueueItem extends HiveObject { ); } - /// Create from a successful TRACE ping (targeted zero-hop trace) - /// heardRepeats format: "repeaterId:localSnr:localRssi:remoteSnr" - factory ApiQueueItem.fromTrace({ - required double latitude, - required double longitude, - required String repeaterId, - required double localSnr, - required int localRssi, - required double remoteSnr, - required int timestamp, - required bool externalAntenna, - int? noiseFloor, - }) { - final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; - return ApiQueueItem( - type: 'TRACE', - latitude: latitude, - longitude: longitude, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate - externalAntenna: externalAntenna, - noiseFloor: noiseFloor, - ); - } - - /// Create from a failed DISC discovery (no nodes responded) - factory ApiQueueItem.fromDiscDrop({ - required double latitude, - required double longitude, - required int timestamp, - required bool externalAntenna, - int? noiseFloor, - }) { - return ApiQueueItem( - type: 'DISC', - latitude: latitude, - longitude: longitude, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - heardRepeats: 'None', - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate - externalAntenna: externalAntenna, - noiseFloor: noiseFloor, - ); - } - /// Convert to API JSON format (matches WebClient exactly) Map toApiJson() { - // For TRACE type, parse the heardRepeats field to extract individual values - if (type == 'TRACE') { - // Format: "repeaterId:localSnr:localRssi:remoteSnr" - final parts = heardRepeats.split(':'); - return { - 'type': type, - 'lat': latitude, - 'lon': longitude, - 'noisefloor': noiseFloor, - 'repeater_id': parts.isNotEmpty ? parts[0] : '', - 'local_snr': parts.length > 1 ? double.tryParse(parts[1]) : null, - 'local_rssi': parts.length > 2 ? int.tryParse(parts[2]) : null, - 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, - 'external_antenna': externalAntenna, - }; - } - // For DISC type, parse the heardRepeats field to extract individual values if (type == 'DISC') { - // Failed discovery (no nodes responded) - if (heardRepeats == 'None') { - return { - 'type': type, - 'lat': latitude, - 'lon': longitude, - 'noisefloor': noiseFloor, - 'repeater_id': 'None', - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, - 'external_antenna': externalAntenna, - }; - } - // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final parts = heardRepeats.split(':'); return { diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 26429beb..758d4abe 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,7 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); + : events.map((e) => '${e.repeaterId}(${e.snr.toStringAsFixed(2)})').join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,23 +39,21 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) - final int? rssi; // RSSI in dBm (null for CARpeater pass-through) + final double snr; // Signal-to-noise ratio in dB + final int rssi; // RSSI in dBm RxEvent({ required this.repeaterId, - this.snr, - this.rssi, + required this.snr, + this.rssi = 0, }); /// Get SNR color severity (red, orange, green) - /// Returns null for CARpeater pass-through (no signal data) /// Reference: getSnrSeverityClass() in wardrive.js - SnrSeverity? get severity { - if (snr == null) return null; - if (snr! <= -1) { + SnrSeverity get severity { + if (snr <= -1) { return SnrSeverity.poor; // Red: -12 to -1 dB - } else if (snr! <= 5) { + } else if (snr <= 5) { return SnrSeverity.fair; // Orange: 0 to 5 dB } else { return SnrSeverity.good; // Green: 6 to 13+ dB @@ -68,8 +66,8 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) - final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) + final double snr; // Signal-to-noise ratio in dB + final int rssi; // Received signal strength indicator in dBm final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -78,8 +76,8 @@ class RxLogEntry { RxLogEntry({ required this.timestamp, required this.repeaterId, - this.snr, - this.rssi, + required this.snr, + required this.rssi, required this.pathLength, required this.header, required this.latitude, @@ -99,12 +97,10 @@ class RxLogEntry { } /// Get SNR color severity - /// Returns null for CARpeater pass-through (no signal data) - SnrSeverity? get severity { - if (snr == null) return null; - if (snr! <= -1) { + SnrSeverity get severity { + if (snr <= -1) { return SnrSeverity.poor; - } else if (snr! <= 5) { + } else if (snr <= 5) { return SnrSeverity.fair; } else { return SnrSeverity.good; @@ -113,67 +109,12 @@ class RxLogEntry { /// Get CSV row String toCsv() { - return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' + return '${timestamp.toIso8601String()},$repeaterId,$snr,$rssi,' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' '$latitude,$longitude'; } } -/// Trace Log Entry (targeted zero-hop trace result) -class TraceLogEntry { - final DateTime timestamp; - final double latitude; - final double longitude; - final String targetRepeaterId; - final int? noiseFloor; - final double? localSnr; - final double? remoteSnr; - final int? localRssi; - final bool success; - - TraceLogEntry({ - required this.timestamp, - required this.latitude, - required this.longitude, - required this.targetRepeaterId, - this.noiseFloor, - this.localSnr, - this.remoteSnr, - this.localRssi, - required this.success, - }); - - /// Get formatted timestamp (HH:MM:SS) - String get timeString { - return '${timestamp.hour.toString().padLeft(2, '0')}:' - '${timestamp.minute.toString().padLeft(2, '0')}:' - '${timestamp.second.toString().padLeft(2, '0')}'; - } - - /// Get formatted location (5 decimal places) - String get locationString { - return '${latitude.toStringAsFixed(5)},${longitude.toStringAsFixed(5)}'; - } - - /// Get SNR color severity based on local SNR - SnrSeverity? get severity { - if (localSnr == null) return null; - if (localSnr! <= -1) { - return SnrSeverity.poor; - } else if (localSnr! <= 5) { - return SnrSeverity.fair; - } else { - return SnrSeverity.good; - } - } - - /// Get CSV row - String toCsv() { - return '${timestamp.toIso8601String()},$targetRepeaterId,${localSnr ?? 'null'},${localRssi ?? 'null'},' - '${remoteSnr ?? 'null'},$latitude,$longitude,${noiseFloor ?? ''},$success'; - } -} - /// SNR Severity levels for color coding enum SnrSeverity { poor, // Red: SNR ≤ -1 dB @@ -181,47 +122,6 @@ enum SnrSeverity { good, // Green: SNR > 5 dB } -/// Ping type for unified log view -enum PingLogType { tx, rx, disc, trace } - -/// Wrapper for unified chronological ping log view -class UnifiedPingLogEntry implements Comparable { - final PingLogType type; - final DateTime timestamp; - final dynamic entry; - - UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); - - TxLogEntry get asTx => entry as TxLogEntry; - RxLogEntry get asRx => entry as RxLogEntry; - DiscLogEntry get asDisc => entry as DiscLogEntry; - TraceLogEntry get asTrace => entry as TraceLogEntry; - - @override - int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); - - String get timeString => switch (type) { - PingLogType.tx => asTx.timeString, - PingLogType.rx => asRx.timeString, - PingLogType.disc => asDisc.timeString, - PingLogType.trace => asTrace.timeString, - }; - - String get locationString => switch (type) { - PingLogType.tx => asTx.locationString, - PingLogType.rx => asRx.locationString, - PingLogType.disc => asDisc.locationString, - PingLogType.trace => asTrace.locationString, - }; - - String toCsv() => switch (type) { - PingLogType.tx => 'TX,${asTx.toCsv()}', - PingLogType.rx => 'RX,${asRx.toCsv()}', - PingLogType.disc => 'DISC,${asDisc.toCsv()}', - PingLogType.trace => 'TRC,${asTrace.toCsv()}', - }; -} - /// User Error Entry for error log class UserErrorEntry { final DateTime timestamp; @@ -297,7 +197,7 @@ class DiscLogEntry { /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") final String nodeType; // "REPEATER" or "ROOM" final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 9c6b574d..a9212162 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -35,12 +35,6 @@ enum PingEventType { @HiveField(4) discFail, // Grey: Discovery no response - - @HiveField(5) - traceSuccess, // Cyan: Trace got response - - @HiveField(6) - traceFail, // Grey: Trace no response } /// Repeater info for graph markers @@ -105,8 +99,6 @@ class PingEventMarker extends HiveObject { PingEventType.rx => Colors.blue, PingEventType.discSuccess => Colors.purple, PingEventType.discFail => Colors.grey, - PingEventType.traceSuccess => Colors.cyan, - PingEventType.traceFail => Colors.grey, }; /// Get a display label for this event type @@ -116,8 +108,6 @@ class PingEventMarker extends HiveObject { PingEventType.rx => 'RX', PingEventType.discSuccess => 'DISC Success', PingEventType.discFail => 'DISC Fail', - PingEventType.traceSuccess => 'Trace Success', - PingEventType.traceFail => 'Trace Fail', }; } @@ -164,7 +154,6 @@ class NoiseFloorSession extends HiveObject { String get modeDisplay => switch (mode) { 'active' => 'Active Mode', 'hybrid' => 'Hybrid Mode', - 'targeted' => 'Trace Mode', _ => 'Passive Mode', }; diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 0e42d087..2425d603 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -111,14 +111,14 @@ class RxPing { /// Repeater that heard a TX ping (from echo tracking) class HeardRepeater { final String repeaterId; // Hex ID (e.g., "4e", "77") - final double? snr; // Best SNR observed (null for CARpeater pass-through) - final int? rssi; // RSSI in dBm (null for CARpeater pass-through) + final double snr; // Best SNR observed + final int rssi; // RSSI in dBm final int seenCount; // How many times this repeater was heard const HeardRepeater({ required this.repeaterId, - this.snr, - this.rssi, + required this.snr, + this.rssi = 0, this.seenCount = 1, }); } @@ -139,7 +139,6 @@ class PingStats { final int txCount; final int rxCount; final int discCount; // Discovery count (Passive Mode) - final int traceCount; // Trace count (Targeted Mode) final int successfulUploads; final int failedUploads; final int queuedCount; @@ -148,7 +147,6 @@ class PingStats { this.txCount = 0, this.rxCount = 0, this.discCount = 0, - this.traceCount = 0, this.successfulUploads = 0, this.failedUploads = 0, this.queuedCount = 0, @@ -158,7 +156,6 @@ class PingStats { int? txCount, int? rxCount, int? discCount, - int? traceCount, int? successfulUploads, int? failedUploads, int? queuedCount, @@ -167,7 +164,6 @@ class PingStats { txCount: txCount ?? this.txCount, rxCount: rxCount ?? this.rxCount, discCount: discCount ?? this.discCount, - traceCount: traceCount ?? this.traceCount, successfulUploads: successfulUploads ?? this.successfulUploads, failedUploads: failedUploads ?? this.failedUploads, queuedCount: queuedCount ?? this.queuedCount, diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index bbf76bd1..cdf1867b 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -34,9 +34,6 @@ class Repeater { /// The repeater is active while `now < staleTime`. final int? staleTime; - /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) - final int hopBytes; - const Repeater({ required this.id, required this.hexId, @@ -48,7 +45,6 @@ class Repeater { this.iata, this.createdAt, this.staleTime, - this.hopBytes = 1, }); /// Parse from JSON object in repeaters.json @@ -82,7 +78,6 @@ class Repeater { iata: json['iata'] as String?, createdAt: createdAt, staleTime: staleTime, - hopBytes: (json['hop_bytes'] as int?) ?? 1, ); } @@ -98,7 +93,6 @@ class Repeater { 'iata': iata, 'created_at': createdAt, 'stale_time': staleTime, - 'hop_bytes': hopBytes, }; } @@ -136,17 +130,6 @@ class Repeater { /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; - /// Get display hex ID based on hop bytes (or override). - /// [overrideHopBytes] is used when regional admin enforces a byte size. - String displayHexId({int? overrideHopBytes}) { - final bytes = overrideHopBytes ?? hopBytes; - final hexChars = bytes * 2; // 1 byte = 2 hex chars - if (hexId.length >= hexChars) { - return hexId.substring(0, hexChars).toUpperCase(); - } - return id; // Fallback to short numeric ID - } - @override String toString() => 'Repeater(id=$id, name=$name, enabled=$isEnabled)'; } diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 4063fc8b..3eb4f576 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -64,27 +64,6 @@ class UserPreferences { /// Map rotation lock (disable rotation gestures) final bool mapRotationLocked; - /// Disable RSSI carpeater filter (allow all signal strengths) - final bool disableRssiFilter; - - /// Anonymous mode: rename companion to "Anonymous" during wardriving - final bool anonymousMode; - - /// Discovery drop: count failed discoveries as failed pings and report to API - final bool discDropEnabled; - - /// Delete wardriving channel from radio on disconnect - final bool deleteChannelOnDisconnect; - - /// Minimum ping distance in meters (25m floor, user can increase) - final int minPingDistanceMeters; - - /// Auto-stop auto-ping after 30 minutes of idle (no movement) - final bool autoStopAfterIdle; - - /// Show top 3 repeaters by SNR on the map during wardriving - final bool showTopRepeaters; - const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -103,17 +82,10 @@ class UserPreferences { this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', - this.hybridModeEnabled = true, + this.hybridModeEnabled = false, this.mapAutoFollow = false, this.mapAlwaysNorth = true, this.mapRotationLocked = false, - this.disableRssiFilter = false, - this.anonymousMode = false, - this.discDropEnabled = false, - this.deleteChannelOnDisconnect = true, - this.minPingDistanceMeters = 25, - this.autoStopAfterIdle = true, - this.showTopRepeaters = false, }); /// Create from JSON (for persistence) @@ -136,17 +108,10 @@ class UserPreferences { closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', - hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, + hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? false, mapAutoFollow: (json['mapAutoFollow'] as bool?) ?? false, mapAlwaysNorth: (json['mapAlwaysNorth'] as bool?) ?? true, mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, - disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, - anonymousMode: (json['anonymousMode'] as bool?) ?? false, - discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, - deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, - minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, - autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, - showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, ); } @@ -174,13 +139,6 @@ class UserPreferences { 'mapAutoFollow': mapAutoFollow, 'mapAlwaysNorth': mapAlwaysNorth, 'mapRotationLocked': mapRotationLocked, - 'disableRssiFilter': disableRssiFilter, - 'anonymousMode': anonymousMode, - 'discDropEnabled': discDropEnabled, - 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, - 'minPingDistanceMeters': minPingDistanceMeters, - 'autoStopAfterIdle': autoStopAfterIdle, - 'showTopRepeaters': showTopRepeaters, }; } @@ -207,13 +165,6 @@ class UserPreferences { bool? mapAutoFollow, bool? mapAlwaysNorth, bool? mapRotationLocked, - bool? disableRssiFilter, - bool? anonymousMode, - bool? discDropEnabled, - bool? deleteChannelOnDisconnect, - int? minPingDistanceMeters, - bool? autoStopAfterIdle, - bool? showTopRepeaters, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -237,13 +188,6 @@ class UserPreferences { mapAutoFollow: mapAutoFollow ?? this.mapAutoFollow, mapAlwaysNorth: mapAlwaysNorth ?? this.mapAlwaysNorth, mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, - disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, - anonymousMode: anonymousMode ?? this.anonymousMode, - discDropEnabled: discDropEnabled ?? this.discDropEnabled, - deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, - minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, - autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, - showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, ); } @@ -269,9 +213,6 @@ class UserPreferences { return '$autoPingInterval seconds'; } - /// Get min ping distance display string - String get minPingDistanceDisplay => '${minPingDistanceMeters}m'; - @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -295,19 +236,12 @@ class UserPreferences { other.hybridModeEnabled == hybridModeEnabled && other.mapAutoFollow == mapAutoFollow && other.mapAlwaysNorth == mapAlwaysNorth && - other.mapRotationLocked == mapRotationLocked && - other.disableRssiFilter == disableRssiFilter && - other.anonymousMode == anonymousMode && - other.discDropEnabled == discDropEnabled && - other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && - other.minPingDistanceMeters == minPingDistanceMeters && - other.autoStopAfterIdle == autoStopAfterIdle && - other.showTopRepeaters == showTopRepeaters; + other.mapRotationLocked == mapRotationLocked; } @override int get hashCode { - return Object.hashAll([ + return Object.hash( powerLevel, txPower, externalAntenna, @@ -328,14 +262,7 @@ class UserPreferences { mapAutoFollow, mapAlwaysNorth, mapRotationLocked, - disableRssiFilter, - anonymousMode, - discDropEnabled, - deleteChannelOnDisconnect, - minPingDistanceMeters, - autoStopAfterIdle, - showTopRepeaters, - ]); + ); } /// Check if using imperial units @@ -369,8 +296,3 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } - -/// Minimum ping distance (meters) -class MinPingDistance { - static const int min = 25; -} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 745b96f0..a91f16ff 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -29,7 +29,6 @@ import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; -import '../services/meshcore/crypto_service.dart'; import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; @@ -48,13 +47,8 @@ enum AutoMode { passive, /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, - /// Trace Mode: Zero-hop trace to specific repeater - targeted, } -/// Ping type for the top-heard overlay dots -enum OverlayPingType { tx, disc, trace, rx } - /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully @@ -67,8 +61,6 @@ enum OfflineUploadResult { authFailed, /// Some pings failed to upload partialFailure, - /// Another upload is already in progress - uploadInProgress, } /// Main application state provider @@ -107,14 +99,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; bool _isAuthError = false; // Track if connection failed due to auth - bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; StreamSubscription? _adapterStateSubscription; - StreamSubscription? _connectionSubscription; - StreamSubscription? _gpsStatusSubscription; - StreamSubscription? _gpsPositionSubscription; // GPS state GpsStatus _gpsStatus = GpsStatus.permissionDenied; @@ -126,9 +114,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Device info DeviceModel? _deviceModel; String? _manufacturerString; - String? _firmwareVersionString; String? _devicePublicKey; - String? _offlineContactUri; /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; @@ -145,8 +131,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { PingStats _pingStats = const PingStats(); bool _autoPingEnabled = false; AutoMode _autoMode = AutoMode.active; - DateTime? _idleAutoStopReference; - static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked int _queueSize = 0; int? _currentNoiseFloor; @@ -169,15 +153,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _txLogEntries = []; final List _rxLogEntries = []; final List _discLogEntries = []; - final List _traceLogEntries = []; - - // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; - ({String repeaterId, double snr})? _rxOverlaySlot; - Timer? _rxOverlayWindowTimer; - - // Targeted mode state - String? _targetRepeaterId; // User error log entries final List _errorLogEntries = []; @@ -185,10 +160,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // User preferences UserPreferences _preferences = const UserPreferences(); - // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" - /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -196,13 +167,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _antennaRestoredFromDevice = false; bool get antennaRestoredFromDevice => _antennaRestoredFromDevice; - /// Per-device power overrides: maps companion name → {powerLevel, txPower} - Map> _devicePowerOverrides = {}; - - /// Whether the current power setting was auto-restored from a saved override - bool _powerRestoredFromDevice = false; - bool get powerRestoredFromDevice => _powerRestoredFromDevice; - // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; @@ -235,10 +199,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; - // Tile refresh after upload - int _overlayCacheBust = 0; - Timer? _tileRefreshTimer; - // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -252,12 +212,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; - Timer? _restoreAutoPingTimer; - Timer? _offlineAutoSaveTimer; - Timer? _zoneRefreshTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; - int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); @@ -266,7 +222,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab - bool _requestConnectionTabSwitch = false; // Request switch to connection tab // Repeater markers state List _repeaters = []; @@ -276,15 +231,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels from API (for UI display) List _regionalChannels = []; - // Regional scope from API (for UI display and flood filtering) - String? _scope; - - // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) - int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) - // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; List _storedNoiseFloorSessions = []; @@ -306,12 +252,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); - } else if (state == AppLifecycleState.paused) { - debugLog('[APP] App paused (backgrounded)'); - // Save offline pings immediately on pause to prevent data loss if OS kills app - if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { - _autoSaveOfflinePings(); - } } } @@ -325,7 +265,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; bool get isAuthError => _isAuthError; - bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; @@ -334,7 +273,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; - String? get firmwareVersionString => _firmwareVersionString; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -346,9 +284,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isPendingDisable => _pingService?.pendingDisable ?? false; /// True when running any mode that does TX (Active or Hybrid) bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); - /// True when running Trace Mode (zero-hop trace) - bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; - String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; int? get currentBatteryPercent => _currentBatteryPercent; @@ -356,71 +291,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isScanning => _isScanning; List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); - - /// Top 3 repeaters by best SNR from TX/DISC/Trace pings - List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; - /// Best RX observation in the current 5-second window - ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; - - /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. - /// Replaces all 3 slots entirely (no carryover from previous pings). - void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { - final bestSnr = {}; - for (final r in current) { - final key = r.repeaterId.toUpperCase(); - if (!bestSnr.containsKey(key) || r.snr > bestSnr[key]!) { - bestSnr[key] = r.snr; - } - } - final fresh = bestSnr.entries - .map((e) => (repeaterId: e.key, snr: e.value, type: type)) - .toList() - ..sort((a, b) => b.snr.compareTo(a.snr)); - _topRepeatersOverlay = fresh.take(3).toList(); - } - - /// Update the RX overlay slot with a 5-second rolling window (best SNR wins). - void _updateRxOverlaySlot(String repeaterId, double snr) { - final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); - if (_rxOverlayWindowTimer?.isActive ?? false) { - if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { - _rxOverlaySlot = entry; - } - } else { - _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(const Duration(seconds: 5), () { - // Window closed — slot stays until next RX or cleared - }); - } - } - - /// Clear all overlay state (top 3 + RX slot). - void _clearOverlayState() { - _topRepeatersOverlay = []; - _rxOverlaySlot = null; - _rxOverlayWindowTimer?.cancel(); - _rxOverlayWindowTimer = null; - } List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get traceLogEntries => List.unmodifiable(_traceLogEntries); List get errorLogEntries => List.unmodifiable(_errorLogEntries); - List get unifiedPingLogEntries { - final merged = [ - ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), - ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), - ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), - ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), - ]; - merged.sort(); - return merged; - } ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; - bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -440,7 +318,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; - int get overlayCacheBust => _overlayCacheBust; int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -464,9 +341,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isSwitchingMode => _isSwitchingMode; String? get modeSwitchError => _modeSwitchError; - // Anonymous mode getter - bool get isAnonymousRenamed => _isAnonymousRenamed; - // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; @@ -477,9 +351,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); - // Regional scope getter (for UI) - String? get scope => _scope; - // Noise floor session getters NoiseFloorSession? get currentNoiseFloorSession => _currentNoiseFloorSession; List get storedNoiseFloorSessions => @@ -498,21 +369,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get rxAllowed => _apiService.rxAllowed; bool get hasApiSession => _apiService.hasSession; bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; - bool get enforceHybrid => _apiService.enforceHybrid; - bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; - int get minModeInterval => _apiService.minModeInterval; - bool get enforceHopBytes => _apiService.enforceHopBytes; - int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; - int get traceHopBytes => _traceHopBytes; - bool get supportsMultiBytePaths => _originalPathHashMode != null; // Offline mode bool get offlineMode => _preferences.offlineMode; List get offlineSessions => _offlineSessionService.sessions; - bool _isUploadingOfflineSession = false; - bool get isUploadingOfflineSession => _isUploadingOfflineSession; // Developer mode bool get developerModeEnabled => _preferences.developerModeEnabled; @@ -569,8 +429,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); - // Initialize debug logging (enabled by default, respects user preference) - await _initDebugLogs(); + // Auto-enable debug logging for development builds + await _autoEnableDebugLogsIfDevelopmentBuild(); // Initialize channel service with Public channel only (regional channels added after auth) await ChannelService.initializePublicChannel(); @@ -593,8 +453,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -610,16 +469,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); - - // Schedule overlay tile refresh after server has time to regenerate tiles - // Cache buster change + notifyListeners triggers flutter_map's reloadImages() - // which updates tile URLs in-place and refetches cleanly - _tileRefreshTimer?.cancel(); - _tileRefreshTimer = Timer(const Duration(seconds: 5), () { - _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; - debugLog('[MAP] Refreshing overlay tiles'); - notifyListeners(); - }); }; // Initialize offline session service @@ -641,7 +490,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Loading preferences...'); await _loadPreferences(); await _loadDeviceAntennaPreferences(); - await _loadDevicePowerOverrides(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -666,8 +514,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); - await _connectionSubscription?.cancel(); - _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -691,8 +538,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to GPS changes debugLog('[INIT] Setting up GPS status listener...'); - await _gpsStatusSubscription?.cancel(); - _gpsStatusSubscription = _gpsService.statusStream.listen((status) { + _gpsService.statusStream.listen((status) { final previousStatus = _gpsStatus; _gpsStatus = status; @@ -717,8 +563,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); - await _gpsPositionSubscription?.cancel(); - _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { + _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -853,7 +698,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveredDevices = []; _connectionError = null; _isAuthError = false; - _isNetworkError = false; notifyListeners(); // Listen for discovered devices using subscription so stopScan() can cancel @@ -864,19 +708,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ).listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { - // Prefer remembered device name (from SelfInfo) over BLE cache - var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && - device.name != _rememberedDevice!.name) { - enrichedDevice = DiscoveredDevice( - id: device.id, - name: _rememberedDevice!.name, - rssi: device.rssi, - ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); - } - _discoveredDevices.add(enrichedDevice); - selectedDevice = enrichedDevice; + _discoveredDevices.add(device); + selectedDevice = device; notifyListeners(); } }, @@ -921,7 +754,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _connectionError = null; _isAuthError = false; - _isNetworkError = false; // Clean up any previous connection first if (_meshCoreConnection != null) { @@ -948,29 +780,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; } - // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" - if (_preferences.anonymousMode && !_isAnonymousRenamed) { - final realName = _meshCoreConnection!.selfInfo?.name; - if (realName != null && realName.isNotEmpty) { - _originalDeviceName = realName; - try { - await _meshCoreConnection!.setAdvertName('Anonymous'); - _isAnonymousRenamed = true; - _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); - // Short delay for firmware to process - await Future.delayed(const Duration(milliseconds: 300)); - } catch (e) { - debugError('[CONN] Anonymous mode: rename failed: $e'); - // Continue with real name if rename fails - } - } - } - - // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name - final deviceName = _isAnonymousRenamed - ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + // Use SelfInfo name (user's configured name) if available, otherwise fall back to BLE advertisement name + final deviceName = _meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot request auth: could not retrieve device name'); return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; @@ -1020,9 +831,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(result); - return result; } @@ -1112,9 +920,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(registerResult); - return registerResult; }; } else { @@ -1129,16 +934,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected - // Use original name (not "Anonymous") for bug report identification - var deviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + // Use companion name (selfInfo.name) or BLE device name with MeshCore- prefix stripped + var deviceName = _meshCoreConnection!.selfInfo?.name ?? + connectedDeviceName; if (deviceName != null) { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); @@ -1146,16 +949,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } - - // In offline mode, fetch signed contact URI for later registration during upload - if (_preferences.offlineMode && _meshCoreConnection != null) { - _meshCoreConnection!.exportContact().then((uri) { - _offlineContactUri = uri; - debugLog('[OFFLINE] Stored contact URI for offline session'); - }).catchError((e) { - debugWarn('[OFFLINE] Failed to get contact URI: $e'); - }); - } } notifyListeners(); }); @@ -1188,8 +981,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { powerLevel: device.power, txPower: device.txPower, autoPowerSet: true, // Indicates power was auto-detected from device model - powerLevelSet: false, // Clear stale manual flag from previous session ); + // TODO: Persist to SharedPreferences when implemented notifyListeners(); debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } @@ -1217,54 +1010,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - final newValidator = PacketValidator( - allowedChannels: allowedChannels, - disableRssiFilter: _preferences.disableRssiFilter, - ); + final newValidator = PacketValidator(allowedChannels: allowedChannels); _unifiedRxHandler!.updateValidator(newValidator); debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } - // Set flood scope from API response (regional TX filtering) - // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) - // Any other value (e.g., "ottawa") → derive TransportKey and set scope - final apiScopes = _apiService.scopes; - final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; - if (!isWildcard) { - final scopeName = firstScope; - _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; - final scopeKey = CryptoService.deriveScopeKey(scopeName); - debugLog('[CONN] Setting flood scope: $scopeName'); - await _meshCoreConnection!.setFloodScope(scopeKey); - debugLog('[CONN] Flood scope set successfully'); - } else { - _scope = null; - debugLog('[CONN] No regional scope — using unscoped flood'); - } - - // Enforce hybrid mode if required by regional admin - if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { - _preferences = _preferences.copyWith(hybridModeEnabled: true); - debugLog('[CONN] Hybrid mode force-enabled by regional admin'); - } - - // Enforce discovery drop if required by regional admin - if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { - _preferences = _preferences.copyWith(discDropEnabled: true); - debugLog('[CONN] Discovery drop force-enabled by regional admin'); - } - - // Enforce minimum auto-ping interval if required by regional admin - if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); - } - - // Configure multi-byte path hash mode on radio - await _configurePathHashMode(); - // Create ping service with wakelock (create new instance per connection) _pingService = PingService( gpsService: _gpsService, @@ -1278,21 +1029,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceId: _deviceId, txTracker: _txTracker, audioService: _audioService, - disableRssiFilter: _preferences.disableRssiFilter, - hopBytes: effectiveHopBytes, - traceHopBytes: _traceHopBytes, shouldIgnoreRepeater: (String repeaterId) { + // Same filter as RxLogger - check user preferences for ignored repeater ID final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); + // Case-insensitive comparison (both uppercase) + final ignored = prefs.ignoreRepeaterId!.toUpperCase(); + final current = repeaterId.toUpperCase(); + return current == ignored; } return false; }, ); - // Wire UnifiedRxHandler so trace payloads route to TraceTracker - _pingService!.unifiedRxHandler = _unifiedRxHandler; - // Set validation callbacks _pingService!.checkExternalAntennaConfigured = () { // External antenna must be explicitly set (yes or no) before pinging @@ -1313,9 +1062,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; - // Check if discovery drop is enabled - _pingService!.getDiscDropEnabled = () => discDropEnabled; - _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -1350,9 +1096,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); - // Update RX overlay slot with this RX observation - _updateRxOverlaySlot(ping.repeaterId, ping.snr); - notifyListeners(); }; @@ -1369,8 +1112,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1383,7 +1125,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events @@ -1423,13 +1165,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); - - // Update top repeaters overlay with current TX echoes - _updateTopRepeaters(existingEvents - .where((e) => e.snr != null) - .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), OverlayPingType.tx); - debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); @@ -1447,20 +1182,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up auto ping scheduled callback for countdown display _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - - // Track idle time for auto-stop - if (skipReason != null) { - // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { - final elapsed = DateTime.now().difference(_idleAutoStopReference!); - if (elapsed >= _autoStopIdleTimeout) { - _triggerIdleAutoStop(); - } - } - } else { - // Successful ping — reset idle reference - _idleAutoStopReference = DateTime.now(); - } }; // Wire up discovery ping callback - fires immediately (like onTxPing) @@ -1474,12 +1195,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isNew) { _audioService.playReceiveSound(); } - - // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters(discPing.discoveredNodes - .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), OverlayPingType.disc); - notifyListeners(); }; @@ -1497,8 +1212,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastTx.events.isNotEmpty) { repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, + snr: e.snr, + rssi: e.rssi, )).toList(); } } @@ -1533,64 +1248,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - PingEventType eventType; - if (success) { - eventType = PingEventType.discSuccess; - } else if (discDropEnabled) { - eventType = PingEventType.txFail; - } else { - eventType = PingEventType.discFail; - } - - recordPingEvent( - eventType, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; - - // Wire up trace ping callback (for log entry creation) - _pingService!.onTracePing = (entry) { - _addTraceLogEntry(entry); - }; - - // Wire up trace window complete callback for noise floor graph - _pingService!.onTraceWindowComplete = (result) { - double? lat; - double? lon; - List? repeaters; - - if (_traceLogEntries.isNotEmpty) { - final lastTrace = _traceLogEntries.first; - lat = lastTrace.latitude; - lon = lastTrace.longitude; - if (result != null && result.success) { - repeaters = [MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - )]; - // Update the log entry with success data - _traceLogEntries[0] = TraceLogEntry( - timestamp: lastTrace.timestamp, - latitude: lastTrace.latitude, - longitude: lastTrace.longitude, - targetRepeaterId: lastTrace.targetRepeaterId, - noiseFloor: lastTrace.noiseFloor, - localSnr: result.localSnr, - remoteSnr: result.remoteSnr, - localRssi: result.localRssi, - success: true, - ); - notifyListeners(); - } - } - recordPingEvent( - result != null && result.success - ? PingEventType.traceSuccess - : PingEventType.traceFail, + success ? PingEventType.discSuccess : PingEventType.discFail, latitude: lat, longitude: lon, repeaters: repeaters, @@ -1634,7 +1293,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update local state _autoPingEnabled = false; - _idleAutoStopReference = null; debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -1647,25 +1305,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // BLE advertisement name may be cached/stale after device rename final selfInfoName = _meshCoreConnection?.selfInfo?.name; if (selfInfoName != null && selfInfoName.isNotEmpty) { - // Keep "Anonymous" display name if anonymous mode is active - _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; - debugLog('[APP] Display name set: "$_displayDeviceName"'); - - // Update remembered device with real name (not "Anonymous") - // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; - if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { - final updatedName = 'MeshCore-$realName'; - if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); - } - } + _displayDeviceName = selfInfoName; + debugLog('[APP] Display name set from SelfInfo: "$selfInfoName"'); } // Restore per-device antenna preference if previously saved - // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final resolvedName = displayDeviceName; if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( @@ -1678,21 +1323,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - // Restore per-device power override if previously saved - if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { - final saved = _devicePowerOverrides[resolvedName]!; - _preferences = _preferences.copyWith( - powerLevel: (saved['powerLevel'] as num).toDouble(), - txPower: (saved['txPower'] as num).toInt(), - autoPowerSet: false, - powerLevelSet: true, - ); - _powerRestoredFromDevice = true; - _savePreferences(); - debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); - notifyListeners(); - } - // Log connection status based on TX/RX permissions if (hasApiSession) { if (txAllowed && rxAllowed) { @@ -1702,11 +1332,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else { debugLog('[CONN] Connected with limited access'); } - - // Start periodic zone refresh to keep slot counts current - if (!_preferences.offlineMode) { - _startZoneRefreshTimer(); - } } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1749,14 +1374,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; - _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { _connectionError = 'Authentication failed'; } } else { _isAuthError = false; - _isNetworkError = false; // Provide clean user-facing messages for common BLE errors if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; @@ -1775,11 +1398,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create TX tracker (stored for use by PingService) _txTracker = TxTracker(); - _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; - - // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1789,16 +1407,37 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; debugLog('[APP] TxTracker.onCarpeaterDrop callback SET'); + // Function to check if repeater should be ignored (carpeater filter) + _txTracker!.shouldIgnoreRepeater = (String repeaterId) { + final prefs = _preferences; + if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { + final ignored = prefs.ignoreRepeaterId!.toUpperCase(); + return repeaterId.toUpperCase() == ignored; + } + return false; + }; + debugLog('[APP] TxTracker.shouldIgnoreRepeater callback SET'); + // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( - // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + // Function to check if repeater should be ignored (carpeater filter) + shouldIgnoreRepeater: (String repeaterId) { + // Check user preferences for ignored repeater ID + final prefs = _preferences; + if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { + // Case-insensitive comparison (both uppercase) + final ignored = prefs.ignoreRepeaterId!.toUpperCase(); + final current = repeaterId.toUpperCase(); + return current == ignored; + } + return false; + }, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' - 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); + 'snr=${observation.snr}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); @@ -1813,8 +1452,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: observation.lon, repeaterId: observation.repeaterId, timestamp: DateTime.now(), - snr: observation.snr ?? 0.0, - rssi: observation.rssi ?? 0, + snr: observation.snr, + rssi: observation.rssi, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1826,10 +1465,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); - // Update RX overlay slot immediately - if (observation.snr != null) { - _updateRxOverlaySlot(repeaterKey, observation.snr!); - } // Play receive sound for new RX observation _audioService.playReceiveSound(); // Record RX event for noise floor graph with location and repeater info @@ -1840,8 +1475,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: [ MarkerRepeaterInfo( repeaterId: observation.repeaterId, - snr: observation.snr ?? 0.0, - rssi: observation.rssi ?? 0, + snr: observation.snr, + rssi: observation.rssi, ), ], ); @@ -1861,7 +1496,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' - 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); + 'snr=${entry.snr}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1878,22 +1513,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastPinIndex != -1) { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; - // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; - if (shouldUpdateSnr) { + if (entry.snr > existingPin.snr) { _rxPings[lastPinIndex] = RxPing( latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch - rssi: entry.rssi ?? existingPin.rssi, + snr: entry.snr, // UPDATE to best SNR from batch + rssi: entry.rssi, ); debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' - '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); + '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr.toStringAsFixed(2)}'); } else { debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' - 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); + 'batch best ${entry.snr.toStringAsFixed(2)} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { // Edge case: pin not found (should have been created in onObservation) @@ -1902,8 +1535,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: entry.lon, repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? 0.0, - rssi: entry.rssi ?? 0, + snr: entry.snr, + rssi: entry.rssi, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1933,20 +1566,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogEntries.add(rxLogEntry); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' - 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); - - // Update RX overlay slot with this RX observation - if (entry.snr != null) { - _updateRxOverlaySlot(entry.repeaterId, entry.snr!); - } + 'snr=${entry.snr}, pathLen=${entry.pathLength}'); // Note: RX count is incremented in onObservation when pin is created (immediate feedback) // Enqueue to API with formatted heard_repeats string - // Format: "repeaterId(snr)" e.g. "4e(12.25)" or "4e(null)" for CARpeater pass-through - final heardRepeats = entry.snr != null - ? '${entry.repeaterId}(${entry.snr!.toStringAsFixed(2)})' - : '${entry.repeaterId}(null)'; + // Format: "repeaterId(snr)" e.g. "4e(12.25)" + final heardRepeats = '${entry.repeaterId}(${entry.snr.toStringAsFixed(2)})'; await _apiQueueService.enqueueRx( latitude: entry.lat, longitude: entry.lon, @@ -1991,11 +1617,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); - final validator = PacketValidator( - allowedChannels: allowedChannels, - disableRssiFilter: _preferences.disableRssiFilter, - ); - + final validator = PacketValidator(allowedChannels: allowedChannels); + // Create unified handler _unifiedRxHandler = UnifiedRxHandler( txTracker: _txTracker!, @@ -2016,174 +1639,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Full disconnect cleanup - called on normal BLE disconnect (user-requested or no remembered device) /// Extracted from the original BLE disconnect listener - /// Configure multi-byte path hash mode on the radio during connection - /// Reads device's current mode, determines effective mode, and sends command if needed - Future _configurePathHashMode() async { - final deviceInfo = _meshCoreConnection?.deviceInfo; - if (deviceInfo == null) return; - - // Store the device's current mode (from DeviceInfo response) - _originalPathHashMode = deviceInfo.pathHashMode; - - // Sync runtime hopBytes from device's current mode - if (_originalPathHashMode != null) { - final deviceHopBytes = _originalPathHashMode! + 1; - _hopBytes = deviceHopBytes; - // Map TX bytes to trace bytes (3-byte traces not possible, use 4) - _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; - _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); - } else { - _hopBytes = 1; - _traceHopBytes = 1; - } - - final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) - final deviceHopBytes = deviceMode + 1; - - if (effective != deviceHopBytes && _originalPathHashMode != null) { - // Need to change the radio's path hash mode - try { - await _meshCoreConnection!.setPathHashMode(effective - 1); - _hopBytes = effective; // Update runtime state to reflect new mode - _traceHopBytes = effective == 3 ? 4 : effective; - _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); - - // Show warning popup if changing from 1-byte to multi-byte - if (deviceMode == 0 && effective > 1) { - final reason = enforceHopBytes - ? 'set by your regional admin' - : 'set in your app preferences'; - _pendingPathHashWarning = (hopBytes: effective, reason: reason); - notifyListeners(); // Trigger UI to show warning - } - } catch (e) { - debugError('[PATH] Failed to set path hash mode: $e'); - } - } else if (_originalPathHashMode == null && effective > 1) { - // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); - if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); - notifyListeners(); - } - } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); - } - } - - /// Restore radio to original path hash mode on clean disconnect - /// Skipped if the user manually changed the setting — they know what they're doing - Future _restorePathHashMode() async { - if (_originalPathHashMode == null) return; - - if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); - _originalPathHashMode = null; - _userChangedPathMode = false; - return; - } - - final originalMode = _originalPathHashMode!; - final originalHopBytes = originalMode + 1; - - // Compare current runtime mode against what the device had before we changed it - if (_hopBytes != originalHopBytes) { - try { - await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); - } catch (e) { - debugError('[PATH] Failed to restore path hash mode: $e'); - } - } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); - } - _originalPathHashMode = null; - _userChangedPathMode = false; - } - - /// Send path hash mode to radio immediately when user changes setting while connected - void _applyLivePathHashMode(int newHopBytes) { - if (_originalPathHashMode == null) { - // Old firmware — can't send command, show warning - debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); - _hopBytes = 1; // Force back to 1 - notifyListeners(); - return; - } - - _hopBytes = newHopBytes; - _userChangedPathMode = true; - _pingService?.hopBytes = newHopBytes; - // Auto-map trace bytes when TX bytes change (3→4, others stay same) - final oldTraceHopBytes = _traceHopBytes; - _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; - _pingService?.traceHopBytes = _traceHopBytes; - // Clear target repeater if trace bytes changed — old hex ID has wrong byte length - if (_traceHopBytes != oldTraceHopBytes) { - _targetRepeaterId = null; - } - final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 - _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); - notifyListeners(); - } - - /// Set hop bytes (called from settings UI). Each companion device may differ. - void setHopBytes(int value) { - if (value < 1 || value > 3) return; - if (value == _hopBytes) return; - - if (isConnected) { - _applyLivePathHashMode(value); - } else { - _hopBytes = value; - notifyListeners(); - } - } - - /// Set trace hop bytes (called from settings UI). Valid values: 1, 2, 4. - void setTraceHopBytes(int value) { - if (value != 1 && value != 2 && value != 4) return; - if (value == _traceHopBytes) return; - _traceHopBytes = value; - _pingService?.traceHopBytes = value; - // Clear target repeater — old hex ID has wrong byte length - _targetRepeaterId = null; - debugLog('[TRACE] User changed trace bytes to $value'); - notifyListeners(); - } - - /// Pending path hash warning data (for UI to show dialog) - ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; - - /// Clear the pending warning after UI has shown it - void clearPathHashWarning() { - _pendingPathHashWarning = null; - } - Future _fullDisconnectCleanup() async { - _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); - // Stop zone refresh timer - _stopZoneRefreshTimer(); - // Stop auto-ping timers _autoPingTimer.stop(); _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; - _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); } @@ -2220,13 +1688,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // Reset anonymous mode state (BLE already gone, can't restore name) - _isAnonymousRenamed = false; - _originalDeviceName = null; - - // Clear top-heard overlay - _clearOverlayState(); - // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; @@ -2236,7 +1697,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { - _cancelPendingAutoPingRestore(); _isAutoReconnecting = true; _reconnectAttempt = 0; _connectionStep = ConnectionStep.reconnecting; @@ -2250,7 +1710,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; - _idleAutoStopReference = null; // Stop heartbeat _apiService.disableHeartbeat(); @@ -2359,21 +1818,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Restore auto-ping if it was active if (wasAutoPing) { - final restoreGeneration = _reconnectRestoreGeneration; // Use a short delay to ensure connection is fully set up - _restoreAutoPingTimer?.cancel(); - _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { - _restoreAutoPingTimer = null; - if (_isDisposed || - restoreGeneration != _reconnectRestoreGeneration || - _userRequestedDisconnect || - _connectionStep != ConnectionStep.connected || - _pingService == null) { - debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); - return; - } - - if (!_autoPingEnabled) { + Timer(const Duration(milliseconds: 500), () { + if (_connectionStep == ConnectionStep.connected) { toggleAutoPing(previousMode); debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } @@ -2396,23 +1843,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; - _cancelPendingAutoPingRestore(); // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; - // Reset antenna and power settings so user must choose again on next connect + // Reset antenna setting so user must choose again on next connect _antennaRestoredFromDevice = false; - _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); - // Reset anonymous mode state (BLE already gone, can't restore name) - _isAnonymousRenamed = false; - _originalDeviceName = null; - // Do full disconnect cleanup (releases API session, etc.) _fullDisconnectCleanup(); notifyListeners(); @@ -2428,7 +1869,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; - _cancelPendingAutoPingRestore(); _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; @@ -2436,14 +1876,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); - // Stop zone refresh timer - _stopZoneRefreshTimer(); - // Stop auto-ping if running (before releasing session) if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; - _idleAutoStopReference = null; } // End noise floor session on disconnect @@ -2460,9 +1896,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop RX wardriving if active (flushes batches to queue) _rxLogger?.stopWardriving(trigger: 'disconnect'); - // Save offline pings before clearing queue (no-op if not in offline mode or no pings) - await _saveOfflineSession(); - // ALWAYS START FRESH - clear any queued data on disconnect // Pings without a valid session cannot be uploaded later await _apiQueueService.clearOnDisconnect(); @@ -2482,37 +1915,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // Restore original device name if anonymous mode renamed it (BLE must still be connected) - if (_isAnonymousRenamed && _originalDeviceName != null) { - try { - await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); - } catch (e) { - debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); - } - _isAnonymousRenamed = false; - _originalDeviceName = null; - } - - // Restore original path hash mode before disconnect (while BLE still connected) - await _restorePathHashMode(); - - // Clear flood scope before disconnect (safety — BLE disconnect resets radio state anyway) - try { - await _meshCoreConnection?.clearFloodScope(); - } catch (e) { - debugLog('[CONN] Failed to clear flood scope: $e'); - } - // Delete wardriving channel FIRST, while BLE connection is still active // This prevents "GATT Server is disconnected" errors - if (_preferences.deleteChannelOnDisconnect) { - await _meshCoreConnection?.deleteWardrivingChannelEarly(); - } else { - debugLog('[CHANNEL] Skipping channel deletion (user preference)'); - } + await _meshCoreConnection?.deleteWardrivingChannelEarly(); // Cleanup unified RX handler and TX tracker _logRxDataSubscription?.cancel(); @@ -2539,26 +1944,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnected; _deviceModel = null; _manufacturerString = null; - _firmwareVersionString = null; _devicePublicKey = null; - _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; - _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; _authType = null; - _originalPathHashMode = null; - _userChangedPathMode = false; - _hopBytes = 1; - _traceHopBytes = 1; - // Clear regional channels (keeps only Public) and scope + // Clear regional channels (keeps only Public) ChannelService.clearRegionalChannels(); _regionalChannels = []; - _scope = null; // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -2639,33 +2036,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Set the target repeater ID for targeted mode - void setTargetRepeaterId(String? id) { - _targetRepeaterId = id; - notifyListeners(); - } - - /// Auto-stop auto-ping after prolonged idle (no movement) - void _triggerIdleAutoStop() { - if (!_autoPingEnabled) return; - final elapsed = _idleAutoStopReference != null - ? DateTime.now().difference(_idleAutoStopReference!).inMinutes - : 30; - debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); - logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); - _idleAutoStopReference = null; - toggleAutoPing(_autoMode); - } - - /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) - /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) + /// Toggle auto-ping mode (Active, Passive, or Hybrid) + /// Returns false if blocked by cooldown (Active/Hybrid Mode only - Passive Mode ignores cooldown) Future toggleAutoPing(AutoMode mode) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; final isHybrid = mode == AutoMode.hybrid; - final isTargeted = mode == AutoMode.targeted; - final isTxMode = !isPassive; // Active, Hybrid, and Targeted all do TX + final isTxMode = !isPassive; // Active and Hybrid both do TX // If currently running the same mode, stop it (always allow stopping) if (_autoPingEnabled && _autoMode == mode) { @@ -2710,16 +2088,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); _autoPingEnabled = false; - _idleAutoStopReference = null; - // Clear top-heard overlay on stop - _clearOverlayState(); - - // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode + // Start 7-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed if (isTxMode) { - _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + _cooldownTimer.start(7000); + debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (7s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2747,8 +2121,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop countdown timers when switching modes _autoPingTimer.stop(); _rxWindowTimer.stop(); - // Clear top-heard overlay on mode switch - _clearOverlayState(); // Save offline session if offline mode is enabled if (_preferences.offlineMode) { await _saveOfflineSession(); @@ -2766,12 +2138,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.setAutoPingInterval(intervalMs); debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); - final started = await _pingService!.enableAutoPing( - passiveMode: isPassive, - hybridMode: isHybrid, - targetedMode: isTargeted, - targetRepeaterId: isTargeted ? _targetRepeaterId : null, - ); + final started = await _pingService!.enableAutoPing(passiveMode: isPassive, hybridMode: isHybrid); if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { @@ -2785,10 +2152,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reference: state.rxTracking.isWardriving = true in wardrive.js _rxLogger?.startWardriving(); _autoPingEnabled = true; - _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; + final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -2808,7 +2174,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; + final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -2825,7 +2191,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); - _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); } @@ -2835,9 +2200,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txLogEntries.clear(); _rxLogEntries.clear(); _discLogEntries.clear(); - _traceLogEntries.clear(); _errorLogEntries.clear(); - _clearOverlayState(); notifyListeners(); } @@ -2851,25 +2214,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - /// Add a trace log entry (from Trace Mode) - void _addTraceLogEntry(TraceLogEntry entry) { - _traceLogEntries.insert(0, entry); - if (_traceLogEntries.length > _maxLogEntries) { - _traceLogEntries.removeLast(); - } - debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); - - // Update top repeaters overlay with successful trace result - if (entry.success && entry.localSnr != null) { - // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay - final id = entry.targetRepeaterId.toUpperCase(); - final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); - } - - notifyListeners(); - } - /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { @@ -2938,8 +2282,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { - // Start periodic auto-save to prevent data loss from app kill - _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode _inZone = null; _currentZone = null; @@ -2947,8 +2289,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _lastZoneCheckPosition = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { - // Stop auto-save timer when leaving offline mode - _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); @@ -3001,9 +2341,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(offlineMode: true); _apiQueueService.offlineMode = true; - // 5b. Start periodic auto-save to prevent data loss from app kill - _startOfflineAutoSaveTimer(); - // 6. Clear zone data _inZone = null; _currentZone = null; @@ -3038,11 +2375,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _saveOfflineSession(); // 4. Request new auth session - // Use "Anonymous" if renamed, otherwise real name - final deviceName = _isAnonymousRenamed - ? 'Anonymous' - : (_meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); + final deviceName = _meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot switch to online mode: no device name available'); @@ -3062,117 +2396,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - // ============================================================ - // STAGE 1: Try existing public_key authentication - // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); - - final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; - - var result = await _apiService.requestAuth( + debugLog('[APP] Requesting auth for online mode'); + final result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: modelString, + model: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, ); - // Check for maintenance mode - if (result != null && result['maintenance'] == true) { - _maintenanceMode = true; - _maintenanceMessage = result['maintenance_message'] as String?; - _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); - _startMaintenancePolling(); - notifyListeners(); - _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; + if (result == null) { + debugError('[APP] Auth request failed: no response'); + _modeSwitchError = 'Unable to connect to server'; return (success: false, error: _modeSwitchError); } - // Check if Stage 1 succeeded - if (result != null && result['success'] == true) { - debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); - if (result['type'] != null) { - _authType = result['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } - _syncZoneCapacityFromAuth(result); - } else if (result == null) { - // API unreachable (null = network/timeout error) - debugError('[APP] API unreachable - network error'); - _modeSwitchError = 'Unable to reach the MeshMapper server'; + if (result['success'] != true) { + final reason = result['reason'] as String?; + final message = result['message'] as String?; + debugError('[APP] Auth request failed: $reason - $message'); + _modeSwitchError = message ?? reason ?? 'Authentication failed'; return (success: false, error: _modeSwitchError); - } else { - // Stage 1 failed — check if Stage 2 is worth attempting - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); - - final stage1Reason = result['reason'] as String?; - if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); - _modeSwitchError = result['message'] as String? ?? 'GPS error'; - return (success: false, error: _modeSwitchError); - } - - // ============================================================ - // STAGE 2: Auth failed, attempt registration via signed contact_uri - // ============================================================ - debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); - - String? contactUri; - try { - debugLog('[APP] Requesting signed contact URI from device...'); - contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); - } catch (e) { - debugError('[APP] Failed to get contact URI from device: $e'); - _modeSwitchError = 'Companion not found in backend and failed to register via API'; - return (success: false, error: _modeSwitchError); - } - - final registerResult = await _apiService.requestAuth( - reason: 'register', - contactUri: contactUri, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: modelString, - lat: _currentPosition!.latitude, - lon: _currentPosition!.longitude, - accuracyMeters: _currentPosition!.accuracy, - ); - - if (registerResult == null) { - debugError('[APP] Stage 2 failed: network error (API unreachable)'); - _modeSwitchError = 'Unable to reach the MeshMapper server'; - return (success: false, error: _modeSwitchError); - } - - if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; - final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); - _modeSwitchError = serverMessage ?? 'Registration rejected by server'; - return (success: false, error: _modeSwitchError); - } - - // Registration successful - debugLog('[APP] Stage 2 succeeded: registered and authenticated'); - if (registerResult['type'] != null) { - _authType = registerResult['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } - _syncZoneCapacityFromAuth(registerResult); - - result = registerResult; } // 5. Auth successful - update state @@ -3246,7 +2496,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; - _idleAutoStopReference = null; debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } @@ -3286,59 +2535,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } - // Include device info for auth during upload (use real name, not "Anonymous" — sessions upload later) + // Include device info for auth during upload (same priority as online auth: SelfInfo name → BLE name) // Note: Connection already validates device name exists, so this should never be null - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, - deviceName: offlineDeviceName, - contactUri: _offlineContactUri, + deviceName: _meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''), ); - _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); - _stopOfflineAutoSaveTimer(); notifyListeners(); } - /// Periodically auto-save offline pings to prevent data loss from app kill. - /// Uses a non-destructive snapshot so in-memory accumulation continues. - void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; - - final pings = _apiQueueService.getOfflinePingsSnapshot(); - if (pings.isEmpty) return; - - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - - _offlineSessionService.updateCurrentSession( - pings, - devicePublicKey: _devicePublicKey, - deviceName: offlineDeviceName, - contactUri: _offlineContactUri, - ); - } - - void _startOfflineAutoSaveTimer() { - _offlineAutoSaveTimer?.cancel(); - _offlineAutoSaveTimer = Timer.periodic(const Duration(seconds: 60), (_) { - _autoSaveOfflinePings(); - }); - debugLog('[OFFLINE] Auto-save timer started (60s interval)'); - } - - void _stopOfflineAutoSaveTimer() { - if (_offlineAutoSaveTimer != null) { - _offlineAutoSaveTimer!.cancel(); - _offlineAutoSaveTimer = null; - debugLog('[OFFLINE] Auto-save timer stopped'); - } - } - /// Upload a stored offline session Future uploadOfflineSession(String filename) async { final sessionData = _offlineSessionService.getSessionData(filename); @@ -3376,42 +2583,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Upload an offline session with authenticated API session - /// Uses stored device credentials to authenticate before uploading. - /// Session is fully isolated from the shared ApiService state — offline uploads - /// never touch _sessionId and cannot trigger BLE disconnect on failure. + /// Uses stored device credentials to authenticate before uploading /// - /// @param onProgress Optional callback for progress updates (e.g., "Batch 1/3") /// Returns the result of the upload operation - Future uploadOfflineSessionWithAuth( - String filename, { - void Function(String status)? onProgress, - }) async { - // Concurrency guard — only one offline upload at a time - if (_isUploadingOfflineSession) { - debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); - return OfflineUploadResult.uploadInProgress; - } - - _isUploadingOfflineSession = true; - notifyListeners(); - - try { - return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); - } finally { - _isUploadingOfflineSession = false; - notifyListeners(); - } - } - - /// Internal implementation of offline session upload with isolated session - Future _uploadOfflineSessionIsolated( - String filename, { - void Function(String status)? onProgress, - }) async { + Future uploadOfflineSessionWithAuth(String filename) async { // 1. Get session with stored device credentials final session = _offlineSessionService.getSession(filename); if (session == null) { - debugLog('[OFFLINE] Session not found: $filename'); + debugLog('[APP] Offline session not found: $filename'); return OfflineUploadResult.notFound; } @@ -3422,28 +2601,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(); if (pings == null || pings.isEmpty) { - debugLog('[OFFLINE] Session has no pings: $filename'); + debugLog('[APP] Offline session has no pings: $filename'); return OfflineUploadResult.invalidSession; } // 2. Get device credentials from session final publicKey = session.devicePublicKey; if (publicKey == null) { - debugLog('[OFFLINE] Session missing device public key: $filename'); + debugLog('[APP] Offline session missing device public key: $filename'); return OfflineUploadResult.invalidSession; } final deviceName = session.deviceName; if (deviceName == null || deviceName.isEmpty) { - debugLog('[OFFLINE] Session missing device name: $filename'); + debugLog('[APP] Offline session missing device name: $filename'); return OfflineUploadResult.invalidSession; } - onProgress?.call('Authenticating...'); - - // 3. Authenticate with offline_mode: true, skipSessionStore: true - // This prevents writing to shared _sessionId/_txAllowed/etc. - debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); + // 3. Authenticate with offline_mode: true + debugLog('[APP] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3456,105 +2632,54 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, offlineMode: true, - skipSessionStore: true, ); - Map? effectiveAuth = authResult; - - if (authResult == null) { - debugError('[OFFLINE] Auth failed: network error'); + if (authResult == null || authResult['success'] != true) { + final reason = authResult?['reason'] as String? ?? 'unknown'; + debugError('[API] Offline upload auth failed: $reason'); return OfflineUploadResult.authFailed; } - if (authResult['success'] != true) { - final reason = authResult['reason'] as String? ?? 'unknown'; - debugLog('[OFFLINE] Stage 1 failed: $reason'); - - // Stage 2: If unknown_device and we have a stored contactUri, attempt registration - if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); - final registerResult = await _apiService.requestAuth( - reason: 'register', - contactUri: session.contactUri, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - offlineMode: true, - skipSessionStore: true, - ); - - if (registerResult == null || registerResult['success'] != true) { - final regReason = registerResult?['reason'] as String? ?? 'unknown'; - debugError('[OFFLINE] Stage 2 registration failed: $regReason'); - return OfflineUploadResult.authFailed; - } - - debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); - effectiveAuth = registerResult; - } else { - debugError('[OFFLINE] Auth failed: $reason'); - return OfflineUploadResult.authFailed; - } - } - - // Extract session_id into local variable — never stored in shared state - final offlineSessionId = effectiveAuth!['session_id'] as String?; - if (offlineSessionId == null) { - debugError('[OFFLINE] Auth succeeded but no session_id in response'); - return OfflineUploadResult.authFailed; - } - - debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); + debugLog('[APP] Offline upload authenticated, session: ${authResult['session_id']}'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); - // 4. Upload pings in batches of 50 using isolated session + // 4. Upload pings in batches of 50 const batchSize = 50; var uploadedCount = 0; var failedBatches = 0; - final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { - final batchNum = (i ~/ batchSize) + 1; - onProgress?.call('Batch $batchNum/$totalBatches'); - final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + final result = await _apiService.uploadBatch(batch); if (result == UploadResult.success) { uploadedCount += batch.length; - debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + debugLog('[APP] Uploaded batch ${(i ~/ batchSize) + 1}: ${batch.length} pings'); } else { failedBatches++; - debugError('[OFFLINE] Failed to upload batch $batchNum'); + debugError('[APP] Failed to upload batch ${(i ~/ batchSize) + 1}'); } } // Delay after posting before disconnect await Future.delayed(const Duration(seconds: 1)); - // 5. Release isolated API session (does not clear shared state) - onProgress?.call('Finalizing...'); + // 5. Release API session await _apiService.requestAuth( reason: 'disconnect', publicKey: publicKey, - sessionId: offlineSessionId, ); - debugLog('[OFFLINE] Isolated upload session released'); + debugLog('[APP] Offline upload session released'); // 6. Mark session as uploaded (don't delete) if all batches succeeded if (failedBatches == 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); + debugLog('[API] Uploaded ${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn('[API] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -3580,104 +2705,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void updatePreferences(UserPreferences preferences) { debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); - _preferences = preferences; - // Clear restored flags — user is making a manual choice now + // Clear restored flag — user is making a manual choice now _antennaRestoredFromDevice = false; - _powerRestoredFromDevice = false; - // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + // Persist antenna choice per device name + final deviceName = displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } - // Persist power override per device name - if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { - _devicePowerOverrides[deviceName] = { - 'powerLevel': preferences.powerLevel, - 'txPower': preferences.txPower, - }; - _saveDevicePowerOverrides(); - debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); - } else if (deviceName != null && preferences.autoPowerSet) { - // User re-selected the auto-detected value — clear any saved override - if (_devicePowerOverrides.remove(deviceName) != null) { - _saveDevicePowerOverrides(); - debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); - } - } - - // Propagate RSSI filter setting to live trackers/validators - _syncRssiFilterSetting(preferences.disableRssiFilter); - - // Propagate CARpeater prefix to live trackers - _syncCarpeaterPrefix(); - - // Propagate min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); - PingService.currentMinDistance = preferences.minPingDistanceMeters; - notifyListeners(); _savePreferences(); } - /// Set anonymous mode, disconnecting and reconnecting if currently connected - Future setAnonymousMode(bool enabled) async { - if (enabled == _preferences.anonymousMode) return; - - _preferences = _preferences.copyWith(anonymousMode: enabled); - _savePreferences(); - notifyListeners(); - - // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { - final deviceToReconnect = _bluetoothService.connectedDevice; - if (deviceToReconnect != null) { - _requestConnectionTabSwitch = true; - notifyListeners(); - await disconnect(); // Full cleanup (restores name if previously anonymous) - // Short delay for BLE cleanup - await Future.delayed(const Duration(milliseconds: 500)); - await connectToDevice(deviceToReconnect); - } - } - } - - /// Propagate carpeaterPrefix to live TxTracker and RxLogger - void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - if (_txTracker != null) { - _txTracker!.carpeaterPrefix = prefix; - debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); - } - if (_rxLogger != null) { - _rxLogger!.carpeaterPrefix = prefix; - debugLog('[APP] Synced RxLogger.carpeaterPrefix = ${prefix ?? 'null'}'); - } - } - - /// Propagate disableRssiFilter to all active trackers and validators - void _syncRssiFilterSetting(bool disableRssiFilter) { - if (_txTracker != null) { - _txTracker!.disableRssiFilter = disableRssiFilter; - } - if (_unifiedRxHandler != null) { - final oldValidator = _unifiedRxHandler!.validator; - final newValidator = PacketValidator( - allowedChannels: oldValidator.allowedChannels, - disableRssiFilter: disableRssiFilter, - ); - _unifiedRxHandler!.updateValidator(newValidator); - } - if (_pingService != null) { - _pingService!.disableRssiFilter = disableRssiFilter; - } - } - /// Set developer mode (unlocked by tapping version 7 times) void setDeveloperMode(bool enabled) { _preferences = _preferences.copyWith(developerModeEnabled: enabled); @@ -3772,11 +2816,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } - /// Clear the connection tab switch request (called by main scaffold after switching) - void clearConnectionTabSwitchRequest() { - _requestConnectionTabSwitch = false; - } - // ============================================ // API Error Handling // ============================================ @@ -3868,29 +2907,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { authErrors.contains(reason) || zoneErrors.contains(reason)) { debugLog('[API] Session error requires disconnect: $reason'); - - // Preserve queued wardrive data to offline storage before disconnect clears it - if (sessionErrors.contains(reason)) { - try { - final queuedPings = await _apiQueueService.extractAllAsJson(); - if (queuedPings.isNotEmpty) { - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); - await _offlineSessionService.saveSession( - queuedPings, - devicePublicKey: _devicePublicKey, - deviceName: offlineDeviceName, - contactUri: _offlineContactUri, - ); - debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); - } - } catch (e) { - debugError('[APP] Failed to preserve queue to offline storage: $e'); - } - } - // Don't call requestAuth disconnect - session is already invalid on server // Just cleanup locally and disconnect await disconnect(); @@ -4099,9 +3115,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { - // Update position even on failure to prevent zone check flooding - // (without this, every GPS update re-triggers a zone check while driving) - _lastZoneCheckPosition = _currentPosition; debugError('[GEOFENCE] Zone status check failed: no response from API'); _scheduleZoneCheckRetry( seconds: 5, @@ -4140,15 +3153,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { - logError('GPS Accuracy Error\n$message', autoSwitch: false); - _zoneCheckError = message; - _zoneCheckErrorReason = 'gps_inaccurate'; - notifyListeners(); + logError('GPS Accuracy Error\n$message'); + _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_inaccurate'); } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message', autoSwitch: false); - _zoneCheckError = message; - _zoneCheckErrorReason = 'gps_stale'; - notifyListeners(); + logError('GPS Stale Error\n$message'); + _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_stale'); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); @@ -4200,68 +3209,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// Sync zone capacity display with auth result. - /// The /status API (pre-connection) and /auth API (during connection) can - /// return different capacity views. This keeps the connection screen's slot - /// display consistent with the map tab's txAllowed flag. - void _syncZoneCapacityFromAuth(Map authResult) { - if (_currentZone == null) return; - - // If auth response includes slot data, use it directly (forward-compatible) - if (authResult.containsKey('slots_available')) { - _currentZone!['slots_available'] = authResult['slots_available']; - debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); - } - if (authResult.containsKey('slots_max')) { - _currentZone!['slots_max'] = authResult['slots_max']; - debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); - } - - // Sync at_capacity with tx_allowed - final authTxAllowed = authResult['tx_allowed'] == true; - _currentZone!['at_capacity'] = !authTxAllowed; - - // If auth says TX not allowed and server didn't provide slot data, set slots to 0 - if (!authTxAllowed && !authResult.containsKey('slots_available')) { - _currentZone!['slots_available'] = 0; - debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); - } - - // If auth says TX allowed and we have slot data but server didn't provide updated count, - // decrement by 1 (we just took a slot) - if (authTxAllowed && !authResult.containsKey('slots_available')) { - final available = _currentZone!['slots_available'] as int?; - if (available != null && available > 0) { - _currentZone!['slots_available'] = available - 1; - debugLog('[CAPACITY] Took a slot, slots_available=${available - 1}'); - } - } - - notifyListeners(); - } - - /// Start periodic zone status refresh while connected. - /// Keeps slot counts and capacity status fresh during a session. - void _startZoneRefreshTimer() { - _zoneRefreshTimer?.cancel(); - _zoneRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) async { - if (!isConnected || _preferences.offlineMode) { - _zoneRefreshTimer?.cancel(); - _zoneRefreshTimer = null; - return; - } - debugLog('[CAPACITY] Periodic zone refresh'); - await checkZoneStatus(); - }); - debugLog('[CAPACITY] Started 60s zone refresh timer'); - } - - /// Stop zone status refresh timer. - void _stopZoneRefreshTimer() { - _zoneRefreshTimer?.cancel(); - _zoneRefreshTimer = null; - } - /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -4288,38 +3235,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Debug File Logging (Mobile Only) // ============================================ - /// Initialize debug file logging, respecting persisted user preference. - /// Enabled by default on all builds. If the user previously disabled it, - /// that preference is restored. - Future _initDebugLogs() async { + /// Auto-enable debug file logging for development builds + Future _autoEnableDebugLogsIfDevelopmentBuild() async { if (kIsWeb) return; // File logging not available on web - try { - final box = await _openBoxSafely(_preferencesBoxName); - if (box == null) { - // Can't read preference — keep default (enabled, already started in main.dart) - _debugLogsEnabled = true; - await _refreshDebugLogFiles(); - return; - } - - final userDisabled = box.get('debug_logs_enabled') == false; - - if (userDisabled) { - debugLog('[INIT] Debug logs disabled by user preference, turning off'); - await DebugFileLogger.disable(); - _debugLogsEnabled = false; - DebugLogger.setEnabled(false); - } else { - debugLog('[INIT] Debug logging enabled (${AppConstants.appVersion})'); - // DebugFileLogger already enabled in main.dart + if (AppConstants.isDevelopmentBuild) { + debugLog('[INIT] Development build detected (${AppConstants.appVersion}), auto-enabling debug logs'); + try { + await DebugFileLogger.enable(); _debugLogsEnabled = true; await _refreshDebugLogFiles(); + } catch (e) { + debugError('[INIT] Failed to auto-enable debug logs: $e'); } - } catch (e) { - debugError('[INIT] Failed to init debug logs: $e'); - // Fallback: keep enabled (already started in main.dart) - _debugLogsEnabled = true; + } else { + debugLog('[INIT] Release build (${AppConstants.appVersion}), debug logs disabled by default'); } } @@ -4336,9 +3266,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _debugLogsEnabled = true; DebugLogger.setEnabled(true); await _refreshDebugLogFiles(); - // Persist user preference - final box = await _openBoxSafely(_preferencesBoxName); - await box?.put('debug_logs_enabled', true); notifyListeners(); debugLog('[DEBUG] Debug file logging enabled'); } catch (e) { @@ -4358,9 +3285,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await DebugFileLogger.disable(); _debugLogsEnabled = false; DebugLogger.setEnabled(false); - // Persist user preference - final box = await _openBoxSafely(_preferencesBoxName); - await box?.put('debug_logs_enabled', false); notifyListeners(); } catch (e) { debugError('[DEBUG] Failed to disable debug file logging: $e'); @@ -4706,10 +3630,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); - - // Apply saved min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); - PingService.currentMinDistance = _preferences.minPingDistanceMeters; } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -4763,40 +3683,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // ============================================ - // Device Power Override Persistence - // ============================================ - - /// Load per-device power overrides from Hive storage - Future _loadDevicePowerOverrides() async { - final box = await _openBoxSafely(_preferencesBoxName); - if (box == null) return; - - try { - final raw = box.get('device_power_overrides'); - if (raw != null) { - _devicePowerOverrides = (raw as Map).map( - (key, value) => MapEntry(key.toString(), Map.from(value as Map)), - ); - debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); - } - } catch (e) { - debugLog('[APP] Failed to load device power overrides: $e'); - } - } - - /// Save per-device power overrides to Hive storage - Future _saveDevicePowerOverrides() async { - final box = await _openBoxSafely(_preferencesBoxName); - if (box == null) return; - - try { - await box.put('device_power_overrides', _devicePowerOverrides); - } catch (e) { - debugLog('[APP] Failed to save device power overrides: $e'); - } - } - // ============================================ // Last Connected Device Persistence // ============================================ @@ -5059,12 +3945,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Cleanup // ============================================ - void _cancelPendingAutoPingRestore() { - _restoreAutoPingTimer?.cancel(); - _restoreAutoPingTimer = null; - _reconnectRestoreGeneration++; - } - @override @override void notifyListeners() { @@ -5076,9 +3956,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); - _connectionSubscription?.cancel(); - _gpsStatusSubscription?.cancel(); - _gpsPositionSubscription?.cancel(); _logRxDataSubscription?.cancel(); _noiseFloorSubscription?.cancel(); _batterySubscription?.cancel(); @@ -5087,10 +3964,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneCheckCountdownTimer?.cancel(); _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); - _restoreAutoPingTimer?.cancel(); - _offlineAutoSaveTimer?.cancel(); - _zoneRefreshTimer?.cancel(); - _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ce97f871..e186fe3e 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,7 +13,6 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; -import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; /// BLE device selection and connection screen @@ -75,50 +74,84 @@ class _ConnectionScreenState extends State with WidgetsBinding @override Widget build(BuildContext context) { final appState = context.watch(); + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + + // Build FAB for scanning - show Cancel during scan, Scan when idle + // Hide FAB entirely during maintenance mode (maintenance UI has its own buttons) + // Hide FAB when Bluetooth is off (shows full-screen message instead) + // Hide FAB during auto-reconnect + Widget? fab; + if (appState.isScanning) { + // Show Cancel FAB during active scan + fab = isLandscape + ? FloatingActionButton.small( + onPressed: () => appState.stopScan(), + backgroundColor: Colors.red, + child: const Icon(Icons.close), + ) + : FloatingActionButton.extended( + onPressed: () => appState.stopScan(), + icon: const Icon(Icons.close), + label: const Text('Cancel'), + backgroundColor: Colors.red, + ); + } else if (appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + !appState.isBluetoothOff) { + // Offline mode bypasses both zone and maintenance checks + final canScan = appState.offlineMode || appState.inZone == true; + fab = isLandscape + ? FloatingActionButton.small( + onPressed: canScan ? () => appState.startScan() : null, + backgroundColor: canScan ? null : Colors.grey, + child: const Icon(Icons.bluetooth_searching), + ) + : FloatingActionButton.extended( + onPressed: canScan ? () => appState.startScan() : null, + icon: const Icon(Icons.bluetooth_searching), + label: Text(appState.offlineMode + ? 'Scan' + : appState.gpsStatus == GpsStatus.disabled + ? 'GPS Disabled' + : appState.gpsStatus == GpsStatus.permissionDenied + ? 'GPS Required' + : appState.isCheckingZone + ? 'Checking Zone...' + : appState.inZone == true + ? 'Scan' + : appState.inZone == false + ? 'Outside Zone' + : 'Checking Zone...'), + backgroundColor: canScan ? null : Colors.grey, + ); + } return Scaffold( appBar: AppBar( - toolbarHeight: 40, - title: const Text('Connection', style: TextStyle(fontSize: 18)), + title: const Text('Connection'), automaticallyImplyLeading: false, ), body: _buildBody(context, appState), + floatingActionButton: fab, ); } Widget _buildBody(BuildContext context, AppStateProvider appState) { - // Reconnecting and connection progress don't show the zone bar or bottom bar + // Show reconnecting UI if (appState.connectionStep == ConnectionStep.reconnecting) { return _buildReconnectingView(context, appState); } + + // Show connection progress if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } - // All other states: zone bar top, content middle, action bar bottom - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildStateContent(context, appState)), - _buildBottomBar(context, appState), - ], - ); - } - - /// Routes to the correct sub-view (zone bar is already rendered above) - Widget _buildStateContent(BuildContext context, AppStateProvider appState) { // Show connected state if (appState.isConnected) { - final pathWarning = appState.pendingPathHashWarning; - if (pathWarning != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); - appState.clearPathHashWarning(); - }); - } return _buildConnectedInfo(context, appState); } @@ -131,99 +164,6 @@ class _ConnectionScreenState extends State with WidgetsBinding return _buildDeviceList(context, appState); } - /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect - Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - children: [ - const Expanded(child: OfflineModeToggle()), - const SizedBox(width: 12), - Expanded(child: _buildActionButton(context, appState)), - ], - ), - ); - } - - /// The right-side action button: Scan, Cancel, or Disconnect - Widget _buildActionButton(BuildContext context, AppStateProvider appState) { - if (appState.isConnected) { - // Disconnect - return _buildBottomButton( - icon: Icons.bluetooth_disabled, - label: 'Disconnect', - color: Colors.red, - onPressed: () async => await appState.disconnect(), - ); - } - - if (appState.isScanning) { - // Cancel scan - return _buildBottomButton( - icon: Icons.close, - label: 'Cancel', - color: Colors.red, - onPressed: () => appState.stopScan(), - ); - } - - // Scan — disabled when can't scan - final canScan = appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff && - (appState.offlineMode || appState.inZone == true); - - return _buildBottomButton( - icon: Icons.bluetooth_searching, - label: 'Scan', - color: Theme.of(context).colorScheme.primary, - onPressed: canScan ? () => appState.startScan() : null, - ); - } - - /// Styled button matching OfflineModeToggle shape - Widget _buildBottomButton({ - required IconData icon, - required String label, - required Color color, - VoidCallback? onPressed, - }) { - final enabled = onPressed != null; - final effectiveColor = enabled ? color : Colors.grey; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: effectiveColor.withValues(alpha: 0.4)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 18, color: effectiveColor), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: effectiveColor, - ), - ), - ], - ), - ), - ), - ); - } - Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; @@ -308,129 +248,132 @@ class _ConnectionScreenState extends State with WidgetsBinding // Get device name - uses displayDeviceName which prefers SelfInfo name over BLE advertisement name final deviceName = appState.displayDeviceName ?? 'Unknown'; - // Extract version from firmware version string (v7+), fall back to manufacturer string + // Parse version from manufacturer string and use shortName from device model for hardware + // Format examples: + // - "MeshCore (Heltec V3) v1.10.0" + // - "Ikoka Stick-E22-30dBm (Xiao_nrf52) nightly-e31c46f" String? version; - final fwString = appState.firmwareVersionString; - if (fwString != null && fwString.isNotEmpty) { - final semverMatch = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(fwString); - if (semverMatch != null) { - version = semverMatch.group(1); - } else { - final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); - if (nightlyMatch != null) { - version = nightlyMatch.group(1); - } - } - } - if (version == null) { - final manufacturerString = appState.manufacturerString; - if (manufacturerString != null) { - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); - final match = versionRegex.firstMatch(manufacturerString); - if (match != null) { - version = match.group(1); - } + final manufacturerString = appState.manufacturerString; + if (manufacturerString != null) { + // Use regex to find version pattern directly instead of splitting + // Match: v followed by digits/dots, OR nightly- followed by hex, OR just digits.digits + final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final match = versionRegex.firstMatch(manufacturerString); + if (match != null) { + version = match.group(1); } } - final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; - final platform = appState.deviceModel?.platform; + // Use shortName from device model if available, otherwise fall back to manufacturer string + final hardware = appState.deviceModel?.shortName ?? manufacturerString ?? 'Unknown'; + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; - final prefs = appState.preferences; - final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Compact device summary card - final deviceSummaryCard = Card( + // Build device info card + final deviceInfoCard = Card( child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Header: BT icon + name/status Row( children: [ - const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), - const SizedBox(width: 8), + const Icon( + Icons.bluetooth_connected, + color: Colors.green, + size: 28, + ), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - deviceName, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, + 'Connected', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + ), ), Text( - 'Connected', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.green, - ), + deviceName, + style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), ], ), - const SizedBox(height: 8), - - // Detail chips: hardware, version, platform - Wrap( - spacing: 6, - runSpacing: 4, - children: [ - _buildDetailChip(context, Icons.memory, hardware), - if (version != null) _buildDetailChip(context, Icons.code, version), - if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), - ], - ), - - // Power level row - const SizedBox(height: 4), - _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), - - // Public key row - if (appState.devicePublicKey != null) ...[ - const SizedBox(height: 8), + const Divider(height: 20), + _buildInfoRow('Hardware', hardware), + _buildInfoRow('Version', version ?? 'Unknown'), + if (appState.deviceModel != null) + _buildInfoRow('Platform', appState.deviceModel!.platform), + if (appState.devicePublicKey != null) _buildPublicKeyRow(context, appState.devicePublicKey!), - ], - - // Registered via row - if (appState.authType != null && !appState.offlineMode) ...[ - const SizedBox(height: 4), + if (appState.authType != null && !appState.offlineMode) _buildAuthTypeRow(context, appState.authType!), - ], ], ), ), ); - // Compact channels card - final channelsCard = RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - scope: appState.offlineMode ? null : appState.scope, - isOfflineMode: appState.offlineMode, - compact: true, + // Build disconnect button + final disconnectButton = ElevatedButton.icon( + onPressed: () async { + await appState.disconnect(); + }, + icon: const Icon(Icons.bluetooth_disabled), + label: const Text('Disconnect'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), ); if (isLandscape) { + // Landscape: two-column layout return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Left column: device info + disconnect Expanded( - child: SingleChildScrollView(child: deviceSummaryCard), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: deviceInfoCard, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: disconnectButton, + ), + ], + ), ), const SizedBox(width: 12), + // Right column: power + regional config Expanded( - child: SingleChildScrollView(child: channelsCard), + child: SingleChildScrollView( + child: Column( + children: [ + _buildPowerLevelCard(context, appState), + const SizedBox(height: 12), + RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + isOfflineMode: appState.offlineMode, + ), + ], + ), + ), ), ], ), @@ -438,122 +381,55 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Portrait: compact vertical layout (bottom bar provided by _buildBody) - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - deviceSummaryCard, - const SizedBox(height: 12), - channelsCard, - ], - ), - ); - } - - /// Power level row matching Registered via / Public Key format - Widget _buildPowerRow( - BuildContext context, - AppStateProvider appState, - bool isPowerSet, - bool isAutoMode, - UserPreferences prefs, - ) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: InkWell( - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( + // Portrait: vertical layout + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), children: [ - const SizedBox( - width: 120, - child: Text( - 'Power Level', - style: TextStyle(fontWeight: FontWeight.w500), - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), - const SizedBox(width: 4), - Text( - isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', - style: TextStyle( - fontWeight: FontWeight.w500, - color: isPowerSet ? null : Colors.orange, - ), - ), - if (prefs.autoPowerSet) ...[ - const SizedBox(width: 4), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), - const SizedBox(width: 2), - const Text( - 'Auto', - style: TextStyle( - fontSize: 12, - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ - const SizedBox(width: 4), - const Icon(Icons.edit, size: 14, color: Colors.orange), - const SizedBox(width: 2), - const Text( - 'Override', - style: TextStyle( - fontSize: 12, - color: Colors.orange, - fontWeight: FontWeight.bold, - ), - ), - ], - if (!isAutoMode) ...[ - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 16, color: Colors.grey), - ], - ], + deviceInfoCard, + const SizedBox(height: 16), + _buildPowerLevelCard(context, appState), + const SizedBox(height: 16), + RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + isOfflineMode: appState.offlineMode, ), ], ), ), - ), + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: disconnectButton, + ), + ), + ], ); } - /// Small detail chip with icon + text - Widget _buildDetailChip(BuildContext context, IconData icon, String text) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( - mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 12, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - text, - style: TextStyle( - fontSize: 11, - color: theme.colorScheme.onSurfaceVariant, + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w500), ), ), + Expanded(child: Text(value)), ], ), ); } - - Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -845,6 +721,50 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } + Widget _buildPowerLevelCard(BuildContext context, AppStateProvider appState) { + final prefs = appState.preferences; + final isAutoMode = appState.autoPingEnabled; + final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + + return Card( + child: ListTile( + leading: const Icon(Icons.power), + title: const Text('Power Level'), + subtitle: Builder( + builder: (context) { + if (!isPowerSet) { + return Text( + 'Unknown hardware - select power', + style: TextStyle(color: Colors.orange.shade700), + ); + } + return Row( + children: [ + Text(prefs.powerLevelDisplay), + if (prefs.autoPowerSet) ...[ + const SizedBox(width: 8), + const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const SizedBox(width: 4), + const Text( + 'Auto', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ); + }, + ), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + ), + ); + } + void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; @@ -879,7 +799,6 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, // Clear auto flag on override - powerLevelSet: true, // Mark as manually overridden ), ); Navigator.pop(context); // Close confirmation @@ -912,29 +831,23 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Auto-detection / override info banner - if (deviceModel != null) + // Auto-detection info banner + if (prefs.autoPowerSet && deviceModel != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), - border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), + color: Colors.green.withValues(alpha: 0.1), + border: Border.all(color: Colors.green), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - Icon( - prefs.autoPowerSet ? Icons.auto_awesome : Icons.edit, - size: 20, - color: prefs.autoPowerSet ? Colors.green : Colors.orange, - ), + const Icon(Icons.auto_awesome, size: 20, color: Colors.green), const SizedBox(width: 8), Expanded( child: Text( - prefs.autoPowerSet - ? 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W' - : 'Override active \u2014 ${deviceModel.shortName} auto-detects as ${deviceModel.power}W', + 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W', style: const TextStyle(fontSize: 12), ), ), @@ -954,7 +867,7 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = deviceModel != null && power == deviceModel.power; + final isRecommended = prefs.autoPowerSet && deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -1001,21 +914,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ], ), actions: [ - if (!prefs.autoPowerSet && deviceModel != null) - TextButton( - onPressed: () { - appState.updatePreferences( - prefs.copyWith( - powerLevel: deviceModel.power, - txPower: deviceModel.txPower, - autoPowerSet: true, - powerLevelSet: false, - ), - ); - Navigator.pop(context); - }, - child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), - ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), @@ -1036,22 +934,18 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: [ Icon( - appState.isNetworkError ? Icons.cloud_off : Icons.error_outline, + Icons.error_outline, size: isLandscape ? 48 : 64, - color: appState.isNetworkError ? Colors.orange : Colors.red, + color: Colors.red, ), SizedBox(height: isLandscape ? 8 : 16), Text( - appState.isNetworkError - ? 'Server Unreachable' - : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - appState.isNetworkError - ? 'MeshMapper services unreachable, try again or use offline mode.' - : appState.connectionError ?? 'Unknown error', + appState.connectionError ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), @@ -1088,17 +982,6 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; - // Network error: show wifi off indicator - } else if (appState.zoneCheckErrorReason == 'network') { - locationIcon = Icons.wifi_off; - locationText = 'No Internet'; - locationColor = Colors.red; - // GPS error: show GPS issue indicator - } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || - appState.zoneCheckErrorReason == 'gps_stale') { - locationIcon = Icons.gps_off; - locationText = 'GPS Unavailable'; - locationColor = Colors.orange; // Show "Checking Zone..." whenever a zone check is in progress // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { @@ -1269,89 +1152,103 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.engineering, - size: 64, - color: Colors.orange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'Maintenance Mode', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 32), - // Primary action: Enable Offline Mode - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - if (appState.maintenanceUrl != null) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('More Info'), - ), - ], - const SizedBox(height: 32), - // Info note at bottom - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + Icon( + Icons.engineering, + size: 64, + color: Colors.orange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Maintenance Mode', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 32), + // Primary action: Enable Offline Mode + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + if (appState.maintenanceUrl != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('More Info'), + ), + ], + const SizedBox(height: 32), + // Info note at bottom + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + ), + ), + ], ), ), ], ), ), - ], + ), ), - ), + ], ); } // Show Bluetooth off message (takes priority over zone checks) if (appState.isBluetoothOff) { - return _buildMessageContent( - context: context, - icon: Icons.bluetooth_disabled, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Bluetooth is Off', - message: 'Please enable Bluetooth to scan for MeshCore devices.', + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.bluetooth_disabled, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Bluetooth is Off', + message: 'Please enable Bluetooth to scan for MeshCore devices.', + ), + ), + ], ); } @@ -1369,150 +1266,71 @@ class _ConnectionScreenState extends State with WidgetsBinding message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } - return _buildMessageContent( - context: context, - icon: Icons.public_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Region Not Available', - message: message, - action: OutlinedButton.icon( - onPressed: () => _launchOnboardingUrl(), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Request Region Onboarding'), - ), + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.public_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Region Not Available', + message: message, + action: OutlinedButton.icon( + onPressed: () => _launchOnboardingUrl(), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Request Region Onboarding'), + ), + ), + ), + ], ); } // Show zone checking status on initial startup (before zone is known) if (appState.inZone == null && !appState.offlineMode) { if (appState.zoneCheckError != null) { + // Zone check failed — show error with countdown final countdown = appState.zoneCheckRetryCountdown; - - // Network error — show offline mode option (matches maintenance pattern) - if (appState.zoneCheckErrorReason == 'network') { - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off, - size: 64, - color: Colors.deepOrange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'No Internet Connection', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 8), - Text( - countdown > 0 - ? 'Retrying in ${countdown}s...' - : 'Retrying...', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade500, - ), - ), - const SizedBox(height: 32), - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - const SizedBox(height: 32), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - // GPS errors — no auto-retry, show manual retry button - if (appState.zoneCheckErrorReason == 'gps_inaccurate' || - appState.zoneCheckErrorReason == 'gps_stale') { - return _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: appState.zoneCheckErrorReason == 'gps_inaccurate' - ? 'GPS Accuracy Error' - : 'GPS Stale Error', - message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', - action: FilledButton.icon( - onPressed: () => appState.checkZoneStatus(), - icon: const Icon(Icons.refresh), - label: const Text('Retry Zone Check'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.cloud_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Zone Check Failed', + message: countdown > 0 + ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' + : '${appState.zoneCheckError}\n\nRetrying...', ), ), - ); - } - - // Other non-network errors — show simple error with countdown - return _buildMessageContent( - context: context, - icon: Icons.cloud_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Zone Check Failed', - message: countdown > 0 - ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' - : '${appState.zoneCheckError}\n\nRetrying...', + ], ); } // Zone check in progress or waiting for GPS - return _buildMessageContent( - context: context, - icon: Icons.location_searching, - iconColor: Colors.blue.withValues(alpha: 0.7), - title: 'Checking MeshMapper Zone...', - message: 'Verifying your location with MeshMapper', + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.location_searching, + iconColor: Colors.blue.withValues(alpha: 0.7), + title: 'Checking MeshMapper Zone...', + message: 'Verifying your location with MeshMapper', + ), + ), + ], ); } if (appState.isScanning) { return Column( children: [ + _buildZoneStatusBar(context, appState), const LinearProgressIndicator(), Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), ], @@ -1523,7 +1341,12 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect)), + ], + ); } // Show GPS disabled message when location services are off @@ -1531,48 +1354,74 @@ class _ConnectionScreenState extends State with WidgetsBinding // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', - action: isIOS - ? null - : ElevatedButton.icon( - onPressed: () => Geolocator.openLocationSettings(), - icon: const Icon(Icons.settings), - label: const Text('Open Location Settings'), - ), + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Location Services Disabled', + message: 'Please enable Location Services to verify you\'re in an allowed zone.', + action: isIOS + ? null + : ElevatedButton.icon( + onPressed: () => Geolocator.openLocationSettings(), + icon: const Icon(Icons.settings), + label: const Text('Open Location Settings'), + ), + ), + ), + ], ); } // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { - return _buildMessageContent( - context: context, - icon: Icons.location_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', - action: ElevatedButton.icon( - onPressed: () => _requestLocationPermission(appState), - icon: const Icon(Icons.location_on), - label: const Text('Enable Location'), - ), + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.location_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'GPS Permission Required', + message: 'Location access is needed to verify you\'re in an allowed zone.', + action: ElevatedButton.icon( + onPressed: () => _requestLocationPermission(appState), + icon: const Icon(Icons.location_on), + label: const Text('Enable Location'), + ), + ), + ), + ], ); } - return _buildMessageContent( - context: context, - icon: Icons.bluetooth_searching, - iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - title: 'No devices found', - message: 'Tap Scan to search for MeshCore devices', + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded( + child: _buildMessageContent( + context: context, + icon: Icons.bluetooth_searching, + iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + title: 'No devices found', + message: 'Tap Scan to search for MeshCore devices', + ), + ), + ], ); } - return _buildDeviceListView(context, appState, canConnect: canConnect); + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), + ], + ); } void _launchOnboardingUrl() async { @@ -1694,82 +1543,6 @@ class _ConnectionScreenState extends State with WidgetsBinding }, ); } - - void _showPathHashWarning(BuildContext context, int hopBytes, String reason) { - if (reason == 'firmware_unsupported') { - // Firmware too old to support multi-byte paths - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.warning_amber, size: 24, color: Colors.amber), - SizedBox(width: 8), - Flexible(child: Text('Firmware Update Recommended')), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Your regional admin has enabled $hopBytes-byte path mode for this zone, ' - 'but your companion firmware does not support it.', - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 12), - const Text( - 'The app will operate in 1-byte mode. Consider updating your companion ' - 'firmware to v1.14.0+ for more accurate repeater identification.', - style: TextStyle(fontSize: 13), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } else { - // Mode changed successfully - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.linear_scale, size: 24), - SizedBox(width: 8), - Flexible(child: Text('Multi-Byte Paths Enabled')), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Your radio has been updated from 1-byte to $hopBytes-byte paths for this session ($reason).', - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 12), - const Text( - 'Your radio will remain in multi-byte mode until you change it.', - style: TextStyle(fontSize: 13, color: Colors.amber), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } } class _DeviceListTile extends StatelessWidget { diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 977ce8cf..b9a4141d 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -19,13 +19,12 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - toolbarHeight: 40, - title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: const Text('Noise Floor History'), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) IconButton( - icon: const Icon(Icons.delete_outline, size: 20), + icon: const Icon(Icons.delete_outline), onPressed: () => _confirmClearSessions(context, appState), tooltip: 'Clear all sessions', ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d984a989..eacda815 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -175,14 +175,6 @@ class _HomeScreenState extends State { onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), - // Trace count - _buildAppBarStatChip( - Icons.route, - appState.pingStats.traceCount, - Colors.cyan, - onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, - ), - const SizedBox(width: 8), // Upload count _buildAppBarStatChip( Icons.cloud_done, @@ -238,7 +230,7 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -339,9 +331,6 @@ class _HomeScreenState extends State { case 'disc': return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); - case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); - case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -891,12 +880,20 @@ class _HomeScreenState extends State { description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), - // Trace Mode + // Offline mode toggle + _buildHelpItem( + icon: Icons.cloud_off, + color: Colors.orange, + title: 'Offline Mode', + description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.', + ), + + // Sound toggle _buildHelpItem( - icon: Icons.gps_fixed, - color: Colors.cyan, - title: 'Trace Mode', - description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', + icon: Icons.volume_up, + color: Colors.blue, + title: 'Sound', + description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.', ), const SizedBox(height: 8), diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 97267e0b..bb8a6a39 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -3,11 +3,10 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; -import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../widgets/repeater_id_chip.dart'; -/// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors +/// Log screen with tabs for TX Log, RX Log, and User Errors class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -17,12 +16,11 @@ class LogScreen extends StatefulWidget { class _LogScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; - final _allPingsKey = GlobalKey<_AllPingsTabState>(); @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 4, vsync: this); } @override @@ -38,155 +36,227 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 1) { - _tabController.animateTo(1); // Switch to Error tab + if (mounted && _tabController.index != 3) { + _tabController.animateTo(3); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); }); } - final totalPings = appState.txLogEntries.length + - appState.rxLogEntries.length + - appState.discLogEntries.length + - appState.traceLogEntries.length; - - final errorCount = appState.errorLogEntries.length; - return Scaffold( appBar: AppBar( - toolbarHeight: 40, - title: const Text('Logs', style: TextStyle(fontSize: 18)), + title: const Text('Logs'), actions: [ - PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 20), - padding: EdgeInsets.zero, - onSelected: (value) { - if (value == 'copy') _copyCurrentTabToCsv(context, appState); - if (value == 'clear') _confirmClearLogs(context, appState); - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), - const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), - ], + IconButton( + icon: const Icon(Icons.copy), + onPressed: () => _copyCurrentTabToCsv(context, appState), + tooltip: 'Copy CSV', ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(32), - child: TabBar( - controller: _tabController, - indicatorSize: TabBarIndicatorSize.tab, - dividerHeight: 1, - labelPadding: EdgeInsets.zero, - tabs: [ - Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), - Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), - ], + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmClearLogs(context, appState), + tooltip: 'Clear all logs', ), - ), + ], ), - body: TabBarView( - controller: _tabController, + body: Column( children: [ - _AllPingsTab( - key: _allPingsKey, - allEntries: appState.unifiedPingLogEntries, - repeaters: appState.repeaters, - txCount: appState.txLogEntries.length, - rxCount: appState.rxLogEntries.length, - discCount: appState.discLogEntries.length, - traceCount: appState.traceLogEntries.length, + // Secondary bar with tabs (full width) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), + const SizedBox(width: 8), + Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), + const SizedBox(width: 8), + Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), + const SizedBox(width: 8), + Expanded(child: _buildTabChip(3, 'Errors', appState.errorLogEntries.length, isError: true)), + ], + ), + ), + // Tab content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _TxLogTab(entries: appState.txLogEntries), + _RxLogTab(entries: appState.rxLogEntries), + _DiscLogTab(entries: appState.discLogEntries), + _ErrorLogTab(entries: appState.errorLogEntries), + ], + ), ), - _ErrorLogTab(entries: appState.errorLogEntries), ], ), ); } + /// Build a tab chip that matches StatusBar chip styling + Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false}) { + final theme = Theme.of(context); + // Colors matching status bar chips + const discColor = Color(0xFF7B68EE); // DISC purple + const txColor = Colors.green; // TX green (matches status bar) + const rxColor = Colors.blue; // RX blue (matches status bar) + + return GestureDetector( + onTap: () { + _tabController.animateTo(index); + setState(() {}); + }, + child: AnimatedBuilder( + animation: _tabController, + builder: (context, child) { + final isCurrentlySelected = _tabController.index == index; + Color currentColor; + if (isCurrentlySelected) { + if (isError) { + currentColor = Colors.red; + } else if (isDisc) { + currentColor = discColor; + } else if (isTx) { + currentColor = txColor; + } else if (isRx) { + currentColor = rxColor; + } else { + currentColor = theme.colorScheme.primary; + } + } else { + currentColor = theme.colorScheme.onSurfaceVariant; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: currentColor.withValues(alpha: isCurrentlySelected ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: currentColor.withValues(alpha: isCurrentlySelected ? 0.4 : 0.2), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, + color: currentColor, + ), + ), + if (count > 0) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: currentColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], + ), + ); + }, + ), + ); + } + void _copyCurrentTabToCsv(BuildContext context, AppStateProvider appState) { - if (_tabController.index == 0) { - _copyAllPingsToCsv(context, appState); - } else { - _copyErrorLogToCsv(context, appState.errorLogEntries); + final currentTab = _tabController.index; + + switch (currentTab) { + case 0: // TX Log + _copyTxLogToCsv(context, appState.txLogEntries); + break; + case 1: // RX Log + _copyRxLogToCsv(context, appState.rxLogEntries); + break; + case 2: // DISC Log + _copyDiscLogToCsv(context, appState.discLogEntries); + break; + case 3: // Error Log + _copyErrorLogToCsv(context, appState.errorLogEntries); + break; } } - void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { - // If a search filter is active, export only the filtered unified entries - final tabState = _allPingsKey.currentState; - final searchQuery = tabState?._searchQuery ?? ''; - if (searchQuery.isNotEmpty && tabState != null) { - final filtered = tabState._filteredEntries; - if (filtered.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), - ); - return; - } - final buffer = StringBuffer(); - buffer.writeln('type,data'); - for (final entry in filtered) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); + void _copyTxLogToCsv(BuildContext context, List entries) { + if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), + const SnackBar(content: Text('No TX log entries to copy'), duration: Duration(seconds: 2)), ); return; } - final tx = appState.txLogEntries; - final rx = appState.rxLogEntries; - final disc = appState.discLogEntries; - final trace = appState.traceLogEntries; + final buffer = StringBuffer(); + buffer.writeln('timestamp,latitude,longitude,power,events'); + for (final entry in entries) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TX log copied to clipboard'), duration: Duration(seconds: 2)), + ); + } - if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { + void _copyRxLogToCsv(BuildContext context, List entries) { + if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('No RX log entries to copy'), duration: Duration(seconds: 2)), ); return; } final buffer = StringBuffer(); - - if (tx.isNotEmpty) { - buffer.writeln('--- TX Log ---'); - buffer.writeln('timestamp,latitude,longitude,power,events'); - for (final entry in tx) { - buffer.writeln(entry.toCsv()); - } - buffer.writeln(); - } - - if (rx.isNotEmpty) { - buffer.writeln('--- RX Log ---'); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); - for (final entry in rx) { - buffer.writeln(entry.toCsv()); - } - buffer.writeln(); + buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + for (final entry in entries) { + buffer.writeln(entry.toCsv()); } + Clipboard.setData(ClipboardData(text: buffer.toString())); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('RX log copied to clipboard'), duration: Duration(seconds: 2)), + ); + } - if (disc.isNotEmpty) { - buffer.writeln('--- DISC Log ---'); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); - for (final entry in disc) { - buffer.writeln(entry.toCsv()); - } - buffer.writeln(); + void _copyDiscLogToCsv(BuildContext context, List entries) { + if (entries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No DISC log entries to copy'), duration: Duration(seconds: 2)), + ); + return; } - if (trace.isNotEmpty) { - buffer.writeln('--- TRC Log ---'); - buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); - for (final entry in trace) { - buffer.writeln(entry.toCsv()); - } + final buffer = StringBuffer(); + buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + for (final entry in entries) { + buffer.writeln(entry.toCsv()); } - Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('DISC log copied to clipboard'), duration: Duration(seconds: 2)), ); } @@ -214,7 +284,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), + content: const Text('This will clear TX, RX, DISC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -233,349 +303,231 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } } -// ============================================================================= -// All Pings Tab — unified chronological view with type filters -// ============================================================================= - -class _AllPingsTab extends StatefulWidget { - final List allEntries; - final List repeaters; - final int txCount; - final int rxCount; - final int discCount; - final int traceCount; - - const _AllPingsTab({ - super.key, - required this.allEntries, - required this.repeaters, - required this.txCount, - required this.rxCount, - required this.discCount, - required this.traceCount, - }); +/// TX Log Tab +class _TxLogTab extends StatelessWidget { + final List entries; - @override - State<_AllPingsTab> createState() => _AllPingsTabState(); -} - -class _AllPingsTabState extends State<_AllPingsTab> { - final Set _activeFilters = { - PingLogType.tx, - PingLogType.rx, - PingLogType.disc, - PingLogType.trace, - }; - - String _searchQuery = ''; - final TextEditingController _searchController = TextEditingController(); - - /// Current filtered entries (used by CSV export) - List _filteredEntries = []; + const _TxLogTab({required this.entries}); @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - void _toggleFilter(PingLogType type) { - setState(() { - if (_activeFilters.contains(type)) { - // Don't allow deselecting the last filter - if (_activeFilters.length > 1) { - _activeFilters.remove(type); - } - } else { - _activeFilters.add(type); - } - }); - } - - // --------------------------------------------------------------------------- - // Repeater name resolution & search matching - // --------------------------------------------------------------------------- - - /// Resolve a short repeater ID to known repeater names via prefix matching. - static ({List names, bool ambiguous}) _resolveRepeaterNames( - String repeaterId, List repeaters, - ) { - final idLower = repeaterId.toLowerCase(); - final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(idLower)) - .map((r) => r.name) - .toList(); - return (names: matches, ambiguous: matches.length > 1); - } - - /// Whether a hex ID has ambiguous name matches (maps to multiple repeaters). - static bool _isAmbiguousId(String repeaterId, List repeaters) { - return _resolveRepeaterNames(repeaterId, repeaters).ambiguous; - } - - /// True if the query looks like a hex string (only 0-9, a-f). - static bool _isHexQuery(String query) { - return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); - } - - /// Whether an entry matches the current search query. - bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { - if (_searchQuery.isEmpty) return true; - final query = _searchQuery.toLowerCase(); - - switch (entry.type) { - case PingLogType.tx: - final tx = entry.asTx; - for (final event in tx.events) { - if (event.repeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; - } - return false; - case PingLogType.rx: - final rx = entry.asRx; - if (rx.repeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(rx.repeaterId, repeaters); - return resolved.names.any((n) => n.toLowerCase().contains(query)); - case PingLogType.disc: - final disc = entry.asDisc; - for (final node in disc.discoveredNodes) { - if (node.repeaterId.toLowerCase().startsWith(query)) return true; - if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; - } - return false; - case PingLogType.trace: - final trace = entry.asTrace; - if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); - return resolved.names.any((n) => n.toLowerCase().contains(query)); + Widget build(BuildContext context) { + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.upload_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('No TX pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], + ), + ); } - } - /// Whether an entry should show the ambiguity indicator. - /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. - bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { - if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; - - switch (entry.type) { - case PingLogType.tx: - return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); - case PingLogType.rx: - return _isAmbiguousId(entry.asRx.repeaterId, repeaters); - case PingLogType.disc: - return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); - case PingLogType.trace: - return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); - } + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: entries.length, + itemBuilder: (context, index) { + // Most recent first (no reverse needed - entries already in chronological order) + final entry = entries[entries.length - 1 - index]; + return _buildTxEntry(context, entry); + }, + ); } - @override - Widget build(BuildContext context) { - final filtered = widget.allEntries - .where((e) => _activeFilters.contains(e.type)) - .where((e) => _matchesSearch(e, widget.repeaters)) - .toList(); - _filteredEntries = filtered; - - final hasEntries = widget.allEntries.isNotEmpty; - final hasResults = filtered.isNotEmpty; - - return Column( - children: [ - // Search bar - Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), - child: SizedBox( - height: 36, - child: TextField( - controller: _searchController, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - hintText: 'Search by repeater name or ID', - hintStyle: const TextStyle(fontSize: 13), - prefixIcon: const Icon(Icons.search, size: 18), - prefixIconConstraints: const BoxConstraints(minWidth: 36), - suffixIcon: _searchQuery.isNotEmpty - ? GestureDetector( - onTap: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - child: const Icon(Icons.close, size: 18), - ) - : null, - suffixIconConstraints: const BoxConstraints(minWidth: 36), - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 8), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + Widget _buildTxEntry(BuildContext context, TxLogEntry entry) { + final appState = context.read(); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () { + // Navigate to map and show this location + // Main scaffold will handle switching to map tab + appState.navigateToMapCoordinates(entry.latitude, entry.longitude); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Time and Power + Row( + children: [ + // Time badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + entry.timeString, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + const Spacer(), + // Power indicator (watts) + Text( + '${entry.power.toStringAsFixed(1)} W', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), ), - ), - onChanged: (value) => setState(() => _searchQuery = value.trim()), - ), - ), - ), - // Filter segmented row - Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - clipBehavior: Clip.antiAlias, - child: IntrinsicHeight( - child: Row( - children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), - _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), - _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), - _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), - ], - ), + ], ), - ), - ), - // List - Expanded( - child: !hasResults - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - hasEntries ? Icons.search_off : Icons.list_alt, - size: 48, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - hasEntries && _searchQuery.isNotEmpty - ? 'No results for \'$_searchQuery\'' - : 'No pings logged yet', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], + const SizedBox(height: 8), + + // Location + Row( + children: [ + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + entry.locationString, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', ), - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), - itemCount: filtered.length, - itemBuilder: (context, index) { - final unified = filtered[index]; - final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); - return switch (unified.type) { - PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), - PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), - PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), - PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), - }; - }, ), - ), - ], - ); - } + ], + ), - Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { - final active = _activeFilters.contains(type); - return Expanded( - child: GestureDetector( - onTap: () => _toggleFilter(type), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 7), - color: active ? color.withValues(alpha: 0.12) : Colors.transparent, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + // Repeaters table + if (entry.events.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 50, + child: Text( + 'Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.events.map((event) => _buildRepeaterRow(context, event)), + ], + ), + ), + ] else ...[ + const SizedBox(height: 8), Text( - label, + 'No repeaters heard', style: TextStyle( - fontSize: 13, - fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, ), ), - if (count > 0) ...[ - const SizedBox(width: 4), - Container( - constraints: const BoxConstraints(minWidth: 18, minHeight: 16), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white, - height: 1.0, - ), - ), - ), - ], ], - ), + ], ), ), + ), ); } - static Widget _segmentDivider(BuildContext context) { - return VerticalDivider( - width: 1, - thickness: 1, - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), - ); - } + /// Build a table row for a repeater event + Widget _buildRepeaterRow(BuildContext context, RxEvent event) { + Color snrColor; + switch (event.severity) { + case SnrSeverity.poor: + snrColor = Colors.red; + case SnrSeverity.fair: + snrColor = Colors.orange; + case SnrSeverity.good: + snrColor = Colors.green; + } - // --------------------------------------------------------------------------- - // Type badge - // --------------------------------------------------------------------------- - - static Widget _buildTypeBadge(PingLogType type) { - final (label, color) = switch (type) { - PingLogType.tx => ('TX', Colors.green), - PingLogType.rx => ('RX', Colors.blue), - PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), - PingLogType.trace => ('TRC', Colors.cyan), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: color, + // RSSI color based on signal strength + Color rssiColor; + if (event.rssi >= -70) { + rssiColor = Colors.green; + } else if (event.rssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Repeater ID + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 50), + // SNR + Expanded( + child: Center( + child: _buildTxChip(event.snr.toStringAsFixed(1), snrColor), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildTxChip('${event.rssi}', rssiColor), + ), + ), + ], ), ), ); } - // --------------------------------------------------------------------------- - // Shared chip builder - // --------------------------------------------------------------------------- - - static Widget _buildChip(String value, Color color) { + /// Build a small colored chip for TX table cells + Widget _buildTxChip(String value, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -586,7 +538,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Text( value, style: TextStyle( - fontSize: 11, + fontSize: 10, fontWeight: FontWeight.w600, color: color, fontFamily: 'monospace', @@ -594,172 +546,326 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ); } +} + +/// RX Log Tab +class _RxLogTab extends StatelessWidget { + final List entries; + + const _RxLogTab({required this.entries}); + + @override + Widget build(BuildContext context) { + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('No RX observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], + ), + ); + } - // --------------------------------------------------------------------------- - // TX Card - // --------------------------------------------------------------------------- + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: entries.length, + itemBuilder: (context, index) { + // Most recent first (no reverse needed - entries already in chronological order) + final entry = entries[entries.length - 1 - index]; + return _buildRxEntry(context, entry); + }, + ); + } - Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildRxEntry(BuildContext context, RxLogEntry entry) { final appState = context.read(); + + Color snrColor; + switch (entry.severity) { + case SnrSeverity.poor: + snrColor = Colors.red; + case SnrSeverity.fair: + snrColor = Colors.orange; + case SnrSeverity.good: + snrColor = Colors.green; + } + + // RSSI color based on signal strength + Color rssiColor; + if (entry.rssi >= -70) { + rssiColor = Colors.green; // Strong: -30 to -70 dBm + } else if (entry.rssi >= -100) { + rssiColor = Colors.orange; // Medium: -70 to -100 dBm + } else { + rssiColor = Colors.red; // Weak: -100 to -120 dBm + } + return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () { + // Navigate to map and show this location + // Main scaffold will handle switching to map tab + appState.navigateToMapCoordinates(entry.latitude, entry.longitude); + }, child: Padding( padding: const EdgeInsets.all(12), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), - // Repeaters table - if (entry.events.isNotEmpty) ...[ - const SizedBox(height: 10), - _buildRepeaterTable(context, entry.events), - ] else ...[ - const SizedBox(height: 8), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Time badge only + Row( + children: [ + // Time badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + entry.timeString, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // Location + Row( + children: [ + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 4), Text( - 'No repeaters heard', + entry.locationString, style: TextStyle( - fontSize: 12, + fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + fontFamily: 'monospace', ), ), ], - ], - ), + ), + const SizedBox(height: 10), + + // Repeater table (single row) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 50, + child: Text( + 'Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Repeater ID + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 50), + // SNR + Expanded( + child: Center( + child: _buildRxChip(entry.snr.toStringAsFixed(1), snrColor), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildRxChip('${entry.rssi}', rssiColor), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], ), ), + ), ); } - Widget _buildRepeaterTable(BuildContext context, List events) { + /// Build a small colored chip for RX table cells + Widget _buildRxChip(String value, Color color) { return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'SNR', center: true)), - Expanded(child: _tableHeader(context, 'RSSI', center: true)), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - ...events.map((event) => _buildTxRepeaterRow(context, event)), - ], + child: Text( + value, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + fontFamily: 'monospace', + ), ), ); } +} - Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { - final snrColor = _snrColor(event.severity); - final rssiColor = _rssiColor(event.rssi); - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( +/// DISC Log Tab (Discovery observations from Passive Mode) +class _DiscLogTab extends StatelessWidget { + final List entries; + + const _DiscLogTab({required this.entries}); + + @override + Widget build(BuildContext context) { + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), + Icon(Icons.radar_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('No discovery observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + const SizedBox(height: 8), + Text('Enable Passive Mode to discover nearby nodes', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12)), ], ), - ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; // Already sorted most recent first + return _buildDiscEntry(context, entry); + }, ); } - // --------------------------------------------------------------------------- - // RX Card - // --------------------------------------------------------------------------- - - Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildDiscEntry(BuildContext context, DiscLogEntry entry) { final appState = context.read(); - final snrColor = _snrColor(entry.severity); - final rssiColor = _rssiColor(entry.rssi); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () { + // Navigate to map and show this location + appState.navigateToMapCoordinates(entry.latitude, entry.longitude); + }, child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), - const SizedBox(height: 10), - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'SNR', center: true)), - Expanded(child: _tableHeader(context, 'RSSI', center: true)), - ], - ), + // Header row: Time and node count (matching TX log style) + Row( + children: [ + // Time badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), - ], - ), + child: Text( + entry.timeString, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, ), ), - ], - ), + ), + const Spacer(), + // Node count indicator (like power indicator in TX log) + Text( + '${entry.nodeCount} node${entry.nodeCount == 1 ? '' : 's'}', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], ), - ], - ), - ), - ), - ); - } + const SizedBox(height: 8), - // --------------------------------------------------------------------------- - // DISC Card - // --------------------------------------------------------------------------- - - Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { - final appState = context.read(); + // Location + Row( + children: [ + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + entry.locationString, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], + ), - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), @@ -771,19 +877,61 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), child: Column( children: [ + // Header row Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 50, + child: Text( + 'Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), + // Data rows + ...entry.discoveredNodes.map((node) => _buildNodeRow(context, node)), ], ), ), @@ -792,7 +940,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Text( 'No nodes discovered', style: TextStyle( - fontSize: 12, + fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -805,9 +953,20 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { - final rxSnrColor = _snrColorFromValue(node.localSnr); - final rssiColor = _rssiColor(node.localRssi); + /// Build a table row for a discovered node + Widget _buildNodeRow(BuildContext context, DiscoveredNodeEntry node) { + // Color for RX SNR (what we received) + Color rxSnrColor; + switch (node.severity) { + case SnrSeverity.poor: + rxSnrColor = Colors.red; + case SnrSeverity.fair: + rxSnrColor = Colors.orange; + case SnrSeverity.good: + rxSnrColor = Colors.green; + } + + // TX SNR color (what they received from us) Color txSnrColor; if (node.remoteSnr <= -1) { txSnrColor = Colors.red; @@ -817,21 +976,32 @@ class _AllPingsTabState extends State<_AllPingsTab> { txSnrColor = Colors.green; } + // RSSI color based on signal strength + Color rssiColor; + if (node.localRssi >= -70) { + rssiColor = Colors.green; + } else if (node.localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ + // Node ID with type SizedBox( - width: 70, + width: 50, child: Row( children: [ - Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), + RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), Text( node.nodeTypeLabel, style: const TextStyle( - fontSize: 11, + fontSize: 9, fontWeight: FontWeight.w500, color: Color(0xFF7B68EE), ), @@ -839,181 +1009,53 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), - Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), - Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), - Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), + // RX SNR + Expanded( + child: Center( + child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildChip('${node.localRssi}', rssiColor), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor), + ), + ), ], ), ), ); } - // --------------------------------------------------------------------------- - // Trace Card - // --------------------------------------------------------------------------- - - Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { - final colorScheme = Theme.of(context).colorScheme; - final appState = context.read(); - - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), - // Results table - if (entry.success) ...[ - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - _buildTraceNodeRow(context, entry), - ], - ), - ), - ] else ...[ - const SizedBox(height: 8), - Text( - 'No response', - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - ), - ); - } - - Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { - final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); - final rssiColor = _rssiColor(entry.localRssi); - final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), - Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), - Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), - Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), - ], + /// Build a small colored chip for table cells + Widget _buildChip(String value, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), ), - ); - } - - // --------------------------------------------------------------------------- - // Shared helpers - // --------------------------------------------------------------------------- - - static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { - return Row( - children: [ - _buildTypeBadge(type), - if (showAmbiguity) ...[ - const SizedBox(width: 2), - Tooltip( - message: 'Repeater ID matches multiple nodes', - child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), - ), - ], - const SizedBox(width: 6), - Text( - timeString, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 2), - Text( - locationString, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), + child: Text( + value, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + fontFamily: 'monospace', ), - ], - ); - } - - static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { - return Text( - text, - textAlign: center ? TextAlign.center : TextAlign.left, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } - - static Color _snrColor(SnrSeverity? severity) { - return switch (severity) { - SnrSeverity.poor => Colors.red, - SnrSeverity.fair => Colors.orange, - SnrSeverity.good => Colors.green, - null => Colors.grey, - }; - } - - static Color _snrColorFromValue(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } - - static Color _snrColorFromNullableValue(double? snr) { - if (snr == null) return Colors.grey; - return _snrColorFromValue(snr); - } - - static Color _rssiColor(int? rssi) { - if (rssi == null) return Colors.grey; - if (rssi >= -70) return Colors.green; - if (rssi >= -100) return Colors.orange; - return Colors.red; - } } -// ============================================================================= -// Error Log Tab (unchanged) -// ============================================================================= - +/// Error Log Tab class _ErrorLogTab extends StatelessWidget { final List entries; @@ -1038,7 +1080,7 @@ class _ErrorLogTab extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: entries.length, itemBuilder: (context, index) { - // Most recent first + // Most recent first (no reverse needed - entries already in chronological order) final entry = entries[entries.length - 1 - index]; return _buildErrorEntry(context, entry); }, diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 87c2c435..b0bc9034 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -26,7 +26,6 @@ class MainScaffold extends StatefulWidget { class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; - bool _hasShownLocationSettingsPrompt = false; final List _screens = [ const HomeScreen(), @@ -60,15 +59,18 @@ class _MainScaffoldState extends State { // Check if disclosure was already shown final hasShown = await PermissionDisclosureService.hasShownDisclosure(); - if (!hasShown) { - // Show the disclosure dialog - if (!mounted) return; - debugLog('[DISCLOSURE] Showing location disclosure dialog'); - await PermissionDisclosureService.showLocationDisclosure(context); + if (hasShown) { + debugLog('[DISCLOSURE] Already shown, skipping'); + return; } - debugLog('[DISCLOSURE] Ensuring location permission after disclosure'); - await _ensureLocationPermission(); + // Show the disclosure dialog + if (!mounted) return; + debugLog('[DISCLOSURE] Showing location disclosure dialog'); + await PermissionDisclosureService.showLocationDisclosure(context); + + debugLog('[DISCLOSURE] User acknowledged, requesting permissions'); + await _requestPermissionsAfterDisclosure(); } /// Request GPS permission on web (triggers browser's native prompt) @@ -91,10 +93,8 @@ class _MainScaffoldState extends State { } } - /// Ensure location permission after disclosure has been shown. - /// Requests when possible, restarts GPS when granted, and surfaces a settings CTA - /// when the permission has been permanently denied. - Future _ensureLocationPermission() async { + /// Request permissions after user accepts disclosure + Future _requestPermissionsAfterDisclosure() async { bool granted = false; if (Platform.isIOS) { @@ -104,23 +104,12 @@ class _MainScaffoldState extends State { permission = await Geolocator.requestPermission(); } debugLog('[DISCLOSURE] iOS location permission: $permission'); - if (permission == LocationPermission.deniedForever) { - _showLocationSettingsPrompt(); - return; - } granted = permission == LocationPermission.always || permission == LocationPermission.whileInUse; } else { - // Android: only request if needed so previously granted permission just restarts GPS. - var status = await Permission.locationWhenInUse.status; - if (status.isDenied) { - status = await Permission.locationWhenInUse.request(); - } + // Android: Request location via permission_handler + final status = await Permission.locationWhenInUse.request(); debugLog('[DISCLOSURE] Android location permission: $status'); - if (status.isPermanentlyDenied) { - _showLocationSettingsPrompt(); - return; - } granted = status.isGranted; } @@ -132,21 +121,6 @@ class _MainScaffoldState extends State { } } - void _showLocationSettingsPrompt() { - if (!mounted || _hasShownLocationSettingsPrompt) return; - _hasShownLocationSettingsPrompt = true; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location permission is disabled in system settings.'), - action: SnackBarAction( - label: 'Settings', - onPressed: Geolocator.openAppSettings, - ), - ), - ); - } - @override Widget build(BuildContext context) { final appState = context.watch(); @@ -175,18 +149,6 @@ class _MainScaffoldState extends State { }); } - // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) - if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _selectedIndex = 3; // Switch to Connect tab - }); - appState.clearConnectionTabSwitchRequest(); - } - }); - } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c99446f8..b0132083 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -14,18 +14,17 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../utils/web_file_helpers_stub.dart' if (dart.library.html) '../utils/web_file_helpers.dart'; -import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../models/user_preferences.dart'; import '../services/debug_file_logger.dart'; +import '../services/debug_submit_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/offline_session_service.dart'; import '../services/permission_disclosure_service.dart'; import '../utils/constants.dart'; import '../widgets/bug_report_dialog.dart'; -import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; @@ -42,19 +41,93 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { - final result = await showUploadLogsDialog(context, appState); + // Debug log upload tracking + String? _uploadingFilePath; + final Set _uploadedFiles = {}; - if (!context.mounted || result == null) return; + Future _uploadSingleLogFile(AppStateProvider appState, File file) async { + final filename = file.path.split('/').last; - if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; - if (result.failedCount > 0) { - message += ' (${result.failedCount} failed)'; + setState(() { + _uploadingFilePath = file.path; + }); + + try { + final service = DebugSubmitService(); + + // Use persistent device info + final publicKey = appState.devicePublicKey ?? + appState.lastConnectedPublicKey ?? + 'not-connected'; + final deviceName = appState.lastConnectedDeviceName ?? 'not-connected'; + + final success = await service.uploadDebugFileOnly( + file: file, + deviceId: deviceName, + publicKey: publicKey, + appVersion: AppConstants.appVersion, + devicePlatform: DebugSubmitService.getDevicePlatform(), + userNotes: 'Direct debug log upload from $deviceName', + ); + + service.dispose(); + + if (!mounted) return; + + if (success) { + setState(() { + _uploadedFiles.add(file.path); + _uploadingFilePath = null; + }); + AppToast.success(context, 'Uploaded $filename'); + } else { + setState(() { + _uploadingFilePath = null; + }); + AppToast.error(context, 'Failed to upload $filename'); + } + } catch (e) { + debugError('[SETTINGS] Log file upload error: $e'); + if (mounted) { + setState(() { + _uploadingFilePath = null; + }); + AppToast.error(context, 'Upload error: $e'); + } + } + } + + /// Upload the current (active) log file by rotating it first + Future _uploadCurrentLogFile(AppStateProvider appState, File currentFile) async { + final originalPath = currentFile.path; + + setState(() { + _uploadingFilePath = originalPath; + }); + + try { + // Rotate the log: closes current file, starts a new one + // The original file is now closed and safe to upload + await appState.prepareDebugLogsForUpload(); + + if (!mounted) return; + + // The original file still exists at the same path, now closed + final closedFile = File(originalPath); + if (!closedFile.existsSync()) { + throw Exception('Log file not found after rotation'); + } + + // Now upload the closed file + await _uploadSingleLogFile(appState, closedFile); + } catch (e) { + debugError('[SETTINGS] Current log upload error: $e'); + if (mounted) { + setState(() { + _uploadingFilePath = null; + }); + AppToast.error(context, 'Upload error: $e'); } - AppToast.success(context, message); - } else if (result.errorMessage != null) { - AppToast.error(context, result.errorMessage!); } } @@ -102,807 +175,633 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( - toolbarHeight: 40, - title: const Text('Settings', style: TextStyle(fontSize: 18)), + title: const Text('Settings'), automaticallyImplyLeading: false, ), body: ListView( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ - // Lock indicator - if (isAutoMode) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), - ), - child: const Row( - children: [ - Icon(Icons.lock, size: 16, color: Colors.amber), - SizedBox(width: 8), - Text( - 'Some settings locked during auto-ping', - style: TextStyle(fontSize: 12, color: Colors.amber), - ), - ], - ), - ), + // Appearance section + _buildSectionHeader(context, 'Appearance'), + ListTile( + leading: Icon( + prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, ), - - // General - _buildSection(context, 'General', [ - SwitchListTile( - secondary: Icon( - prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, - ), - title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + title: const Text('Theme'), + subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + trailing: Switch( value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - SwitchListTile( - secondary: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + ), + ListTile( + leading: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + trailing: Switch( value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); }, ), - SwitchListTile( - secondary: const Icon(Icons.cell_tower), - title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR from last ping'), - value: prefs.showTopRepeaters, - onChanged: (value) { - appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); - }, - ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), - ]), + ), - // Ping Settings - _buildSection(context, 'Ping Settings', [ - SwitchListTile( - secondary: const Icon(Icons.visibility_off), - title: const Text('Anonymous Mode'), - subtitle: Text(prefs.anonymousMode - ? 'Device broadcasts as "Anonymous"' - : 'Device uses its real name'), - value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, - ), - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Auto-Ping Interval'), - subtitle: Text(prefs.autoPingIntervalDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + const Divider(), + + // Wardriving Settings section + _buildSectionHeader(context, 'Wardriving Settings'), + + // Auto-Ping Interval Selector + ListTile( + leading: const Icon(Icons.timer), + title: const Text('Auto-Ping Interval'), + subtitle: Text(prefs.autoPingIntervalDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + ), + + // Hybrid Mode Toggle + SwitchListTile( + secondary: const Icon(Icons.compare_arrows), + title: Row( + children: [ + const Text('Hybrid Mode'), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _showHybridModeInfo(context), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), + subtitle: const Text('Combines Active and Passive modes'), + value: prefs.hybridModeEnabled, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); + }, + ), + + // Carpeater Ignore Setting + SwitchListTile( + secondary: const Icon(Icons.filter_alt), + title: const Text('Ignore Carpeater'), + subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Filtering repeater 0x${prefs.ignoreRepeaterId}' + : 'Tap to set repeater ID to ignore'), + value: prefs.ignoreCarpeater, + onChanged: isAutoMode ? null : (value) { + if (value && prefs.ignoreRepeaterId == null) { + // Show dialog to set repeater ID when enabling + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); + } + }, + ), + + // Repeater ID to Ignore - show when enabled + if (prefs.ignoreCarpeater) ListTile( - leading: const Icon(Icons.straighten), - title: const Text('Min Ping Distance'), - subtitle: Text(prefs.minPingDistanceDisplay), + leading: const SizedBox(width: 24), // Indent + title: const Text('Repeater ID'), + subtitle: Text(prefs.ignoreRepeaterId != null + ? '0x${prefs.ignoreRepeaterId}' + : 'Not set'), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), - ), - SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), + onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), ), - SwitchListTile( - secondary: const Icon(Icons.timer_off), - title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), - value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, - ), - ]), - // Modes - _buildSection(context, 'Modes', [ - SwitchListTile( - secondary: const Icon(Icons.compare_arrows), - title: Row( + // Lock indicator + if (isAutoMode) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - IconButton( - onPressed: () => _showHybridModeInfo(context), - icon: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Icon(Icons.lock, size: 16, color: Colors.orange.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Settings locked during auto-ping mode', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + fontStyle: FontStyle.italic, + ), ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), ), ], ), - subtitle: appState.enforceHybrid - ? const Text( - 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', - style: TextStyle(color: Colors.amber), - ) - : const Text('Combines Active and Passive modes'), - value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, ), - SwitchListTile( - secondary: const Icon(Icons.signal_wifi_off), - title: Row( - children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - IconButton( - onPressed: () => _showDiscDropInfo(context), - icon: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - subtitle: appState.enforceDiscDrop - ? const Text( - 'Set by Regional Admin — reports dead zones for network analysis.', - style: TextStyle(color: Colors.amber), - ) - : const Text('Count failed discoveries as failed pings'), - value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value == true) { - _showDiscDropEnableConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); - } - }, + + // Background Mode - for "Always" location permission (iOS and Android) + if (!kIsWeb) ...[ + const Divider(), + _buildSectionHeader(context, 'Background Mode'), + _BackgroundModeToggle(appState: appState), + ], + + const Divider(), + + // Data Management section + _buildSectionHeader(context, 'Data Management'), + ListTile( + leading: const Icon(Icons.cloud_queue), + title: const Text('Queued Pings'), + subtitle: Text('${appState.queueSize} items waiting'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.cloud_upload), + onPressed: appState.queueSize > 0 + ? () => appState.forceUploadQueue() + : null, + tooltip: 'Force upload', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: appState.queueSize > 0 + ? () => _confirmClearQueue(context, appState) + : null, + tooltip: 'Clear queue', + ), + ], ), - ]), + ), + + // Clear Map Markers + ListTile( + leading: const Icon(Icons.delete_sweep), + title: const Text('Clear Map Markers'), + subtitle: const Text('Remove all TX/RX markers from map'), + onTap: () => _confirmClearPings(context, appState), + ), + + // Offline Sessions + if (appState.offlineSessions.isNotEmpty) ...[ + _buildSectionHeader(context, 'Offline Sessions'), + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + onUpload: () => _uploadOfflineSession(context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), + onDownload: () => _downloadOfflineSession(context, appState, session.filename), + )), + ], + + const Divider(), + + // Device Info section + _buildSectionHeader(context, 'Device'), + ListTile( + leading: const Icon(Icons.perm_identity), + title: const Text('Device ID'), + subtitle: Text(appState.deviceId), + ), + + const Divider(), + + // About section + _buildSectionHeader(context, 'About'), + const ListTile( + leading: Icon(Icons.info_outline), + title: Text(AppConstants.appName), + ), + GestureDetector( + onTap: () => _onVersionTap(appState), + child: ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: const Text('Version'), + subtitle: Text(AppConstants.appVersion), + ), + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('Submit Feedback'), + subtitle: const Text('Report bugs or request features'), + onTap: () => _showBugReportDialog(context, appState), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.github), + title: const Text('GitHub'), + subtitle: const Text('View issues and source code'), + onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Discord'), + subtitle: const Text('Join our community chat'), + onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), + ), + ListTile( + leading: const Icon(Icons.groups), + title: const Text('Community'), + subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + onTap: () => _launchUrl('https://ottawamesh.ca/'), + ), + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee ☕'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), + + // Exit Options (Android only) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ...[ + const Divider(), + _buildSectionHeader(context, 'Exit Options'), - // Filtering - _buildSection(context, 'Filtering', [ + // Auto-close toggle SwitchListTile( - secondary: const Icon(Icons.filter_alt), - title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), - value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, + secondary: const Icon(Icons.exit_to_app), + title: const Text('Close App After Disconnect'), + subtitle: const Text('Automatically exit the app when disconnecting'), + value: prefs.closeAppAfterDisconnect, + onChanged: (value) => appState.setCloseAppAfterDisconnect(value), ), - if (prefs.ignoreCarpeater) - ListTile( - leading: const SizedBox(width: 24), - title: const Text('CARpeater ID'), - subtitle: Text(prefs.ignoreRepeaterId != null - ? '0x${prefs.ignoreRepeaterId}' - : 'Not set'), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), - ), + + // Manual close button + ListTile( + leading: const Icon(Icons.power_settings_new, color: Colors.red), + title: const Text('Close App'), + subtitle: const Text('Exit the app completely'), + onTap: () => _showCloseAppConfirmation(context, appState), + ), + ], + + // Developer Tools section - only visible when developer mode is enabled + if (appState.developerModeEnabled) ...[ + const Divider(), + + _buildSectionHeader(context, 'Developer Tools'), + + // Developer mode toggle (to disable) SwitchListTile( - secondary: const Icon(Icons.shield_outlined), - title: const Text('Disable RSSI Filter'), - subtitle: Text(prefs.disableRssiFilter - ? 'Allows all signal strengths' - : 'Drops signals stronger than -30 dBm'), - value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } + secondary: const Icon(Icons.developer_mode), + title: const Text('Developer Mode'), + subtitle: const Text('Disable to hide developer tools'), + value: appState.developerModeEnabled, + onChanged: (value) { + appState.setDeveloperMode(value); }, ), - ]), - // Radio Settings - _buildSection(context, 'Radio', [ - ListTile( - leading: const Icon(Icons.linear_scale), - title: Row( - children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - IconButton( - onPressed: () => _showHopBytesInfo(context), - icon: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, + // GPS Simulator Toggle + SwitchListTile( + secondary: Icon( + Icons.gps_fixed, + color: appState.isGpsSimulatorEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('GPS Simulator'), + if (appState.isGpsSimulatorEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SIMULATED', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), ), ], - ), - subtitle: appState.enforceHopBytes - ? const Text( - 'Set by Regional Admin — larger IDs reduce collisions in your region.', - style: TextStyle(color: Colors.amber), - ) - : (appState.isConnected && !appState.supportsMultiBytePaths) - ? const Text( - 'Firmware 1.14+ required', - style: TextStyle(color: Colors.amber), - ) - : !appState.isConnected - ? const Text( - 'Connect to radio to configure', - style: TextStyle(color: Colors.amber), - ) - : const Text('Repeater ID size in TX/RX path hops'), - trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, - underline: const SizedBox(), - items: const [ - DropdownMenuItem(value: 1, child: Text('1')), - DropdownMenuItem(value: 2, child: Text('2')), - DropdownMenuItem(value: 3, child: Text('3')), - ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) - ? null - : (value) { - if (value != null) appState.setHopBytes(value); - }, - ), + ], ), + subtitle: Text(appState.isGpsSimulatorEnabled + ? 'Smooth simulated movement active' + : 'Use simulated GPS for testing'), + value: appState.isGpsSimulatorEnabled, + onChanged: (value) { + if (value) { + appState.enableGpsSimulator(); + } else { + appState.disableGpsSimulator(); + } + }, + ), + + // Simulator Settings (only when enabled) + if (appState.isGpsSimulatorEnabled) ...[ + // Speed Slider ListTile( - leading: const Icon(Icons.gps_fixed), - title: Row( - children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - IconButton( - onPressed: () => _showTraceBytesInfo(context), - icon: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], + leading: const SizedBox(width: 24), + title: const Text('Simulation Speed'), + subtitle: Slider( + value: appState.gpsSimulatorSpeed, + min: 10, + max: 120, + divisions: 11, + label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + onChanged: (value) { + appState.setGpsSimulatorSpeed(value); + }, ), - subtitle: !appState.isConnected - ? const Text( - 'Connect to radio to configure', - style: TextStyle(color: Colors.amber), - ) - : (appState.isConnected && !appState.supportsMultiBytePaths) - ? const Text( - 'Firmware 1.14+ required', - style: TextStyle(color: Colors.amber), - ) - : const Text('Repeater ID size in trace path'), - trailing: DropdownButton( - value: appState.traceHopBytes, - underline: const SizedBox(), - items: const [ - DropdownMenuItem(value: 1, child: Text('1')), - DropdownMenuItem(value: 2, child: Text('2')), - DropdownMenuItem(value: 4, child: Text('4')), - ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) - ? null - : (value) { - if (value != null) appState.setTraceHopBytes(value); - }, + trailing: Text( + formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + style: const TextStyle(fontWeight: FontWeight.bold), ), ), - SwitchListTile( - secondary: const Icon(Icons.delete_sweep), - title: Row( - children: [ - const Flexible(child: Text('Delete Channel on Disconnect')), - const SizedBox(width: 4), - IconButton( - onPressed: () => _showDeleteChannelInfo(context), - icon: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, + + // Pattern Selector + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Movement Pattern'), + trailing: SizedBox( + width: 180, + child: DropdownButton( + value: appState.gpsSimulatorPattern, + underline: const SizedBox(), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: SimulatorPattern.straight, + child: Text('Straight Line', overflow: TextOverflow.ellipsis), ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], + const DropdownMenuItem( + value: SimulatorPattern.circle, + child: Text('Circle', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.randomWalk, + child: Text('Random Walk', overflow: TextOverflow.ellipsis), + ), + if (appState.hasSimulatorRoute) + DropdownMenuItem( + value: SimulatorPattern.route, + child: Text( + 'Route: ${appState.simulatorRouteName ?? "Loaded"}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (pattern) { + if (pattern != null) { + appState.setGpsSimulatorPattern(pattern); + } + }, + ), ), - subtitle: Text(prefs.deleteChannelOnDisconnect - ? 'Removes #wardriving channel from device' - : 'Keeps #wardriving channel on device'), - value: prefs.deleteChannelOnDisconnect, - onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); - }, ), - ]), - // Data Management - _buildSection(context, 'Data', [ + // Load Route File ListTile( - leading: const Icon(Icons.cloud_queue), - title: const Text('Queued Pings'), - subtitle: Text('${appState.queueSize} items waiting'), + leading: const SizedBox(width: 24), + title: const Text('Load Route File'), + subtitle: Text(appState.hasSimulatorRoute + ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' + : 'KML or GPX file'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: appState.queueSize > 0 - ? () => appState.forceUploadQueue() - : null, - tooltip: 'Force upload', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: appState.queueSize > 0 - ? () => _confirmClearQueue(context, appState) - : null, - tooltip: 'Clear queue', + icon: const Icon(Icons.folder_open), + onPressed: () => _pickRouteFile(context, appState), + tooltip: 'Load route file', ), + if (appState.hasSimulatorRoute) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + appState.clearSimulatorRoute(); + AppToast.info(context, 'Route cleared'); + }, + tooltip: 'Clear route', + ), ], ), ), - ListTile( - leading: const Icon(Icons.delete_sweep), - title: const Text('Clear Map Markers'), - subtitle: const Text('Remove all TX/RX markers from map'), - onTap: () => _confirmClearPings(context, appState), - ), - ]), - // Offline Sessions - _buildSection(context, 'Offline Sessions', [ - if (appState.offlineSessions.isEmpty) - ListTile( - leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), - title: Text( - 'No offline sessions stored', - style: TextStyle(color: Colors.grey.shade500), - ), - subtitle: Text( - 'Sessions recorded in offline mode will appear here', - style: TextStyle(fontSize: 12, color: Colors.grey.shade400), - ), - ) - else - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), - ]), - - // About - _buildSection(context, 'About', [ - const ListTile( - leading: Icon(Icons.info_outline), - title: Text(AppConstants.appName), - subtitle: Text('Mesh network coverage mapper'), - ), - ListTile( - leading: const Icon(Icons.new_releases_outlined), - title: const Text('Version'), - subtitle: Text(AppConstants.appVersion), - onTap: () => _onVersionTap(appState), - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: const Text('Submit Feedback'), - subtitle: const Text('Report bugs or request features'), - onTap: () => _showBugReportDialog(context, appState), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.github), - title: const Text('GitHub'), - subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), - ), + // Reset Position Button ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: const Text('Discord'), - subtitle: const Text('Join our community chat'), - onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), - ), - ListTile( - leading: const Icon(Icons.groups), - title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), - onTap: () => _launchUrl('https://ottawamesh.ca/'), - ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + leading: const SizedBox(width: 24), + title: const Text('Reset Position'), + subtitle: Text(appState.hasSimulatorRoute + ? 'Reset to route start' + : 'Reset to Ottawa downtown'), + trailing: IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + appState.resetGpsSimulator(); + AppToast.info( + context, + appState.hasSimulatorRoute + ? 'Reset to route start' + : 'GPS simulator reset to Ottawa downtown', + ); + }, + ), ), - ]), + ], + ], // Close developerModeEnabled conditional - // Exit Options (Android only) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) - _buildSection(context, 'Exit', [ - SwitchListTile( - secondary: const Icon(Icons.exit_to_app), - title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), - value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), - ), - ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), - title: const Text('Close App'), - subtitle: const Text('Exit the app completely'), - onTap: () => _showCloseAppConfirmation(context, appState), - ), - ]), - - // Developer Tools - only visible when developer mode is enabled - if (appState.developerModeEnabled) - _buildSection(context, 'Developer Tools', [ - SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: const Text('Developer Mode'), - subtitle: const Text('Disable to hide developer tools'), - value: appState.developerModeEnabled, - onChanged: (value) { - appState.setDeveloperMode(value); - }, + // Debug section (always visible on mobile) + if (!kIsWeb) ...[ + const Divider(), + + _buildSectionHeader(context, 'Debug'), + + SwitchListTile( + secondary: Icon( + Icons.bug_report, + color: appState.debugLogsEnabled ? Colors.orange : null, ), - SwitchListTile( - secondary: Icon( - Icons.gps_fixed, - color: appState.isGpsSimulatorEnabled ? Colors.orange : null, - ), - title: Row( - children: [ - const Text('GPS Simulator'), - if (appState.isGpsSimulatorEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SIMULATED', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + title: Row( + children: [ + const Text('Debug Logs'), + if (appState.debugLogsEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LOGGING', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, ), ), - ], + ), ], - ), - subtitle: Text(appState.isGpsSimulatorEnabled - ? 'Smooth simulated movement active' - : 'Use simulated GPS for testing'), - value: appState.isGpsSimulatorEnabled, - onChanged: (value) { - if (value) { - appState.enableGpsSimulator(); - } else { - appState.disableGpsSimulator(); - } - }, + ], ), - if (appState.isGpsSimulatorEnabled) ...[ - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Simulation Speed'), - subtitle: Slider( - value: appState.gpsSimulatorSpeed, - min: 10, - max: 120, - divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - onChanged: (value) { - appState.setGpsSimulatorSpeed(value); - }, - ), - trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Movement Pattern'), - trailing: SizedBox( - width: 180, - child: DropdownButton( - value: appState.gpsSimulatorPattern, - underline: const SizedBox(), - isExpanded: true, - items: [ - const DropdownMenuItem( - value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), - ), - if (appState.hasSimulatorRoute) - DropdownMenuItem( - value: SimulatorPattern.route, - child: Text( - 'Route: ${appState.simulatorRouteName ?? "Loaded"}', - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: (pattern) { - if (pattern != null) { - appState.setGpsSimulatorPattern(pattern); - } - }, - ), - ), - ), - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Load Route File'), - subtitle: Text(appState.hasSimulatorRoute - ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' - : 'KML or GPX file'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () => _pickRouteFile(context, appState), - tooltip: 'Load route file', - ), - if (appState.hasSimulatorRoute) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - appState.clearSimulatorRoute(); - AppToast.info(context, 'Route cleared'); - }, - tooltip: 'Clear route', - ), - ], - ), - ), - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Reset Position'), - subtitle: Text(appState.hasSimulatorRoute - ? 'Reset to route start' - : 'Reset to Ottawa downtown'), - trailing: IconButton( - icon: const Icon(Icons.restart_alt), - onPressed: () { - appState.resetGpsSimulator(); - AppToast.info( - context, - appState.hasSimulatorRoute - ? 'Reset to route start' - : 'GPS simulator reset to Ottawa downtown', - ); - }, - ), - ), - ], - ]), + subtitle: Text( + appState.debugLogsEnabled + ? AppConstants.isDevelopmentBuild + ? 'Auto-enabled for development build' + : 'Writing logs to file' + : 'Enable to save debug logs to device', + ), + value: appState.debugLogsEnabled, + onChanged: (value) async { + if (value) { + await appState.enableDebugLogs(); + } else { + await appState.disableDebugLogs(); + } + }, + ), - // Debug section (always visible on mobile) - if (!kIsWeb) - _buildSection(context, 'Debug', [ - SwitchListTile( - secondary: Icon( - Icons.bug_report, - color: appState.debugLogsEnabled ? Colors.orange : null, - ), - title: Row( + // Log Files List (show when toggle is ON or when files exist) + if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + // Section header with Delete All button + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( children: [ - const Text('Debug Logs'), - if (appState.debugLogsEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LOGGING', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, + Text( + 'Debug Log Files', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, ), - ), + ), + const Spacer(), + if (appState.debugLogFiles.isNotEmpty) + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + onPressed: () => _confirmDeleteAllLogs(context, appState), ), - ], ], ), - subtitle: Text( - appState.debugLogsEnabled - ? 'Writing logs to file' - : 'Enable to save debug logs to device', - ), - value: appState.debugLogsEnabled, - onChanged: (value) async { - if (value) { - await appState.enableDebugLogs(); - } else { - await appState.disableDebugLogs(); - } - }, ), - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + + // Log files list or empty message + if (appState.debugLogFiles.isEmpty) Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Row( - children: [ - Text( - 'Log Files', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.grey.shade600, + padding: const EdgeInsets.all(16), + child: Text( + 'No debug logs yet', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + ), + ) + else + ...appState.debugLogFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isUploading = _uploadingFilePath == file.path; + final wasUploaded = _uploadedFiles.contains(file.path); + // First file (index 0) is the most recent/active log - can't upload + final isCurrentLog = index == 0; + // Parse unix timestamp from filename (meshmapper-debug-{timestamp}.txt) + final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final fileDate = timestampMatch != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + : null; + final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + + // Format size and show part count for oversized files + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + if (isCurrentLog) { + sizeDisplay = '$sizeDisplay (current)'; + } + + return ListTile( + leading: const Icon(Icons.description, size: 20), + title: Text(dateStr, style: const TextStyle(fontSize: 13)), + subtitle: Text( + sizeDisplay, + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Upload button + if (wasUploaded) + Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: const Icon( + Icons.check_circle, + size: 20, + color: Colors.green, ), - ), - const Spacer(), - if (appState.debugLogFiles.isNotEmpty) ...[ - TextButton.icon( - icon: const Icon(Icons.cloud_upload, size: 18), - label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + ) + else if (isUploading) + Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + icon: const Icon(Icons.cloud_upload, size: 20), + onPressed: _uploadingFilePath != null + ? null + : () => isCurrentLog + ? _uploadCurrentLogFile(appState, file) + : _uploadSingleLogFile(appState, file), + tooltip: isCurrentLog + ? 'Close log and upload' + : 'Upload to developer', + ), + // View button + IconButton( + icon: const Icon(Icons.visibility, size: 20), + onPressed: () => _showLogViewer(context, appState, file), + tooltip: 'View', ), - TextButton.icon( - icon: const Icon(Icons.delete_sweep, size: 18), - label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + // Share button + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file), + tooltip: 'Share', ), ], - ], - ), - ), - if (appState.debugLogFiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), ), - ) - else - ...appState.debugLogFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); - final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) - : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; - - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - if (isCurrentLog) { - sizeDisplay = '$sizeDisplay (current)'; - } + ); + }), + ], + ], - return ListTile( - leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), - subtitle: Text( - sizeDisplay, - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), - tooltip: 'View', - ), - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', - ), - ], - ), - ); - }), - ], - ]), + const SizedBox(height: 32), ], ), ); } - Widget _buildSection(BuildContext context, String title, List children) { + Widget _buildSectionHeader(BuildContext context, String title) { 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, - ), - ), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, ), - ...children, - const SizedBox(height: 4), - ], - ), ), ); } @@ -929,122 +828,31 @@ class _SettingsScreenState extends State { } if (result.failedFileCount > 0) { message += ' (${result.failedFileCount} failed)'; - } - - AppToast.success( - context, - message, - duration: const Duration(seconds: 5), - actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, - ); - } else if (result.errorMessage != null) { - AppToast.error( - context, - 'Failed: ${result.errorMessage}', - duration: const Duration(seconds: 4), - ); - } - } - - void _confirmClearQueue(BuildContext context, AppStateProvider appState) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Clear Queue?'), - content: Text( - 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - appState.clearQueue(); - Navigator.pop(context); - }, - child: const Text('Clear'), - ), - ], - ), - ); - } - - void _confirmClearPings(BuildContext context, AppStateProvider appState) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Clear Map Markers?'), - content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - appState.clearPings(); - Navigator.pop(context); - }, - child: const Text('Clear'), - ), - ], - ), - ); - } - - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Disable RSSI Filter?'), - content: const Text( - 'By disabling this filter, you are confirming that you are not operating ' - 'a carpeater (a repeater co-located with your device).\n\n' - 'If this filter is disabled while a carpeater is present, your device will ' - 'report false coverage data to the MeshMapper community map. This degrades ' - 'map accuracy for everyone.\n\n' - 'Only disable this if you are certain no co-located repeater is within range.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - appState.updatePreferences( - appState.preferences.copyWith(disableRssiFilter: true), - ); - Navigator.pop(context); - }, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Disable Filter'), - ), - ], - ), - ); + } + + AppToast.success( + context, + message, + duration: const Duration(seconds: 5), + actionLabel: result.issueUrl != null ? 'View' : null, + onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + ); + } else if (result.errorMessage != null) { + AppToast.error( + context, + 'Failed: ${result.errorMessage}', + duration: const Duration(seconds: 4), + ); + } } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { - final isConnected = appState.connectionStatus == ConnectionStatus.connected; + void _confirmClearQueue(BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Enable Anonymous Mode?'), + title: const Text('Clear Queue?'), content: Text( - 'Your device will be renamed to "Anonymous" for all mesh pings. ' - 'Other mesh users will not see your companion name.\n\n' - 'Your public key is still used to authenticate your session, but ' - 'neither your sessions nor your pings are linked to it on the server.\n\n' - '${isConnected ? 'Your device will disconnect and reconnect automatically.\n\n' : ''}' - 'If the app crashes or BLE disconnects unexpectedly, your device ' - 'may remain named "Anonymous" until you reconnect and properly disconnect. ' - 'Always use the Disconnect button to restore your device name.', + 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', ), actions: [ TextButton( @@ -1053,24 +861,23 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { + appState.clearQueue(); Navigator.pop(context); - appState.setAnonymousMode(true); }, - style: TextButton.styleFrom(foregroundColor: Colors.orange), - child: const Text('Enable'), + child: const Text('Clear'), ), ], ), ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _confirmClearPings(BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Disable Anonymous Mode?'), + title: const Text('Clear Map Markers?'), content: const Text( - 'This will disconnect and reconnect your device to restore your companion name. Continue?', + 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', ), actions: [ TextButton( @@ -1079,10 +886,10 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { + appState.clearPings(); Navigator.pop(context); - appState.setAnonymousMode(false); }, - child: const Text('Continue'), + child: const Text('Clear'), ), ], ), @@ -1144,232 +951,8 @@ class _SettingsScreenState extends State { ); } - void _showDiscDropInfo(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.signal_wifi_off, size: 24), - SizedBox(width: 8), - Text('Discovery Drop'), - ], - ), - content: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', - style: TextStyle(fontSize: 14), - ), - SizedBox(height: 12), - Text( - 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', - style: TextStyle(fontSize: 13, color: Colors.amber), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Got it'), - ), - ], - ), - ); - } - - void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.signal_wifi_off, size: 24), - SizedBox(width: 8), - Text('Discovery Drop'), - ], - ), - content: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', - style: TextStyle(fontSize: 14), - ), - SizedBox(height: 12), - Text( - 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', - style: TextStyle(fontSize: 13, color: Colors.amber), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - appState.updatePreferences( - appState.preferences.copyWith(discDropEnabled: true), - ); - }, - child: const Text('Enable'), - ), - ], - ), - ); - } - - void _showDeleteChannelInfo(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.delete_sweep, size: 24), - SizedBox(width: 8), - Flexible(child: Text('Delete Channel on Disconnect')), - ], - ), - content: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'When enabled, the #wardriving channel is removed from your radio when you disconnect. ' - 'This keeps your radio\'s channel list clean.\n\n' - 'When disabled, the channel remains on the radio after disconnect.', - style: TextStyle(fontSize: 14), - ), - SizedBox(height: 12), - Text( - 'If the app crashes or BLE disconnects unexpectedly, your device ' - 'may retain the #wardriving channel until you reconnect and properly disconnect.', - style: TextStyle(fontSize: 13, color: Colors.amber), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Got it'), - ), - ], - ), - ); - } - - void _showHopBytesInfo(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.linear_scale, size: 24), - SizedBox(width: 8), - Text('TX Bytes'), - ], - ), - content: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Controls how many bytes are used to identify each repeater in TX/RX packet paths. ' - 'More bytes = more unique IDs, reducing collisions in large networks.', - style: TextStyle(fontSize: 14), - ), - SizedBox(height: 12), - Text( - '\u2022 1 byte: 256 unique IDs (default)\n' - '\u2022 2 bytes: 65,536 unique IDs\n' - '\u2022 3 bytes: 16 million unique IDs', - style: TextStyle(fontSize: 13), - ), - SizedBox(height: 12), - Text( - 'Requires MeshCore firmware v1.14.0+. ' - 'RX always auto-detects the sender\'s byte size.', - style: TextStyle(fontSize: 13, color: Colors.amber), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Got it'), - ), - ], - ), - ); - } - - void _showTraceBytesInfo(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.gps_fixed, size: 24), - SizedBox(width: 8), - Text('Trace Bytes'), - ], - ), - content: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Controls how many bytes are used for the repeater ID in trace path requests. ' - 'This is separate from TX Bytes because traces use a different encoding.', - style: TextStyle(fontSize: 14), - ), - SizedBox(height: 12), - Text( - 'TX/RX uses a simple counter:\n' - '\u2022 Mode 0 \u2192 1 byte\n' - '\u2022 Mode 1 \u2192 2 bytes\n' - '\u2022 Mode 2 \u2192 3 bytes\n\n' - 'Trace uses bitshift encoding:\n' - '\u2022 Mode 0 \u2192 1 byte\n' - '\u2022 Mode 1 \u2192 2 bytes\n' - '\u2022 Mode 2 \u2192 4 bytes', - style: TextStyle(fontSize: 13), - ), - SizedBox(height: 12), - Text( - '3-byte traces are not supported by the MeshCore protocol. ' - 'When your region uses 3-byte TX paths, set Trace Bytes to 4.', - style: TextStyle(fontSize: 13, color: Colors.amber), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Got it'), - ), - ], - ), - ); - } - void _showIntervalSelector(BuildContext context, AppStateProvider appState) { - final minInterval = appState.minModeInterval; - var currentInterval = appState.preferences.autoPingInterval; - - // Auto-bump if current interval is below the admin minimum - if (currentInterval < minInterval) { - currentInterval = minInterval; - appState.updatePreferences( - appState.preferences.copyWith(autoPingInterval: minInterval), - ); - } + final currentInterval = appState.preferences.autoPingInterval; showDialog( context: context, @@ -1388,46 +971,18 @@ class _SettingsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: AutoPingInterval.values.map((interval) { - final isDisabled = interval < minInterval; - - String description; - if (interval == 15) { - description = 'Fast (More coverage, causes more mesh load)'; - } else if (interval == 30) { - description = 'Normal (Balanced coverage and mesh load)'; - } else { - description = 'Slow (Less coverage, little mesh load)'; - } - - final tile = RadioListTile( - title: Text( - '$interval seconds', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), - ), - subtitle: isDisabled - ? const Text( - 'Set by Regional Admin — slower intervals reduce congestion in your region', - style: TextStyle(fontSize: 12, color: Colors.amber), - ) - : Text( - description, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + final isSelected = interval == currentInterval; + + return RadioListTile( + title: Text('$interval seconds'), + subtitle: Text(interval == 15 + ? 'Fast (More coverage, causes more mesh load)' + : interval == 30 + ? 'Normal (Balanced coverage and mesh load)' + : 'Slow (Less coverage, little mesh load)'), value: interval, + selected: isSelected, ); - - if (isDisabled) { - return IgnorePointer( - child: Opacity( - opacity: 0.5, - child: tile, - ), - ); - } - return tile; }).toList(), ), ), @@ -1441,49 +996,7 @@ class _SettingsScreenState extends State { ); } - void _showDistanceSelector(BuildContext context, AppStateProvider appState) { - final currentDistance = appState.preferences.minPingDistanceMeters; - final controller = TextEditingController(text: currentDistance.toString()); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Min Ping Distance'), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - autofocus: true, - decoration: const InputDecoration( - suffixText: 'meters', - helperText: 'Minimum ${MinPingDistance.min}m', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final value = int.tryParse(controller.text); - if (value != null && value >= MinPingDistance.min) { - appState.updatePreferences( - appState.preferences.copyWith(minPingDistanceMeters: value), - ); - Navigator.pop(context); - } - }, - child: const Text('Save'), - ), - ], - ), - ); - } - void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { - const maxHexChars = 6; - const hintText = 'FFFFFF'; - final controller = TextEditingController( text: appState.preferences.ignoreRepeaterId ?? '', ); @@ -1491,22 +1004,22 @@ class _SettingsScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('CARpeater Repeater ID'), + title: const Text('Ignore Repeater ID'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Enter the full 3-byte repeater ID (6 hex digits):'), + const Text('Enter the repeater ID to ignore (2 hex digits):'), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( - labelText: 'CARpeater ID', - hintText: hintText, + labelText: 'Repeater ID', + hintText: 'FF', prefixText: '0x', border: OutlineInputBorder(), ), - maxLength: maxHexChars, + maxLength: 2, textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters @@ -1521,9 +1034,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Enter all 6 hex digits of your CARpeater\'s ID. ' - 'The app will automatically truncate to match your region\'s hop byte size (1, 2, or 3 bytes). ' - 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater.', + 'Enter 2-character hex ID (e.g., FF) to ignore a specific repeater.\nLeave empty to disable.', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1540,8 +1051,7 @@ class _SettingsScreenState extends State { onPressed: () { final value = controller.text.trim().toUpperCase(); final isValidHex = value.isEmpty || - (value.length == maxHexChars && - RegExp(r'^[0-9A-F]+$').hasMatch(value)); + (value.length == 2 && RegExp(r'^[0-9A-F]{2}$').hasMatch(value)); if (isValidHex) { // Enable ignoreCarpeater when setting a repeater ID @@ -1554,7 +1064,7 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); + AppToast.warning(context, 'Invalid hex value. Use 2 digits (00-FF).'); } }, child: const Text('Save'), @@ -1640,61 +1150,31 @@ class _SettingsScreenState extends State { } Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { - // Progress text notifier for updating dialog without rebuilding screen - final progressNotifier = ValueNotifier('Authenticating...'); - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - const Text( - 'Uploading session...', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 4), - Text( - filename, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: progressNotifier, - builder: (_, status, __) => Text( - status, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - ), - ], - ), + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 12), + Text('Uploading session...'), + ], ), + duration: Duration(seconds: 30), // Will be dismissed when upload completes ), ); - final result = await appState.uploadOfflineSessionWithAuth( - filename, - onProgress: (status) => progressNotifier.value = status, - ); + final result = await appState.uploadOfflineSessionWithAuth(filename); - // Close progress dialog if (context.mounted) { - Navigator.of(context).pop(); - } + // Dismiss loading indicator + ScaffoldMessenger.of(context).hideCurrentSnackBar(); - progressNotifier.dispose(); - - if (context.mounted) { - // Show result via SnackBar + // Show result String message; Color backgroundColor; @@ -1719,10 +1199,6 @@ class _SettingsScreenState extends State { message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; break; - case OfflineUploadResult.uploadInProgress: - message = 'Another upload is already in progress'; - backgroundColor = Colors.orange; - break; } ScaffoldMessenger.of(context).showSnackBar( @@ -2035,7 +1511,29 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Icons.location_on, color: _hasAlwaysPermission ? Colors.green : null, ), - title: const Text('Background Location'), + title: Row( + children: [ + const Text('Background Location'), + if (_hasAlwaysPermission) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'ALWAYS', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], + ), subtitle: Text( _hasAlwaysPermission ? 'Location tracking works in background' @@ -2059,14 +1557,12 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> /// Widget for displaying an offline session in the list class _OfflineSessionTile extends StatelessWidget { final OfflineSession session; - final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; final VoidCallback onDownload; const _OfflineSessionTile({ required this.session, - this.uploadEnabled = true, required this.onUpload, required this.onDelete, required this.onDownload, @@ -2113,9 +1609,9 @@ class _OfflineSessionTile extends StatelessWidget { if (!isUploaded) IconButton( icon: const Icon(Icons.cloud_upload), - onPressed: uploadEnabled ? onUpload : null, + onPressed: onUpload, tooltip: 'Upload session', - color: uploadEnabled ? Colors.green : Colors.grey, + color: Colors.green, ), // Delete button - always available IconButton( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 2cb45faf..d55fdca1 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -25,13 +25,9 @@ class ApiQueueService { final ApiService _apiService; Box? _box; Timer? _batchTimer; - Timer? _pingFlushTimer; bool _isUploading = false; bool _isRecovering = false; - // In-memory fallback when Hive is corrupted/unavailable - final List _memoryQueue = []; - // Offline mode bool offlineMode = false; final List> _offlinePings = []; @@ -82,7 +78,6 @@ class ApiQueueService { debugError('[API QUEUE] Failed to clear stale items: $e - recovering'); await _recoverBox(); } - _memoryQueue.clear(); _rxBuffer.clear(); _offlinePings.clear(); @@ -211,8 +206,8 @@ class ApiQueueService { } } - /// Get current queue size (Hive + in-memory fallback) - int get queueSize => _safeRead((box) => box.length, 0) + _memoryQueue.length; + /// Get current queue size + int get queueSize => _safeRead((box) => box.length, 0); /// Enqueue a TX ping /// heardRepeats format: "4e(12.25),77(12.25)" or "None" @@ -240,20 +235,10 @@ class ApiQueueService { return; } - final wrote = await _safeWrite((box) => box.add(item)); - if (!wrote) { - _memoryQueue.add(item); - debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); - } else { - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); - } + await _safeWrite((box) => box.add(item)); + debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); onQueueUpdated?.call(queueSize); - _pingFlushTimer?.cancel(); - _pingFlushTimer = Timer(const Duration(seconds: 5), () { - debugLog('[API QUEUE] Ping flush timer fired'); - _flushRxBuffer(); - _uploadBatch(); - }); + _checkBatchUpload(); } /// Enqueue an RX observation @@ -331,106 +316,10 @@ class ApiQueueService { return; } - final wrote = await _safeWrite((box) => box.add(item)); - if (!wrote) { - _memoryQueue.add(item); - debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); - } else { - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); - } - onQueueUpdated?.call(queueSize); - _pingFlushTimer?.cancel(); - _pingFlushTimer = Timer(const Duration(seconds: 5), () { - debugLog('[API QUEUE] Ping flush timer fired'); - _flushRxBuffer(); - _uploadBatch(); - }); - } - - /// Enqueue a TRACE ping result (targeted zero-hop trace) - Future enqueueTrace({ - required double latitude, - required double longitude, - required String repeaterId, - required double localSnr, - required int localRssi, - required double remoteSnr, - required int timestamp, - required bool externalAntenna, - int? noiseFloor, - }) async { - final item = ApiQueueItem.fromTrace( - latitude: latitude, - longitude: longitude, - repeaterId: repeaterId, - localSnr: localSnr, - localRssi: localRssi, - remoteSnr: remoteSnr, - timestamp: timestamp, - externalAntenna: externalAntenna, - noiseFloor: noiseFloor, - ); - - // In offline mode, accumulate to offline pings list instead of queue - if (offlineMode) { - _offlinePings.add(item.toApiJson()); - debugLog('[API QUEUE] TRACE enqueued (offline): $repeaterId'); - return; - } - - final wrote = await _safeWrite((box) => box.add(item)); - if (!wrote) { - _memoryQueue.add(item); - debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); - } else { - debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); - } + await _safeWrite((box) => box.add(item)); + debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); onQueueUpdated?.call(queueSize); - _pingFlushTimer?.cancel(); - _pingFlushTimer = Timer(const Duration(seconds: 5), () { - debugLog('[API QUEUE] Ping flush timer fired'); - _flushRxBuffer(); - _uploadBatch(); - }); - } - - /// Enqueue a failed DISC discovery (no nodes responded) - Future enqueueDiscDrop({ - required double latitude, - required double longitude, - required int timestamp, - required bool externalAntenna, - int? noiseFloor, - }) async { - final item = ApiQueueItem.fromDiscDrop( - latitude: latitude, - longitude: longitude, - timestamp: timestamp, - externalAntenna: externalAntenna, - noiseFloor: noiseFloor, - ); - - // In offline mode, accumulate to offline pings list instead of queue - if (offlineMode) { - _offlinePings.add(item.toApiJson()); - debugLog('[API QUEUE] DISC drop enqueued (offline)'); - return; - } - - final wrote = await _safeWrite((box) => box.add(item)); - if (!wrote) { - _memoryQueue.add(item); - debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); - } else { - debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); - } - onQueueUpdated?.call(queueSize); - _pingFlushTimer?.cancel(); - _pingFlushTimer = Timer(const Duration(seconds: 5), () { - debugLog('[API QUEUE] Ping flush timer fired'); - _flushRxBuffer(); - _uploadBatch(); - }); + _checkBatchUpload(); } // Guard to prevent concurrent RX buffer flushes @@ -452,12 +341,10 @@ class ApiQueueService { final bufferSize = _rxBuffer.length; _rxBuffer.clear(); - // Now add items to the box (or memory fallback) + // Now add items to the box for (final item in itemsToFlush) { final ok = await _safeWrite((box) => box.add(item)); - if (!ok) { - _memoryQueue.add(item); - } + if (!ok) break; } debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); @@ -486,23 +373,25 @@ class ApiQueueService { }); } + void _checkBatchUpload() { + if (queueSize >= _batchSize) { + _uploadBatch(); + } + } + /// Manually flush queue (called by TX-triggered flush timer) Future flushQueue() async { await _flushRxBuffer(); await _uploadBatch(); } - /// Upload batch of queued items (from Hive box or in-memory fallback) + /// Upload batch of queued items Future _uploadBatch() async { if (_isUploading) { debugLog('[API QUEUE] Upload skipped: already uploading'); return; } - - final hiveEmpty = _safeRead((box) => box.isEmpty, true); - final memoryEmpty = _memoryQueue.isEmpty; - - if (hiveEmpty && memoryEmpty) { + if (_safeRead((box) => box.isEmpty, true)) { debugLog('[API QUEUE] Upload skipped: queue empty'); return; } @@ -510,8 +399,15 @@ class ApiQueueService { _isUploading = true; try { - // Collect items from both Hive and memory queue - final hiveItems = _safeRead((box) => box.values + // Log if TX items are waiting in hold period + final pendingTx = _safeRead((box) => box.values.where((item) => + item.type == 'TX' && !item.isUploadEligible).length, 0); + if (pendingTx > 0) { + debugLog('[API QUEUE] $pendingTx TX items still in hold period'); + } + + // Get items ready for upload (must pass retry, retry delay, AND upload eligibility checks) + final items = _safeRead((box) => box.values .where((item) => item.retryCount < _maxRetries && item.isReadyForRetry && @@ -519,16 +415,6 @@ class ApiQueueService { .take(_batchSize) .toList(), []); - final memoryItems = _memoryQueue - .where((item) => - item.retryCount < _maxRetries && - item.isReadyForRetry && - item.isUploadEligible) - .take(_batchSize - hiveItems.length) - .toList(); - - final items = [...hiveItems, ...memoryItems]; - if (items.isEmpty) { debugLog('[API QUEUE] Upload skipped: no items ready for upload'); _isUploading = false; @@ -544,47 +430,30 @@ class ApiQueueService { debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } - final memoryCount = memoryItems.length; - if (memoryCount > 0) { - debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); - } else { - debugLog('[API QUEUE] Uploading ${items.length} items...'); - } + debugLog('[API QUEUE] Uploading ${items.length} items...'); // Attempt upload final result = await _apiService.uploadBatch(pings); if (result == UploadResult.success) { final uploadedCount = items.length; - // Remove successful Hive items - for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} - } - // Remove successful memory items - for (final item in memoryItems) { - _memoryQueue.remove(item); + // Remove successful items + for (final item in items) { + await item.delete(); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); onUploadSuccess?.call(uploadedCount); } else if (result == UploadResult.nonRetryable) { - // Data is permanently invalid — discard - for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} - } - for (final item in memoryItems) { - _memoryQueue.remove(item); + // Data is permanently invalid (bad GPS, invalid request, etc.) — discard + for (final item in items) { + await item.delete(); } debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried - for (final item in hiveItems) { + for (final item in items) { item.markRetried(); } - // Memory items: update retry fields directly (no Hive save) - for (final item in memoryItems) { - item.retryCount++; - item.lastRetryAt = DateTime.now(); - } debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } @@ -603,18 +472,44 @@ class ApiQueueService { await _uploadBatch(); } - /// Force upload all queued items immediately + /// Force upload after waiting for any TX items in hold period /// Used during BLE disconnect to ensure all data is uploaded before session release Future forceUploadWithHoldWait() async { - _pingFlushTimer?.cancel(); await _flushRxBuffer(); + + // Check if any TX items are still in hold period + try { + if (_box != null && _box!.isNotEmpty) { + final now = DateTime.now().millisecondsSinceEpoch; + int maxWaitMs = 0; + + for (final item in _box!.values) { + if (item.type == 'TX' && !item.isUploadEligible) { + final waitMs = item.canUploadAfter - now; + if (waitMs > maxWaitMs) { + maxWaitMs = waitMs; + } + } + } + + if (maxWaitMs > 0) { + // Cap wait time at 6 seconds (slightly more than 5s hold period) + final cappedWaitMs = maxWaitMs.clamp(0, 6000); + debugLog('[API QUEUE] Waiting ${cappedWaitMs}ms for TX hold period to expire'); + await Future.delayed(Duration(milliseconds: cappedWaitMs)); + } + } + } catch (e) { + debugError('[API QUEUE] Failed to check hold period: $e - skipping wait'); + await _recoverBox(); + } + await _uploadBatch(); } /// Clear all queued items Future clear() async { await _safeWrite((box) => box.clear()); - _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -623,19 +518,16 @@ class ApiQueueService { /// Called when device disconnects to ensure no stale pings remain /// Also stops the batch timer to prevent upload attempts without a session Future clearOnDisconnect() async { - // Stop timers to prevent upload attempts without session + // Stop the batch timer to prevent upload attempts without session _batchTimer?.cancel(); _batchTimer = null; - _pingFlushTimer?.cancel(); - _pingFlushTimer = null; - debugLog('[API QUEUE] Timers stopped on disconnect'); + debugLog('[API QUEUE] Batch timer stopped on disconnect'); final count = queueSize + _rxBuffer.length; if (count > 0) { debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); - _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -649,7 +541,6 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count stale items before connect'); } await _safeWrite((box) => box.clear()); - _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); @@ -662,18 +553,10 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { - final hiveItems = _safeRead( + return _safeRead( (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); - final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); - return [...hiveItems, ...memoryItems]; - } - - /// Get a snapshot of accumulated offline pings without clearing. - /// Used for periodic auto-saves to persist data without losing the in-memory accumulator. - List> getOfflinePingsSnapshot() { - return List>.from(_offlinePings); } /// Get accumulated offline pings and clear the accumulator @@ -689,28 +572,9 @@ class ApiQueueService { _offlinePings.clear(); } - /// Extract all queued items as API JSON without clearing the queue. - /// Used to preserve data before session-expiry disconnect. - Future>> extractAllAsJson() async { - // Flush RX buffer first so all items are in the main queue - await _flushRxBuffer(); - - final hiveItems = _safeRead( - (box) => box.values.toList(), - [], - ); - - final allItems = [...hiveItems, ..._memoryQueue]; - - if (allItems.isEmpty) return []; - - return allItems.map((item) => item.toApiJson()).toList(); - } - /// Dispose of resources void dispose() { _batchTimer?.cancel(); - _pingFlushTimer?.cancel(); _box?.close(); } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 064c25a5..717b232b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:http/http.dart' as http; @@ -39,17 +38,8 @@ class ApiService { bool _rxAllowed = false; int? _sessionExpiresAt; Timer? _heartbeatTimer; - Timer? _heartbeatRetryTimer; - Timer? _sessionDeadlineTimer; - int _heartbeatRetryCount = 0; - static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; List _channels = []; - List _scopes = []; - bool _enforceHybrid = false; - bool _enforceDiscDrop = false; - int _minModeInterval = 15; - int _apiHopBytes = 1; /// Callback to get current GPS coordinates for heartbeat /// Returns (lat, lon) or null if GPS is not available @@ -58,24 +48,6 @@ class ApiService { /// Regional channels from auth response (e.g., ['public', 'ottawa', 'testing']) List get channels => List.unmodifiable(_channels); - /// Regional scopes from auth response (e.g., ['ottawa']) - List get scopes => List.unmodifiable(_scopes); - - /// Whether hybrid mode is enforced by regional admin - bool get enforceHybrid => _enforceHybrid; - - /// Whether discovery drop is enforced by regional admin - bool get enforceDiscDrop => _enforceDiscDrop; - - /// Minimum auto-ping interval enforced by regional admin (seconds) - int get minModeInterval => _minModeInterval; - - /// Path hop bytes enforced by regional admin (1, 2, or 3) - int get apiHopBytes => _apiHopBytes; - - /// Whether hop bytes are enforced by regional admin (only 2 or 3 enforces) - bool get enforceHopBytes => _apiHopBytes > 1; - ApiService({http.Client? client}) : _client = client ?? http.Client(); /// Sanitize payload by removing sensitive fields for logging @@ -224,8 +196,6 @@ class ApiService { /// @param publicKey Device public key (for existing auth flow) /// @param contactUri Signed contact URI (for registration flow) /// @param offlineMode Set to true when uploading offline session data - /// @param skipSessionStore When true, does not write to shared _sessionId/_txAllowed/etc. Caller manages session locally. - /// @param sessionId Explicit session ID for disconnect. When provided, disconnect uses this instead of _sessionId and skips _clearSession(). /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, @@ -240,8 +210,6 @@ class ApiService { double? lon, double? accuracyMeters, bool offlineMode = false, - bool skipSessionStore = false, - String? sessionId, }) async { final stopwatch = Stopwatch()..start(); try { @@ -282,8 +250,8 @@ class ApiService { 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, }; } else { - // For disconnect: use explicit sessionId if provided, otherwise shared _sessionId - payload['session_id'] = sessionId ?? _sessionId; + // For disconnect: add session_id + payload['session_id'] = _sessionId; } final response = await _client.post( @@ -320,71 +288,25 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) if ((reason == 'connect' || reason == 'register') && data['success'] == true) { - if (!skipSessionStore) { - _sessionId = data['session_id'] as String?; - _txAllowed = data['tx_allowed'] == true; - _rxAllowed = data['rx_allowed'] == true; - _sessionExpiresAt = data['expires_at'] as int?; - - // Parse channels array from auth response - final channelsData = data['channels']; - if (channelsData is List) { - _channels = channelsData.cast().toList(); - debugLog('[API] Regional channels: $_channels'); - } else { - _channels = []; - } - - // Parse scopes array from auth response - final scopesData = data['scopes']; - if (scopesData is List && scopesData.isNotEmpty) { - _scopes = scopesData.cast().toList(); - debugLog('[API] Regional scopes: $_scopes'); - } else { - _scopes = []; - } - - // Parse enforce_hybrid flag from auth response - _enforceHybrid = data['enforce_hybrid'] == true; - if (_enforceHybrid) { - debugLog('[API] Regional admin enforces hybrid mode'); - } - - // Parse disc_drop flag from auth response - _enforceDiscDrop = data['disc_drop'] == true; - if (_enforceDiscDrop) { - debugLog('[API] Regional admin enforces discovery drop'); - } - - // Parse min_mode_interval from auth response - final minInterval = data['min_mode_interval']; - if (minInterval is int && minInterval > 0) { - _minModeInterval = minInterval; - debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); - } else { - _minModeInterval = 15; - } - - // Parse hop_bytes from auth response - final hopBytes = data['hop_bytes']; - if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { - _apiHopBytes = hopBytes; - if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); - } - } else { - _apiHopBytes = 1; - } - - // Note: Heartbeat is enabled by AppStateProvider when auto mode starts - // (not on initial auth, since heartbeat is only for auto mode) + _sessionId = data['session_id'] as String?; + _txAllowed = data['tx_allowed'] == true; + _rxAllowed = data['rx_allowed'] == true; + _sessionExpiresAt = data['expires_at'] as int?; + + // Parse channels array from auth response + final channelsData = data['channels']; + if (channelsData is List) { + _channels = channelsData.cast().toList(); + debugLog('[API] Regional channels: $_channels'); + } else { + _channels = []; } + + // Note: Heartbeat is enabled by AppStateProvider when auto mode starts + // (not on initial auth, since heartbeat is only for auto mode) } else if (reason == 'disconnect') { - // Only clear shared session when no explicit sessionId was provided - // (explicit sessionId means caller manages its own session lifecycle) - if (sessionId == null) { - _clearSession(); - } + // Clear session on disconnect + _clearSession(); } return data; @@ -604,11 +526,6 @@ class ApiService { _gpsProvider = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; - _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = null; - _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -616,12 +533,9 @@ class ApiService { /// Matches scheduleHeartbeat() in wardrive.js /// @param expiresAt Unix timestamp when session expires void scheduleHeartbeat(int expiresAt) { - // Cancel any existing heartbeat timer and reset retry state + // Cancel any existing heartbeat timer _heartbeatTimer?.cancel(); _heartbeatTimer = null; - _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = null; - _heartbeatRetryCount = 0; if (!_heartbeatEnabled) return; @@ -634,17 +548,15 @@ class ApiService { // Session is about to expire or already expired - send heartbeat immediately debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); - } else { - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); - - _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { - debugLog('[HEARTBEAT] Timer fired, sending keepalive'); - _sendScheduledHeartbeat(); - }); + return; } - // Schedule session deadline timer at exact expiry - _scheduleSessionDeadline(expiresAt); + debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + + _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); } /// Send scheduled heartbeat with GPS coordinates @@ -655,22 +567,10 @@ class ApiService { if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); - // Reset retry state on success - _heartbeatRetryCount = 0; - _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = null; // Next heartbeat will be scheduled when we get new expires_at } else if (result == null) { - // Network error — schedule retry with exponential backoff - if (_heartbeatRetryCount < _maxHeartbeatRetries) { - final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); - _heartbeatRetryCount++; - debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); - _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); - } else { - debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); - } + // Network error — transient, trigger session expiring + debugWarn('[HEARTBEAT] Heartbeat failed: network error'); _onSessionExpiring?.call(); } else { // Server returned an error — check if critical @@ -693,51 +593,15 @@ class ApiService { } } - /// Schedule a hard deadline timer at the exact session expiry time. - /// If the server is unreachable and all heartbeat retries fail, this fires - /// and triggers the same disconnect flow as a server-returned session_expired. - void _scheduleSessionDeadline(int expiresAt) { - _sessionDeadlineTimer?.cancel(); - if (!_heartbeatEnabled) return; - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final secondsUntilExpiry = expiresAt - now; - - if (secondsUntilExpiry <= 0) { - _onSessionDeadlineReached(); - return; - } - - debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); - _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); - } - - /// Called when the session deadline timer fires — server was unreachable - void _onSessionDeadlineReached() { - debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); - _clearSession(); - onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); - } - - /// Clear session data and cancel all timers + /// Clear session data and cancel heartbeat timer void _clearSession() { _sessionId = null; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; _channels = []; - _scopes = []; - _enforceHybrid = false; - _enforceDiscDrop = false; - _minModeInterval = 15; - _apiHopBytes = 1; _heartbeatTimer?.cancel(); _heartbeatTimer = null; - _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = null; - _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -818,7 +682,7 @@ class ApiService { /// Returns a list of enabled repeaters for the given IATA zone code Future> fetchRepeaters(String iata) async { final stopwatch = Stopwatch()..start(); - const endpoint = '/get_repeaters.php'; + const endpoint = '/repeaters.json'; try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; @@ -870,120 +734,10 @@ class ApiService { } } - /// Submit wardrive data using an explicit session ID (for offline uploads) - /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state - Future?> submitWardriveDataWithSessionId( - List> entries, - String sessionId, - ) async { - final stopwatch = Stopwatch()..start(); - try { - final payload = { - 'key': apiKey, - 'session_id': sessionId, - 'data': entries, - }; - - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); - - stopwatch.stop(); - - if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); - } - - Map data; - try { - data = json.decode(response.body) as Map; - } on FormatException { - debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' - '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); - rethrow; - } - - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); - _logApiCall( - endpoint: '/wardrive-api.php/wardrive (offline)', - method: 'POST', - stopwatch: stopwatch, - statusCode: response.statusCode, - request: {'data': '${entries.length} items', 'items': antennaSummary}, - response: data, - ); - - // Do NOT update shared _sessionExpiresAt or schedule heartbeat - return data; - } catch (e) { - stopwatch.stop(); - debugError('[API] POST /wardrive-api.php/wardrive (offline) failed: $e'); - return null; - } - } - - /// Upload batch using explicit session ID (for offline uploads) - /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode - Future uploadBatchWithSessionId( - List> pings, - String sessionId, - ) async { - if (pings.isEmpty) return UploadResult.success; - - try { - final result = await submitWardriveDataWithSessionId(pings, sessionId); - - if (result == null) { - debugError('[API] Offline upload batch failed: no response'); - return UploadResult.retryable; - } - - if (result['success'] == true) { - debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); - return UploadResult.success; - } - - final reason = result['reason'] as String?; - - // For offline uploads, session/auth errors are non-retryable but do NOT cascade - const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', - }; - if (criticalErrors.contains(reason)) { - debugError('[API] Offline upload batch session error: $reason'); - return UploadResult.nonRetryable; - } - - const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', - }; - if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); - return UploadResult.nonRetryable; - } - - return UploadResult.retryable; - } catch (e) { - debugError('[API] Offline upload batch exception: $e'); - return UploadResult.retryable; - } - } - /// Dispose of resources void dispose() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; - _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = null; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 3ff0d174..49a110c0 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -249,8 +249,6 @@ class BackgroundServiceManager { String body; if (mode == 'Passive Mode') { body = 'RX: $rxCount | Queue: $queueSize'; - } else if (mode == 'Trace Mode') { - body = 'Trace: $txCount | RX: $rxCount | Queue: $queueSize'; } else { body = 'TX: $txCount | RX: $rxCount | Queue: $queueSize'; } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index d279477d..6131f832 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -46,7 +46,6 @@ class MobileBluetoothService implements BluetoothService { StreamSubscription? _connectionStateSubscription; StreamSubscription? _notificationSubscription; StreamSubscription? _scanSubscription; - StreamController? _scanController; // Store scanned device info for use in connect() // This preserves the device name from scan results @@ -184,15 +183,10 @@ class MobileBluetoothService implements BluetoothService { @override Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); - _scanController = controller; _updateStatus(ConnectionStatus.scanning); try { - // Ensure any previous scan stream is closed before starting a new one - await _scanSubscription?.cancel(); - _scanSubscription = null; - // Start scanning with filter for MeshCore service UUID await fbp.FlutterBluePlus.startScan( withServices: [fbp.Guid(BleUuids.serviceUuid)], @@ -214,30 +208,14 @@ class MobileBluetoothService implements BluetoothService { ); // Store device info for use in connect() - preserves name from scan _scannedDevices[device.id] = device; - if (!controller.isClosed) { - controller.add(device); - } + controller.add(device); } }); - // Complete stream when scan naturally stops (timeout or platform stop) - unawaited(() async { - await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; - if (!controller.isClosed) { - await controller.close(); - } - }()); - yield* controller.stream; } finally { await _scanSubscription?.cancel(); _scanSubscription = null; - if (!controller.isClosed) { - await controller.close(); - } - if (identical(_scanController, controller)) { - _scanController = null; - } } } @@ -246,11 +224,6 @@ class MobileBluetoothService implements BluetoothService { await fbp.FlutterBluePlus.stopScan(); await _scanSubscription?.cancel(); _scanSubscription = null; - final scanController = _scanController; - if (scanController != null && !scanController.isClosed) { - await scanController.close(); - } - _scanController = null; // NOTE: Do NOT fire 'disconnected' here. Stopping a scan is not a disconnection. // The status will be updated by connect() when a connection starts. // Firing 'disconnected' here causes a race condition where the queued event diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 753c67c2..1535a629 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -28,18 +28,6 @@ class GpsService { /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; - /// Configured minimum ping distance (user-adjustable, clamped to minDistanceMeters floor) - double _configuredMinDistance = minDistanceMeters; - - /// Get the configured minimum ping distance - double get configuredMinDistance => _configuredMinDistance; - - /// Set the minimum ping distance (clamped to 25m floor) - void setMinPingDistance(double meters) { - _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; - debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); - } - final _statusController = StreamController.broadcast(); final _positionController = StreamController.broadcast(); @@ -173,14 +161,6 @@ class GpsService { Future startWatching() async { debugLog('[GPS] startWatching() called, current status: $_status'); - // Ensure only one active position stream subscription exists. - // startWatching() can be called multiple times (e.g. after permission flow). - if (_positionSubscription != null) { - debugLog('[GPS] Existing position subscription found, restarting watcher'); - await _positionSubscription?.cancel(); - _positionSubscription = null; - } - // Check if location services are enabled first (system-level setting) final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); @@ -214,12 +194,6 @@ class GpsService { } debugLog('[GPS] Starting position stream listener...'); - - // Cancel any existing subscription to prevent orphaned listeners - // (e.g. restartGpsAfterPermission() racing with _initialize()) - await _positionSubscription?.cancel(); - _positionSubscription = null; - _updateStatus(GpsStatus.searching); // Configure location settings for position stream @@ -286,10 +260,10 @@ class GpsService { ); } - /// Check if current position is far enough from last ping + /// Check if current position is far enough from last ping (25m minimum) bool canPingAtPosition(Position position) { if (_lastPingPosition == null) return true; - return distanceFromLastPing(position) >= _configuredMinDistance; + return distanceFromLastPing(position) >= minDistanceMeters; } /// Mark current position as ping location diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 07c08100..0292a289 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,15 +17,11 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 - final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) - final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, required this.manufacturer, this.firmwareBuildDate, - this.firmwareVersionString, - this.pathHashMode, }); } @@ -70,7 +66,6 @@ class MeshCoreConnection { final _rawDataController = StreamController>.broadcast(); final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); - final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -125,10 +120,6 @@ class MeshCoreConnection { /// Stream of ControlData packets (for discovery responses) Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; - /// Stream of TraceData packets (for trace path responses) - /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly - Stream get traceDataStream => _traceDataController.stream; - /// Stream of noise floor updates (dBm) Stream get noiseFloorStream => _noiseFloorController.stream; @@ -408,9 +399,6 @@ class MeshCoreConnection { case PushCodes.controlData: _onControlDataPush(reader); break; - case PushCodes.traceData: - _onTraceDataPush(reader); - break; case ResponseCodes.stats: _onStatsResponse(reader); break; @@ -450,43 +438,17 @@ class MeshCoreConnection { // Protocol v7+ format reader.readBytes(6); // skip reserved bytes final buildDate = reader.readCString(12); // e.g. "04-Jan-2026" - - // Read manufacturer model as CString(40) — fixed-length null-terminated - final manufacturerModel = reader.readCString(40); - - // Parse additional fields from v9+ firmware - int? pathHashMode; - String? firmwareVersionString; - if (reader.remainingBytesCount > 0) { - // FIRMWARE_VERSION: 20-byte null-terminated C-string - if (reader.remainingBytesCount >= 20) { - firmwareVersionString = reader.readCString(20); - debugLog('[CONN] Firmware version string: $firmwareVersionString'); - } - - // client_repeat: 1 byte (v9+, skip) - if (reader.remainingBytesCount >= 1) { - reader.readByte(); // client_repeat - } - - // path_hash_mode: 1 byte (v10+) - if (reader.remainingBytesCount >= 1) { - pathHashMode = reader.readByte(); - debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); - } - } - + final manufacturerModel = reader.readString(); // remainder of frame + debugLog('[CONN] Build date: $buildDate'); debugLog('[CONN] Manufacturer model: $manufacturerModel'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturerModel, firmwareBuildDate: buildDate, - firmwareVersionString: firmwareVersionString, - pathHashMode: pathHashMode, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } else { @@ -622,17 +584,6 @@ class MeshCoreConnection { _controlDataController.add((raw: raw, snr: snr, rssi: rssi)); } - void _onTraceDataPush(BufferReader reader) { - // 0x89 TraceData has NO snr/rssi prefix (unlike 0x88 LogRxData). - // The entire remaining payload is the trace response: - // [reserved][path_len][flags][tag:4][auth:4][path_hashes][path_snrs] - final raw = reader.readRemainingBytes(); - - debugLog('[CONN] Received trace data: ${raw.length} bytes'); - - _traceDataController.add(raw); - } - void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // @@ -774,14 +725,6 @@ class MeshCoreConnection { await _sendToRadio(data); } - /// Set the companion advertised name - Future setAdvertName(String name) async { - final data = BufferWriter(); - data.writeByte(CommandCodes.setAdvertName); - data.writeString(name); - await _sendToRadio(data); - } - /// Set radio parameters Future setRadioParams(int freq, int bw, int sf, int cr) async { final data = BufferWriter(); @@ -828,24 +771,6 @@ class MeshCoreConnection { await _sendToRadio(data); } - /// Set flood scope for regional packet filtering - /// TransportKey is 16-byte SHA-256 derived key from scope name - Future setFloodScope(Uint8List transportKey) async { - final data = BufferWriter(); - data.writeByte(CommandCodes.setFloodScope); - data.writeByte(0); // reserved byte - data.writeBytes(transportKey); // 16-byte key - await _sendToRadio(data); - } - - /// Clear flood scope (return to unscoped global flood) - Future clearFloodScope() async { - final data = BufferWriter(); - data.writeByte(CommandCodes.setFloodScope); - data.writeByte(0); // reserved byte — no key means clear - await _sendToRadio(data); - } - /// Delete channel by setting it to empty Future deleteChannel(int channelIdx) async { await setChannel(channelIdx, '', Uint8List(16)); @@ -979,38 +904,6 @@ class MeshCoreConnection { return tag; } - /// Send trace path to a specific repeater (targeted ping / zero-hop trace) - /// Returns the 4-byte tag used for matching the response - /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) - Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { - final random = Random.secure(); - final tag = Uint8List.fromList([ - random.nextInt(256), random.nextInt(256), - random.nextInt(256), random.nextInt(256), - ]); - - // Trace uses bitshift encoding: actual_bytes = 1 << path_sz - // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 - final int pathSz; - switch (hopBytes) { - case 4: pathSz = 2; break; - case 2: pathSz = 1; break; - default: pathSz = 0; break; - } - final int flags = pathSz & 0x03; - - debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); - - final data = BufferWriter(); - data.writeByte(CommandCodes.sendTracePath); // 0x24 - data.writeBytes(tag); // 4-byte tag - data.writeUInt32LE(0); // auth_code = 0 - data.writeByte(flags); // flags with path_sz in bits 0-1 - data.writeBytes(repeaterIdBytes); // target repeater ID - await _sendToRadio(data); - return tag; - } - /// Get battery voltage Future getBatteryVoltage() async { final data = BufferWriter(); @@ -1135,17 +1028,6 @@ class MeshCoreConnection { debugLog('[CONN] Stopped battery polling'); } - /// Set path hash mode on the radio - /// mode: 0=1-byte, 1=2-byte, 2=3-byte (persisted in radio prefs) - Future setPathHashMode(int mode) async { - final data = BufferWriter(); - data.writeByte(CommandCodes.setPathHashMode); // 61 (0x3D) - data.writeByte(0); // reserved - data.writeByte(mode); // 0=1-byte, 1=2-byte, 2=3-byte - await _sendToRadio(data); - debugLog('[CONN] Sent setPathHashMode: mode=$mode (${mode + 1}-byte hops)'); - } - /// Reboot device Future reboot() async { final data = BufferWriter(); @@ -1164,7 +1046,6 @@ class MeshCoreConnection { _rawDataController.close(); _logRxDataController.close(); _controlDataController.close(); - _traceDataController.close(); _noiseFloorController.close(); _batteryController.close(); } diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index 30da8862..cf736dd2 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -55,20 +55,6 @@ class CryptoService { return channelKey; } - /// Derive a 16-byte TransportKey from a scope/region name - /// Same algorithm as channel key derivation: SHA-256(name)[0:16] - /// API returns names without '#' prefix (e.g., "ottawa") — we prepend it - /// to match MeshCore's implicit hashtag region convention - static Uint8List deriveScopeKey(String scopeName) { - final name = scopeName.startsWith('#') ? scopeName : '#$scopeName'; - final normalizedName = name.toLowerCase(); - final bytes = utf8.encode(normalizedName); - final digest = sha256.convert(bytes); - final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); - return scopeKey; - } - /// Get channel key for any channel (handles both Public and hashtag channels) /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 5796d6f1..a836f3a3 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -20,9 +20,6 @@ class DiscTracker { /// Callback to check if a repeater should be ignored (carpeater filter) final bool Function(String repeaterId)? shouldIgnoreRepeater; - /// When true, skip RSSI carpeater check (user setting) - final bool disableRssiFilter; - /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when a discovery response is dropped due to carpeater detection void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -31,10 +28,7 @@ class DiscTracker { /// Parameters: (node, isNew) - isNew is true for first time seeing this node void Function(DiscoveredNode node, bool isNew)? onNodeDiscovered; - /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. - final int hopBytes; - - DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); + DiscTracker({this.shouldIgnoreRepeater}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -45,7 +39,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); @@ -137,8 +131,8 @@ class DiscTracker { final pubkey = rawBytes.sublist(7, 39); final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - // Get repeater ID (first N hex chars based on hopBytes setting) - final repeaterId = pubkeyHex.substring(0, hopBytes * 2); + // Get repeater ID (first 2 hex chars = first byte) + final repeaterId = pubkeyHex.substring(0, 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { @@ -147,9 +141,7 @@ class DiscTracker { } // Check RSSI (carpeater failsafe) - if (disableRssiFilter) { - debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); - } else if (PacketValidator.isCarpeater(localRssi)) { + if (PacketValidator.isCarpeater(localRssi)) { debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); @@ -212,7 +204,7 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index c65a6ba1..b3fc7430 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -8,31 +8,31 @@ import 'protocol_constants.dart'; class PacketMetadata { /// Original raw packet bytes final Uint8List raw; - + /// Packet header byte (contains route type, payload type, version) final int header; - + /// Route type (FLOOD=0x01, DIRECT=0x02) final int routeType; - - /// Raw pathLen byte (encodes both hash size and hop count) - final int pathLenRaw; - - /// Number of bytes per hop hash (1, 2, 3, or 4) - final int pathHashSize; - - /// Number of hops in path (0-63) - final int pathHashCount; - - /// Raw path bytes (repeater IDs, length = pathHashCount * pathHashSize) + + /// Number of hops in path + final int pathLength; + + /// Raw path bytes (repeater IDs) final Uint8List pathBytes; - + + /// First hop (first repeater ID) - used for TX tracking + final int? firstHop; + + /// Last hop (last repeater ID) - used for RX logging + final int? lastHop; + /// Signal-to-noise ratio (dB) final double snr; - + /// Received signal strength indicator (dBm) final int rssi; - + /// Encrypted payload (after header + path) final Uint8List encryptedPayload; @@ -40,17 +40,17 @@ class PacketMetadata { required this.raw, required this.header, required this.routeType, - required this.pathLenRaw, - required this.pathHashSize, - required this.pathHashCount, + required this.pathLength, required this.pathBytes, + required this.firstHop, + required this.lastHop, required this.snr, required this.rssi, required this.encryptedPayload, }); /// Parse packet metadata from BLE LogRxData event - /// + /// /// Event structure: /// ```dart /// { @@ -61,21 +61,21 @@ class PacketMetadata { /// ``` factory PacketMetadata.fromLogRxData(Map data) { debugLog('[RX PARSE] Starting metadata parsing'); - + final Uint8List raw = data['raw'] as Uint8List; final double snr = (data['lastSnr'] as num).toDouble(); final int rssi = data['lastRssi'] as int; - + // Dump raw packet for debugging final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); - + // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - + debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); - + // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 int pathLengthOffset = 1; @@ -83,44 +83,42 @@ class PacketMetadata { // TransportFlood or TransportDirect - has 4-byte transport codes pathLengthOffset = 5; } - - // Extract raw pathLen byte and decode multi-byte path encoding - // pathHashSize = (pathLen >> 6) + 1 → 1, 2, 3, or 4 - // pathHashCount = pathLen & 63 → 0-63 hops - final int pathLenRaw = raw[pathLengthOffset]; - final int pathHashSize = (pathLenRaw >> 6) + 1; - final int pathHashCount = pathLenRaw & 63; - final int pathByteLen = pathHashCount * pathHashSize; - - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' - 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); - + + // Extract path length from calculated offset + final int pathLength = raw[pathLengthOffset]; + + debugLog('[RX PARSE] Path length offset: $pathLengthOffset, Path length: $pathLength'); + // Path data starts after path length byte final int pathDataOffset = pathLengthOffset + 1; final Uint8List pathBytes = raw.sublist( pathDataOffset, - (pathDataOffset + pathByteLen).clamp(0, raw.length), + (pathDataOffset + pathLength).clamp(0, raw.length), ); - + + // Derive first and last hops + final int? firstHop = pathBytes.isNotEmpty ? pathBytes.first : null; + final int? lastHop = pathBytes.isNotEmpty ? pathBytes.last : null; + // Extract encrypted payload after path data - final int payloadOffset = pathDataOffset + pathByteLen; + final int payloadOffset = pathDataOffset + pathLength; final Uint8List encryptedPayload = raw.sublist(payloadOffset); - + debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' - 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' - 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' + 'pathLength=$pathLength, ' + 'firstHop=${firstHop != null ? '0x${firstHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' + 'lastHop=${lastHop != null ? '0x${lastHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' 'SNR=$snr, RSSI=$rssi, ' 'payload=${encryptedPayload.length} bytes'); - + return PacketMetadata( raw: raw, header: header, routeType: routeType, - pathLenRaw: pathLenRaw, - pathHashSize: pathHashSize, - pathHashCount: pathHashCount, + pathLength: pathLength, pathBytes: pathBytes, + firstHop: firstHop, + lastHop: lastHop, snr: snr, rssi: rssi, encryptedPayload: encryptedPayload, @@ -162,49 +160,20 @@ class PacketMetadata { return payloadType == PayloadType.advert; } - /// Check if packet is TRACE (trace path response, header 0x26) - bool get isTrace { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; - return payloadType == PayloadType.trace; - } - /// Get first hop as hex string (for TX tracking keys) - /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get firstHopHex { - if (pathHashCount == 0) return null; - return _bytesToHex(pathBytes.sublist(0, pathHashSize)); + return firstHop?.toRadixString(16).padLeft(2, '0'); } /// Get last hop as hex string (for RX logging keys) - /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get lastHopHex { - if (pathHashCount == 0) return null; - return _bytesToHex(pathBytes.sublist(pathBytes.length - pathHashSize)); - } - - /// Get any hop by index as hex string - /// @param hopIndex 0-based hop index (0 = first hop) - String? getHopHex(int hopIndex) { - if (hopIndex < 0 || hopIndex >= pathHashCount) return null; - final offset = hopIndex * pathHashSize; - return _bytesToHex(pathBytes.sublist(offset, offset + pathHashSize)); - } - - /// Convert N bytes to uppercase hex string - String _bytesToHex(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - } - - /// Static version for use in factory constructor - static String _bytesToHexStatic(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return lastHop?.toRadixString(16).padLeft(2, '0'); } @override String toString() { return 'PacketMetadata(header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' - 'firstHop=$firstHopHex, lastHop=$lastHopHex, ' + 'pathLength=$pathLength, firstHop=$firstHopHex, lastHop=$lastHopHex, ' 'SNR=$snr, RSSI=$rssi)'; } } diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index e9cec944..7e9b7847 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -21,15 +21,11 @@ class PacketValidator { /// Allowed channels for validation final Map allowedChannels; - /// When true, skip RSSI carpeater check (user setting) - final bool disableRssiFilter; - - PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); + PacketValidator({required this.allowedChannels}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason - /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) - Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { + Future validate(PacketMetadata metadata) async { try { // Log packet for debugging final rawHex = metadata.raw @@ -38,20 +34,15 @@ class PacketValidator { debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' - 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); + 'PathLength: ${metadata.pathLength} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) - if (skipRssiCheck) { - debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); - } else if (disableRssiFilter) { - debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); - } else if (isCarpeater(metadata.rssi)) { + if (isCarpeater(metadata.rssi)) { debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); - } else { - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } + debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); // VALIDATION 2: Check packet type if (metadata.isGroupText) { @@ -172,18 +163,6 @@ class PacketValidator { return rssi >= maxRssiThreshold; } - /// Check if a hop hex string matches a stored CARpeater ID using prefix truncation. - /// The stored ID is always 3-byte (6 hex chars). Incoming hop IDs vary by the - /// packet's path hash size (1, 2, or 3 bytes). Compares the shorter prefix. - /// Also handles legacy shorter stored IDs from before the 3-byte requirement. - static bool isCarpeaterIdMatch(String hopHex, String storedId) { - final hop = hopHex.toUpperCase(); - final stored = storedId.toUpperCase(); - final compareLen = hop.length < stored.length ? hop.length : stored.length; - if (compareLen == 0) return false; - return hop.substring(0, compareLen) == stored.substring(0, compareLen); - } - /// Calculate ratio of printable ASCII characters (32-126) static double getPrintableRatio(String text) { if (text.isEmpty) return 0.0; diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 3dee89f4..537d229b 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -66,10 +66,8 @@ class CommandCodes { static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; - static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE static const int getStats = 56; // 0x38 static const int sendBinaryReq = 50; - static const int setPathHashMode = 61; // 0x3D - CMD_SET_PATH_HASH_MODE } /// Response codes received from device diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 0451fffc..64b3950e 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -34,17 +34,12 @@ class RxLogger { /// Called with repeater ID and reason when a packet is dropped due to carpeater detection final void Function(String repeaterId, String reason)? onCarpeaterDrop; - /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped - /// to report the underlying repeater with null SNR/RSSI - String? carpeaterPrefix; - RxLogger({ required this.onRxEntry, this.onObservation, required this.getGpsLocation, this.shouldIgnoreRepeater, this.onCarpeaterDrop, - this.carpeaterPrefix, }); /// Start passive RX wardriving @@ -73,35 +68,16 @@ class RxLogger { // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info - if (metadata.pathHashCount == 0) { + if (metadata.pathLength == 0) { debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } - bool carpeaterStripped = false; - String repeaterId; - double? reportedSnr = metadata.snr; - int? reportedRssi = metadata.rssi; - // Extract LAST hop from path (the repeater that directly delivered to us) - final lastHopHex = metadata.lastHopHex!; - - // CARpeater check: the carpeater is co-located with us, so it only - // appears as the last hop (the delivery repeater) on RX packets - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { - if (metadata.pathHashCount < 2) { - debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); - return false; - } - // Second-to-last hop = the real repeater that forwarded to our carpeater - repeaterId = metadata.getHopHex(metadata.pathHashCount - 2)!; - carpeaterStripped = true; - reportedSnr = null; - reportedRssi = null; - debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); - } else { - repeaterId = lastHopHex; - } + // Do this early so we have repeater ID for carpeater logging + // Mask to last byte only (0xFF) for consistent 2-character display + final lastHopId = metadata.lastHop! & 0xFF; + final repeaterId = lastHopId.toRadixString(16).padLeft(2, '0').toUpperCase(); // Get current GPS location final gpsLocation = getGpsLocation(); @@ -113,15 +89,13 @@ class RxLogger { // Check if this repeater should be ignored (user carpeater filter) // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore - // Skip for CARpeater pass-through (CARpeater itself was already handled) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { + if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging - // Skip RSSI check for CARpeater pass-through - final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); + final validation = await validator.validate(metadata); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -136,17 +110,17 @@ class RxLogger { return false; } - debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' - 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); + debugLog('[RX LOG] Packet heard via last hop: $repeaterId, ' + 'SNR=${metadata.snr}, path_length=${metadata.pathLength}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, - snr: reportedSnr, - rssi: reportedRssi, - pathLength: metadata.pathHashCount, + snr: metadata.snr, + rssi: metadata.rssi, + pathLength: metadata.pathLength, header: metadata.header, lat: gpsLocation.lat, lon: gpsLocation.lon, @@ -158,9 +132,9 @@ class RxLogger { // Returns true if this observation updated the batch (new repeater or better SNR) final wasKept = await _handleRxBatching( repeaterId: repeaterId, - snr: reportedSnr, - rssi: reportedRssi, - pathLength: metadata.pathHashCount, + snr: metadata.snr, + rssi: metadata.rssi, + pathLength: metadata.pathLength, header: metadata.header, currentLocation: gpsLocation, metadata: metadata, @@ -175,10 +149,10 @@ class RxLogger { final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' - 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); + 'snr=${batchedObservation.snr}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' - 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); + 'snr=${metadata.snr}, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } return true; @@ -194,8 +168,8 @@ class RxLogger { /// Returns true if this observation was kept (new repeater or better SNR) Future _handleRxBatching({ required String repeaterId, - required double? snr, - required int? rssi, + required double snr, + required int rssi, required int pathLength, required int header, required ({double lat, double lon}) currentLocation, @@ -233,14 +207,9 @@ class RxLogger { debugLog('[RX BATCH] Started 30s timeout timer for repeater $repeaterId'); } else { // Already tracking this repeater - check if new SNR is better - // Null SNR never replaces non-null; non-null always replaces null - final existingSnr = buffer.bestObservation.snr; - final shouldUpdate = snr != null && existingSnr != null - ? snr > existingSnr - : snr != null && existingSnr == null; - if (shouldUpdate) { + if (snr > buffer.bestObservation.snr) { debugLog('[RX BATCH] Better SNR for repeater $repeaterId: ' - '$existingSnr -> $snr'); + '${buffer.bestObservation.snr} -> $snr'); // IMPORTANT: Keep the FIRST location where we heard this repeater, // only update the SNR/RSSI/metadata. This ensures the map pin stays // at the original location and doesn't follow the user. @@ -454,8 +423,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double snr; + final int rssi; final int pathLength; final int header; final double lat; @@ -465,8 +434,8 @@ class RxObservation { RxObservation({ required this.repeaterId, - this.snr, - this.rssi, + required this.snr, + required this.rssi, required this.pathLength, required this.header, required this.lat, @@ -481,8 +450,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double snr; + final int rssi; final int pathLength; final int header; final DateTime timestamp; @@ -492,8 +461,8 @@ class RxApiEntry { required this.repeaterId, required this.lat, required this.lon, - this.snr, - this.rssi, + required this.snr, + required this.rssi, required this.pathLength, required this.header, required this.timestamp, diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart deleted file mode 100644 index ad7b5297..00000000 --- a/lib/services/meshcore/trace_tracker.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import '../../utils/debug_logger_io.dart'; - -/// Result of a trace path probe to a specific repeater -class TraceResult { - final String targetRepeaterId; - final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) - final int localRssi; // RSSI from BLE event metadata - final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) - final bool success; - - const TraceResult({ - required this.targetRepeaterId, - required this.localSnr, - required this.localRssi, - required this.remoteSnr, - required this.success, - }); -} - -/// Trace path tracker for targeted ping (zero-hop trace) -/// Sends CMD_SEND_TRACE_PATH (0x24) to a specific repeater and listens -/// for the trace response (PUSH_CODE_TRACE_DATA, 0x89). -/// -/// Follows the DiscTracker pattern but simpler: expects exactly 1 response. -class TraceTracker { - bool isListening = false; - Uint8List? _expectedTag; - String _targetRepeaterId = ''; - TraceResult? _result; - Timer? _windowTimer; - - /// BLE metadata from the 0x88 LogRxData event that arrives before the 0x89 TraceData. - /// Set by UnifiedRxHandler when it sees a trace packet in the LogRxData stream. - double pendingBleSnr = 0.0; - int pendingBleRssi = 0; - - /// Fired when a trace response is received during the window - void Function(TraceResult)? onTraceReceived; - - /// Fired when the listening window ends (result is null if no response) - void Function(TraceResult?)? onWindowComplete; - - TraceTracker(); - - /// Start tracking trace responses - void startTracking({ - required Uint8List tag, - required String targetRepeaterId, - Duration windowDuration = const Duration(seconds: 7), - }) { - debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); - debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); - - isListening = true; - _expectedTag = tag; - _targetRepeaterId = targetRepeaterId; - _result = null; - pendingBleSnr = 0.0; - pendingBleRssi = 0; - - // Start window timer - _windowTimer?.cancel(); - _windowTimer = Timer(windowDuration, _endWindow); - - debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); - } - - /// Handle incoming trace data packet (0x89) - /// Returns true if the packet was a valid trace response - /// - /// Trace response format (per meshcore_py reference): - /// Byte 0: reserved (skip) - /// Byte 1: path_len (raw byte count of path hashes, NOT LogRxData encoding) - /// Byte 2: flags → hashSize = 1 << (flags & 3) - /// Bytes 3-6: tag → match against _expectedTag - /// Bytes 7-10: auth_code (skip) - /// Bytes 11 to 11+path_len: path_hashes → extract repeater ID - /// Next hopCount+1 bytes: path_snrs → each is signedInt8 / 4.0 for dB - /// For zero-hop (hopCount=1): remoteSnr = snrs[0], localSnr = snrs[1] - bool handlePacket(Uint8List rawBytes, double bleSnr, int bleRssi) { - if (!isListening) return false; - - try { - // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes - if (rawBytes.length < 11) { - debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); - return false; - } - - // Skip byte 0 (reserved) - final pathLen = rawBytes[1]; // Raw byte count of path hashes - final flags = rawBytes[2]; - - // Decode per 0x89 trace format (meshcore_py reference): - // hash size from flags, hop count from path_len / hash_size - final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop - final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; - - debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); - - // Extract tag (bytes 3-6) - final tag = rawBytes.sublist(3, 7); - - // Match tag against expected - final expectedTag = _expectedTag; - if (expectedTag != null) { - bool tagMatch = true; - for (int i = 0; i < 4; i++) { - if (tag[i] != expectedTag[i]) { - tagMatch = false; - break; - } - } - if (!tagMatch) { - debugLog('[TRACE] Tag mismatch, ignoring packet'); - return false; - } - } - - // Skip auth_code (bytes 7-10) - - // Extract path hashes (bytes 11 to 11 + hopCount*hashSize) - const pathStart = 11; - final pathEnd = pathStart + (hopCount * hashSize); - - if (rawBytes.length < pathEnd) { - debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); - return false; - } - - // Extract repeater ID from first hop in path - String repeaterId = ''; - if (hopCount > 0) { - final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); - repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - } - - // Extract path SNRs (hopCount+1 bytes after path hashes) - final snrStart = pathEnd; - final snrEnd = snrStart + hopCount + 1; - - double remoteSnr = 0.0; - double localSnr = bleSnr; // Default to BLE metadata SNR - - if (rawBytes.length >= snrEnd && hopCount >= 1) { - // Each SNR byte is signed int8, divide by 4.0 for dB - remoteSnr = rawBytes[snrStart].toSigned(8) / 4.0; - if (hopCount + 1 >= 2) { - localSnr = rawBytes[snrStart + 1].toSigned(8) / 4.0; - } - } - - debugLog('[TRACE] Trace response from $repeaterId: ' - 'localSnr=${localSnr.toStringAsFixed(2)}, ' - 'remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' - 'bleRssi=$bleRssi'); - - _result = TraceResult( - targetRepeaterId: _targetRepeaterId, - localSnr: localSnr, - localRssi: bleRssi, - remoteSnr: remoteSnr, - success: true, - ); - - // Notify callback - onTraceReceived?.call(_result!); - - return true; - } catch (e, stackTrace) { - debugError('[TRACE] Error processing trace response: $e'); - debugError('[TRACE] Stack trace: $stackTrace'); - return false; - } - } - - /// Stop tracking and return result - TraceResult? stopTracking() { - debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); - - final result = _result; - isListening = false; - _windowTimer?.cancel(); - _windowTimer = null; - _expectedTag = null; - - return result; - } - - /// Handle trace window completion - void _endWindow() { - debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); - - final result = _result; - isListening = false; - _windowTimer = null; - _expectedTag = null; - pendingBleSnr = 0.0; - pendingBleRssi = 0; - - onWindowComplete?.call(result); - } - - /// Dispose of resources - void dispose() { - stopTracking(); - } -} diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 50b1d2ca..999a16dd 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,7 +7,7 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; -/// TX echo tracker for repeater detection during 5-second window +/// TX echo tracker for repeater detection during 7-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { bool isListening = false; @@ -22,14 +22,9 @@ class TxTracker { Timer? _windowTimer; - /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped - /// to report the underlying repeater with null SNR/RSSI - String? carpeaterPrefix; - /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater - /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) - void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + void Function(String repeaterId, double snr, int rssi, bool isNew)? onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -39,9 +34,6 @@ class TxTracker { /// Returns true if the repeater should be filtered out bool Function(String repeaterId)? shouldIgnoreRepeater; - /// When true, skip RSSI carpeater check (user setting) - bool disableRssiFilter = false; - /// Start tracking echoes for a sent ping /// /// @param payload - The message text sent (for content verification) @@ -54,7 +46,7 @@ class TxTracker { required int channelIdx, required int channelHash, required Uint8List channelKey, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); @@ -86,7 +78,7 @@ class TxTracker { // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr}, seen=${entry.value.seenCount}x'); } } } @@ -112,55 +104,31 @@ class TxTracker { // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops - if (metadata.pathHashCount == 0) { + if (metadata.pathLength == 0) { debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } // Extract first hop (first repeater) for use in validation and logging - var pathHex = metadata.firstHopHex!; - - // CARpeater pass-through: strip CARpeater hop and report underlying repeater - bool carpeaterStripped = false; - double? reportedSnr = metadata.snr; - int? reportedRssi = metadata.rssi; - - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { - if (metadata.pathHashCount < 2) { - debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); - return false; - } - // Multi-hop: strip CARpeater, report underlying repeater (second hop) - final underlyingHex = metadata.getHopHex(1)!; - debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); - pathHex = underlyingHex; - carpeaterStripped = true; - reportedSnr = null; - reportedRssi = null; - } + final firstHopId = metadata.firstHop!; + final pathHex = firstHopId.toRadixString(16).padLeft(2, '0'); // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { + if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - // Skip for CARpeater pass-through (user explicitly identified their CARpeater) - if (carpeaterStripped) { - debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); - } else if (disableRssiFilter) { - debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); - } else if (PacketValidator.isCarpeater(metadata.rssi)) { + if (PacketValidator.isCarpeater(metadata.rssi)) { debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) - } else { - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } + debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { @@ -236,53 +204,50 @@ class TxTracker { // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' - 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); + debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=${metadata.snr}, ' + 'full_path_length=${metadata.pathLength}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; if (repeaters.containsKey(pathHex)) { final existing = repeaters[pathHex]!; debugLog('[PING] Deduplication: path $pathHex already seen ' - '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); + '(existing SNR=${existing.snr}, new SNR=${metadata.snr})'); - // Keep the best (highest) SNR — null SNR never replaces non-null - final shouldUpdate = reportedSnr != null && existing.snr != null - ? reportedSnr > existing.snr! - : reportedSnr != null && existing.snr == null; - if (shouldUpdate) { + // Keep the best (highest) SNR + if (metadata.snr > existing.snr) { debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' - '${existing.snr} -> $reportedSnr'); + '${existing.snr} -> ${metadata.snr}'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: reportedSnr, - rssi: reportedRssi, + snr: metadata.snr, + rssi: metadata.rssi, seenCount: existing.seenCount + 1, ); } else { debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' - '(existing ${existing.snr} >= new $reportedSnr)'); + '(existing ${existing.snr} >= new ${metadata.snr})'); // Still increment seen count existing.seenCount++; } } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=${metadata.snr}, RSSI=${metadata.rssi}'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: reportedSnr, - rssi: reportedRssi, + snr: metadata.snr, + rssi: metadata.rssi, seenCount: 1, ); } // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; - final bestRssi = repeaters[pathHex]!.rssi; + final rssi = repeaters[pathHex]!.rssi; debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); + onEchoReceived!(pathHex, bestSnr, rssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); } @@ -304,14 +269,14 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { final String repeaterId; // Hex string - double? snr; // Best SNR seen (null for CARpeater pass-through) - int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) + double snr; // Best SNR seen + int rssi; // RSSI value (dBm) int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, - this.snr, - this.rssi, + required this.snr, + required this.rssi, this.seenCount = 1, }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 1c95dd2c..f78c9629 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -4,7 +4,6 @@ import '../../utils/debug_logger_io.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; import 'rx_logger.dart'; -import 'trace_tracker.dart'; import 'tx_tracker.dart'; /// Unified RX handler - orchestrates TX echo tracking and passive RX logging @@ -19,9 +18,6 @@ class UnifiedRxHandler { /// Get current validator PacketValidator get validator => _validator; - /// Trace tracker for targeted ping mode (set by PingService when active) - TraceTracker? traceTracker; - /// Channel key for message decryption (injected for TX validation) Uint8List? channelKey; @@ -75,22 +71,9 @@ class UnifiedRxHandler { debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' - 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); - - // Store BLE metadata from 0x88 LogRxData for trace packets. - // The actual trace payload arrives separately via 0x89 TraceData stream. - // We only store RSSI/SNR here — the 0x89 handler combines them. - if (metadata.isTrace) { - final tt = traceTracker; - if (tt != null && tt.isListening) { - debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); - tt.pendingBleSnr = metadata.snr; - tt.pendingBleRssi = metadata.rssi; - } - return; // Trace packets don't go to TX tracker or RX logger - } - - // Route to TX tracking if active (during 5s echo window) + 'pathLength=${metadata.pathLength}'); + + // Route to TX tracking if active (during 7s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); final wasEcho = await txTracker.handlePacket(metadata); diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index d37cd8db..282d911d 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -12,7 +12,6 @@ class OfflineSession { final Map data; final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display - final String? contactUri; // Signed contact URI for registration during upload final bool uploaded; // Track upload status OfflineSession({ @@ -22,7 +21,6 @@ class OfflineSession { required this.data, this.devicePublicKey, this.deviceName, - this.contactUri, this.uploaded = false, }); @@ -35,7 +33,6 @@ class OfflineSession { data: json['data'] as Map, devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, - contactUri: json['contactUri'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -49,7 +46,6 @@ class OfflineSession { 'data': data, 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, - 'contactUri': contactUri, 'uploaded': uploaded, }; } @@ -63,7 +59,6 @@ class OfflineSession { data: data, devicePublicKey: devicePublicKey, deviceName: deviceName, - contactUri: contactUri, uploaded: uploaded ?? this.uploaded, ); } @@ -83,10 +78,6 @@ class OfflineSessionService { SharedPreferences? _prefs; List _sessions = []; - /// Tracks the filename of the session currently being accumulated via periodic auto-save. - /// When set, `updateCurrentSession()` updates this session in-place instead of creating a new one. - String? _currentSessionFilename; - /// Callback when sessions list changes void Function(List sessions)? onSessionsUpdated; @@ -150,7 +141,6 @@ class OfflineSessionService { List> pings, { String? devicePublicKey, String? deviceName, - String? contactUri, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -177,7 +167,6 @@ class OfflineSessionService { data: sessionData, devicePublicKey: devicePublicKey, deviceName: deviceName, - contactUri: contactUri, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -186,70 +175,6 @@ class OfflineSessionService { debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } - /// Update the current in-progress session with the latest pings snapshot. - /// If no current session exists, creates a new one and tracks it. - /// This allows periodic saves to update the same file instead of creating duplicates. - Future updateCurrentSession( - List> pings, { - String? devicePublicKey, - String? deviceName, - String? contactUri, - }) async { - if (pings.isEmpty) { - debugLog('[OFFLINE] No pings to auto-save, skipping'); - return; - } - - // If we have a tracked session, update it in-place - if (_currentSessionFilename != null) { - final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); - if (index != -1) { - final existing = _sessions[index]; - final updatedData = Map.from(existing.data); - updatedData['pings'] = pings; - updatedData['ping_count'] = pings.length; - - _sessions[index] = OfflineSession( - filename: existing.filename, - createdAt: existing.createdAt, - pingCount: pings.length, - data: updatedData, - devicePublicKey: devicePublicKey ?? existing.devicePublicKey, - deviceName: deviceName ?? existing.deviceName, - contactUri: contactUri ?? existing.contactUri, - ); - await _saveSessions(); - debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); - return; - } - // Session was deleted externally — fall through to create new - debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); - _currentSessionFilename = null; - } - - // No current session — create a new one and track it - await saveSession( - pings, - devicePublicKey: devicePublicKey, - deviceName: deviceName, - contactUri: contactUri, - ); - // saveSession inserts at index 0 (newest first) - if (_sessions.isNotEmpty) { - _currentSessionFilename = _sessions.first.filename; - debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); - } - } - - /// Clear the current session tracker so the next save creates a fresh session file. - /// Called after final saves (mode switch, disconnect) to create a clean break. - void finalizeCurrentSession() { - if (_currentSessionFilename != null) { - debugLog('[OFFLINE] Finalized session: $_currentSessionFilename'); - _currentSessionFilename = null; - } - } - /// Mark a session as uploaded without deleting it Future markAsUploaded(String filename) async { final index = _sessions.indexWhere((s) => s.filename == filename); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index d7822824..685cdbea 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:geolocator/geolocator.dart'; @@ -13,9 +12,7 @@ import 'countdown_timer_service.dart'; import 'gps_service.dart'; import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; -import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; -import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; /// Ping service for TX/RX ping orchestration @@ -25,7 +22,7 @@ import 'wakelock_service.dart'; /// 1. Validate GPS lock, 25m min distance (zone validation handled server-side) /// 2. Start TxTracker to monitor for repeater echoes /// 3. Send @[MapperBot] LAT, LON [POWERw] to #wardriving channel -/// 4. Start 5-second RX listening window +/// 4. Start 6-second RX listening window (matches JS RX_LOG_LISTEN_WINDOW_MS) /// 5. Post to API queue with type "TX" /// /// RX Flow (via TxTracker): @@ -36,24 +33,21 @@ import 'wakelock_service.dart'; /// /// Discovery Flow (Passive Mode only): /// 1. In Passive Mode, send discovery request instead of TX ping -/// 2. Start 5-second listening window via DiscTracker +/// 2. Start 7-second listening window via DiscTracker /// 3. Collect discovery responses (0x8E packets) /// 4. After window ends, create log entry and queue DISC API payloads class PingService { - /// RX listening window duration (5 seconds - matches cooldown duration) - static const Duration _rxListeningWindow = Duration(seconds: 5); - /// Cooldown period between pings (5 seconds) - static const Duration _autoPingCooldown = Duration(seconds: 5); - /// Discovery listening window duration (5 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 5); + /// RX listening window duration (7 seconds - matches cooldown duration) + static const Duration _rxListeningWindow = Duration(seconds: 7); + /// Cooldown period between pings (7 seconds - matches JS COOLDOWN_MS = 7000) + static const Duration _autoPingCooldown = Duration(seconds: 7); + /// Discovery listening window duration (7 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 7); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); - /// Current configured min ping distance (for validation messages) - static int currentMinDistance = 25; - final GpsService _gpsService; final MeshCoreConnection _connection; final ApiQueueService _apiQueue; @@ -67,24 +61,6 @@ class PingService { final AudioService? _audioService; final bool Function(String repeaterId)? shouldIgnoreRepeater; - /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. - int _hopBytes; - - /// Number of bytes for trace path IDs (1, 2, or 4). Uses bitshift encoding, separate from TX. - int _traceHopBytes; - - /// Update hop bytes at runtime (e.g. when user changes path mode while connected) - set hopBytes(int value) => _hopBytes = value; - - /// Update trace hop bytes at runtime (e.g. when user changes trace byte setting) - set traceHopBytes(int value) => _traceHopBytes = value; - - /// When true, skip RSSI carpeater check in DiscTracker (user setting) - bool disableRssiFilter; - - /// Unified RX handler reference for routing trace packets - UnifiedRxHandler? unifiedRxHandler; - PingStats _stats = const PingStats(); DateTime? _lastTxTime; Timer? _rxWindowTimer; @@ -101,17 +77,9 @@ class PingService { bool _autoPingEnabled = false; bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; - bool _targetedModeEnabled = false; bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; - // Targeted mode tracking - TraceTracker? _traceTracker; - StreamSubscription? _traceDataSubscription; - Timer? _targetedTimer; - String? _targetRepeaterId; - Position? _lastTargetedPosition; - // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; @@ -137,9 +105,6 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; - /// Callback to check if discovery drop is enabled (failed discoveries → API) - bool Function()? getDiscDropEnabled; - /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; @@ -167,13 +132,6 @@ class PingService { /// Parameters: (bool success) - true if any nodes discovered, false if none void Function(bool success)? onDiscoveryWindowComplete; - /// Callback when trace window ends (for noise floor graph + log) - /// Parameters: (TraceResult? result) - null if no response - void Function(TraceResult? result)? onTraceWindowComplete; - - /// Callback when trace ping is sent (for log entry creation) - void Function(TraceLogEntry)? onTracePing; - /// Callback when pingInProgress changes (for immediate UI refresh) void Function()? onPingProgressChanged; @@ -201,9 +159,6 @@ class PingService { TxTracker? txTracker, AudioService? audioService, this.shouldIgnoreRepeater, - this.disableRssiFilter = false, - int hopBytes = 1, - int traceHopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -214,9 +169,7 @@ class PingService { _discoveryWindowCountdown = discoveryWindowTimer, _deviceId = deviceId, _txTracker = txTracker, - _audioService = audioService, - _hopBytes = hopBytes, - _traceHopBytes = traceHopBytes; + _audioService = audioService; /// Get current ping statistics PingStats get stats => _stats; @@ -233,9 +186,6 @@ class PingService { /// Check if Hybrid Mode is active (alternates discovery + TX) bool get isHybridMode => _hybridModeEnabled; - /// Check if Targeted Mode is active (zero-hop trace to specific repeater) - bool get isTargetedMode => _targetedModeEnabled; - /// Check if discovery tracker is currently listening (for Passive Mode UI) bool get isDiscoveryListening => _discTracker?.isListening ?? false; @@ -315,7 +265,7 @@ class PingService { return PingValidation.tooCloseToLastPing; } - // Check cooldown (5 seconds between pings) + // Check cooldown (7 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -424,7 +374,7 @@ class PingService { // NOTE: Skip distance check (tooCloseToLastPing) intentionally // Auto mode handles this by setting skipReason='too close' and scheduling next ping - // Check cooldown (5 seconds between pings) + // Check cooldown (7 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -497,7 +447,7 @@ class PingService { return false; } } else { - // Auto ping: 5-second cooldown, 25m distance check + // Auto ping: 7-second cooldown, 25m distance check // This fixes a race condition where disabling Active Mode during cooldown // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { @@ -632,7 +582,7 @@ class PingService { // Manual ping: 15-second cooldown, no distance check _manualPingCooldownTimer.start(_manualPingCooldown.inMilliseconds); } else { - // Auto ping: 5-second cooldown + // Auto ping: 7-second cooldown _cooldownTimer.start(_autoPingCooldown.inMilliseconds); } @@ -660,13 +610,14 @@ class PingService { } } - /// Start the 5-second RX listening window after TX + /// Start the 6-second RX listening window after TX /// Note: TxTracker handles the actual echo tracking, we just manage the countdown UI + /// Reference: RX_LOG_LISTEN_WINDOW_MS = 6000 in wardrive.js void _startRxListeningWindow(Position txPosition) { // Cancel previous timer _rxWindowTimer?.cancel(); - // Start RX window countdown display (5 seconds) + // Start RX window countdown display (6 seconds - matches JS RX_LOG_LISTEN_WINDOW_MS) _rxWindowCountdown.start(_rxListeningWindow.inMilliseconds); // Set timer for window end @@ -696,10 +647,8 @@ class PingService { for (final entry in txTracker.repeaters.entries) { final repeaterId = entry.key; final echo = entry.value; - // Format SNR with 2 decimal places, or "null" for CARpeater pass-through - repeaterStrings.add(echo.snr != null - ? '$repeaterId(${echo.snr!.toStringAsFixed(2)})' - : '$repeaterId(null)'); + // Format SNR with 2 decimal places + repeaterStrings.add('$repeaterId(${echo.snr.toStringAsFixed(2)})'); debugLog('[PING] Heard repeater: $repeaterId, SNR=${echo.snr}'); } heardRepeats = repeaterStrings.join(','); @@ -742,11 +691,9 @@ class PingService { debugLog('[PING] Executing pending disable after RX window'); _pendingDisable = false; final wasHybrid = _hybridModeEnabled; - final wasTargeted = _targetedModeEnabled; _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; - _targetedModeEnabled = false; _nextPingIsDiscovery = true; _autoTimer?.cancel(); _autoTimer = null; @@ -754,10 +701,6 @@ class PingService { if (wasHybrid) { _stopDiscoveryMode(); } - // Clean up targeted infrastructure if targeted was enabled - if (wasTargeted) { - _stopTargetedMode(); - } // Start cooldown immediately _cooldownTimer.start(_autoPingCooldown.inMilliseconds); debugLog('[PING] Pending disable complete, cooldown started'); @@ -850,34 +793,20 @@ class PingService { } } - /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), - /// Hybrid Mode, or Targeted Mode (zero-hop trace) + /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), or Hybrid Mode /// Reference: startAutoPing() in wardrive.js /// @param passiveMode - If true, only listens for RX (no TX pings) - this is Passive Mode /// @param hybridMode - If true, alternates discovery + TX pings each interval - /// @param targetedMode - If true, sends trace path to specific repeater - /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) - Future enableAutoPing({ - bool passiveMode = false, - bool hybridMode = false, - bool targetedMode = false, - String? targetRepeaterId, - }) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); + Future enableAutoPing({bool passiveMode = false, bool hybridMode = false}) async { + debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); return false; } - // Targeted mode requires a repeater ID - if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { - debugLog('[AUTO] Targeted mode requires a repeater ID'); - return false; - } - // Check if we're in cooldown (can't start during cooldown) - // Hybrid, Active, and Targeted modes are blocked by cooldown, Passive is not + // Hybrid and Active modes are blocked by cooldown, Passive is not // Reference: isInCooldown() check in startAutoPing() in wardrive.js if (!passiveMode && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); @@ -895,23 +824,14 @@ class PingService { _autoPingEnabled = true; _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; - _targetedModeEnabled = targetedMode; _nextPingIsDiscovery = true; // Always start hybrid with discovery - if (targetedMode) { - _targetRepeaterId = targetRepeaterId; - } - // Enable wake lock to keep screen on during auto mode // Reference: acquireWakeLock() in wardrive.js debugLog('[AUTO] Acquiring wake lock for auto mode'); await _wakelockService.enable(); - if (targetedMode) { - // Targeted Mode: send trace path to specific repeater - debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); - await _startTargetedMode(); - } else if (hybridMode) { + if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); @@ -964,20 +884,14 @@ class PingService { // Clear skip reason _skipReason = null; - // Clean up discovery infrastructure if passive or hybrid was enabled - if (_passiveModeEnabled || _hybridModeEnabled) { + // Clean up discovery infrastructure if hybrid was enabled + if (_hybridModeEnabled) { _stopDiscoveryMode(); } - // Clean up targeted mode infrastructure - if (_targetedModeEnabled) { - _stopTargetedMode(); - } - _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; - _targetedModeEnabled = false; _nextPingIsDiscovery = true; // Disable wake lock when auto mode stops @@ -998,10 +912,8 @@ class PingService { _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; - _targetedModeEnabled = false; _nextPingIsDiscovery = true; _stopDiscoveryMode(); - _stopTargetedMode(); await _wakelockService.disable(); } @@ -1020,11 +932,7 @@ class PingService { debugLog('[DISC] Starting discovery mode'); // Create and configure discovery tracker - final tracker = DiscTracker( - shouldIgnoreRepeater: shouldIgnoreRepeater, - disableRssiFilter: disableRssiFilter, - hopBytes: _hopBytes, - ); + final tracker = DiscTracker(shouldIgnoreRepeater: shouldIgnoreRepeater); _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { @@ -1104,8 +1012,8 @@ class PingService { position.latitude, position.longitude, ); - if (distance < _gpsService.configuredMinDistance) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + if (distance < GpsService.minDistanceMeters) { + debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < 25m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1156,7 +1064,7 @@ class PingService { windowDuration: _discoveryListeningWindow, ); - // Start discovery window countdown display (5 seconds) + // Start discovery window countdown display (7 seconds) _discoveryWindowCountdown.start(_discoveryListeningWindow.inMilliseconds); // Clear pingInProgress now that discovery window is active @@ -1213,18 +1121,6 @@ class PingService { onStatsUpdated?.call(_stats); } else { debugLog('[DISC] No nodes discovered'); - - // Queue failed discovery to API if disc drop is enabled - if (getDiscDropEnabled?.call() == true) { - _apiQueue.enqueueDiscDrop( - latitude: position.latitude, - longitude: position.longitude, - timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, - externalAntenna: getExternalAntenna?.call() ?? false, - noiseFloor: _pendingTxNoiseFloor, - ); - debugLog('[DISC] Discovery drop queued (no response)'); - } } // Entry already added to log via onDiscPing - no need to fire onDiscoveryComplete @@ -1275,8 +1171,8 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 5000 + // At 15s: wait = 15000 - 7000 = 8000ms. Clamp to min 1s. + final listenMs = _rxListeningWindow.inMilliseconds; // 7000 final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1303,219 +1199,6 @@ class PingService { }); } - // ============================================ - // Targeted Mode (Zero-Hop Trace) - // ============================================ - - /// Start targeted mode - subscribes to trace data and sends first trace - Future _startTargetedMode() async { - debugLog('[TRACE] Starting targeted mode for repeater $_targetRepeaterId'); - - // Create trace tracker - final tracker = TraceTracker(); - _traceTracker = tracker; - tracker.onTraceReceived = (result) { - debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); - }; - tracker.onWindowComplete = (result) { - debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); - _handleTraceWindowComplete(result); - }; - - // Wire trace tracker into UnifiedRxHandler so 0x88 BLE metadata - // gets stored for the 0x89 handler - unifiedRxHandler?.traceTracker = tracker; - - // Subscribe to 0x89 TraceData stream for actual trace payloads - _traceDataSubscription = _connection.traceDataStream.listen((raw) { - final tt = _traceTracker; - if (tt != null && tt.isListening) { - tt.handlePacket(raw, tt.pendingBleSnr, tt.pendingBleRssi); - } - }); - - // Send first trace immediately - await _sendTargetedPing(); - } - - /// Stop targeted mode - cleans up tracker and subscription - void _stopTargetedMode() { - debugLog('[TRACE] Stopping targeted mode'); - _targetedTimer?.cancel(); - _targetedTimer = null; - _traceDataSubscription?.cancel(); - _traceDataSubscription = null; - unifiedRxHandler?.traceTracker = null; - _traceTracker?.dispose(); - _traceTracker = null; - _lastTargetedPosition = null; - } - - /// Send a targeted ping (trace path) and start listening window - Future _sendTargetedPing() async { - if (!_autoPingEnabled || !_targetedModeEnabled) { - debugLog('[TRACE] Not in targeted mode, skipping trace'); - return; - } - - final targetId = _targetRepeaterId; - if (targetId == null || targetId.isEmpty) { - debugLog('[TRACE] No target repeater ID, skipping trace'); - _scheduleNextTargetedPing(); - return; - } - - // Check GPS - final position = _gpsService.lastPosition; - if (position == null) { - debugLog('[TRACE] No GPS position, skipping trace'); - _pingInProgress = false; - _scheduleNextTargetedPing(); - return; - } - - // Check minimum distance from last trace (25m) - final lastPos = _lastTargetedPosition; - if (lastPos != null) { - final distance = Geolocator.distanceBetween( - lastPos.latitude, lastPos.longitude, - position.latitude, position.longitude, - ); - if (distance < _gpsService.configuredMinDistance) { - debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); - _skipReason = 'too close'; - _pingInProgress = false; - _scheduleNextTargetedPing(); - return; - } - } - - // Clear skip reason since we're proceeding - _skipReason = null; - - // Signal "Sending..." to UI - _pingInProgress = true; - onPingProgressChanged?.call(); - - // Capture noise floor - final noiseFloor = _connection.lastNoiseFloor; - _pendingTxNoiseFloor = noiseFloor; - - // Create trace log entry immediately - final traceEntry = TraceLogEntry( - timestamp: DateTime.now(), - latitude: position.latitude, - longitude: position.longitude, - targetRepeaterId: targetId, - noiseFloor: noiseFloor, - success: false, // Will be updated after window completes - ); - onTracePing?.call(traceEntry); - - debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); - - try { - // Play transmit sound - _audioService?.playTransmitSound(); - - // Convert hex repeater ID to bytes (trace uses separate byte size: 1, 2, or 4) - final traceBytes = _traceHopBytes; - final repeaterIdBytes = Uint8List(traceBytes); - for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { - repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); - } - - // Send trace path and get tag - final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); - - // Start tracking with the tag - _traceTracker?.startTracking( - tag: tag, - targetRepeaterId: targetId, - windowDuration: _rxListeningWindow, - ); - - // Start listening window countdown display - _discoveryWindowCountdown.start(_rxListeningWindow.inMilliseconds); - - // Clear pingInProgress now that trace window is active - _pingInProgress = false; - - // Update last targeted position for 25m check - _lastTargetedPosition = position; - - } catch (e) { - _pingInProgress = false; - debugError('[TRACE] Failed to send trace: $e'); - _scheduleNextTargetedPing(); - } - } - - /// Handle trace window completion - void _handleTraceWindowComplete(TraceResult? result) { - _discoveryWindowCountdown.stop(); - final position = _lastTargetedPosition; - final targetId = _targetRepeaterId ?? ''; - - if (result != null && result.success && position != null) { - debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); - - // Queue to API (only successful traces) - _apiQueue.enqueueTrace( - latitude: position.latitude, - longitude: position.longitude, - repeaterId: targetId, - localSnr: result.localSnr, - localRssi: result.localRssi, - remoteSnr: result.remoteSnr, - timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, - externalAntenna: getExternalAntenna?.call() ?? false, - noiseFloor: _pendingTxNoiseFloor, - ); - - // Update stats - _stats = _stats.copyWith(traceCount: _stats.traceCount + 1); - onStatsUpdated?.call(_stats); - - // Play receive sound for successful trace - _audioService?.playReceiveSound(); - } else { - debugLog('[TRACE] Trace failed: no response from $targetId'); - // Failed traces are NOT posted to API (local visual only) - } - - // Notify for noise floor graph and log updates - onTraceWindowComplete?.call(result); - - _scheduleNextTargetedPing(); - } - - /// Schedule next targeted ping after interval - void _scheduleNextTargetedPing() { - if (!_autoPingEnabled || !_targetedModeEnabled) { - debugLog('[TRACE] Not in targeted mode, not scheduling next trace'); - return; - } - - _targetedTimer?.cancel(); - _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { - debugLog('[TRACE] Targeted ping timer fired'); - if (_autoPingEnabled && _targetedModeEnabled) { - if (_pingInProgress) { - debugLog('[TRACE] Ping already in progress, skipping'); - return; - } - _skipReason = null; - _sendTargetedPing(); - } - }); - - // Notify callback for countdown display - onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); - - debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); - } - /// Stop any active TX echo tracking window /// Called when disabling auto mode to prevent late timer callbacks from /// triggering pings during cooldown (race condition fix) @@ -1538,7 +1221,6 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; _stopDiscoveryMode(); - _stopTargetedMode(); _wakelockService.dispose(); } } @@ -1573,7 +1255,7 @@ enum PingValidation { /// Too close to last ping (< 25m) tooCloseToLastPing, - /// Cooldown period active (< 5s since last ping) + /// Cooldown period active (< 7s since last ping) cooldownActive, /// Manual ping cooldown period active (< 15s since last manual ping) @@ -1603,9 +1285,9 @@ extension PingValidationExtension on PingValidation { case PingValidation.outsideGeofence: return 'Outside service area'; case PingValidation.tooCloseToLastPing: - return 'Move ${PingService.currentMinDistance}m before next ping'; + return 'Move 25m before next ping'; case PingValidation.cooldownActive: - return 'Wait 5 seconds between pings'; + return 'Wait 7 seconds between pings'; case PingValidation.manualCooldownActive: return 'Wait 15 seconds between manual pings'; case PingValidation.txNotAllowed: diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index 4dc5a98d..b7e96ba3 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -20,8 +20,8 @@ class DebugLogger { if (_initialized) return; _initialized = true; - // Enable debug logging by default on all builds - _debugEnabled = true; + // On mobile, enable debug logging in debug mode + _debugEnabled = kDebugMode; if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7d2e2ef5..82dab78d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -80,18 +80,6 @@ extension MapStyleExtension on MapStyle { return null; // ArcGIS doesn't use subdomains } } - - /// Whether this style supports retina tiles via {r} placeholder - bool get supportsRetina { - switch (this) { - case MapStyle.dark: - return true; // Carto supports @2x via {r} - case MapStyle.light: - return false; // OSM has no retina support - case MapStyle.satellite: - return false; // ArcGIS has no retina support - } - } } /// Custom tile provider that silently handles HTTP errors (404, 503, etc.) @@ -569,20 +557,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map _buildMap(appState, center), - // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) + // GPS Info overlay (top-left, respects dynamic island in landscape) Positioned( top: topPadding, left: leftPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildGpsInfoOverlay(appState), - if (appState.preferences.showTopRepeaters) ...[ - const SizedBox(height: 6), - _buildTopRepeatersOverlay(appState), - ], - ], - ), + child: _buildGpsInfoOverlay(appState), ), // Map controls - top-right in both orientations, collapsible @@ -637,7 +616,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { initialCenter: center, initialZoom: _defaultZoom, minZoom: 3, - maxZoom: 17, + maxZoom: 18, interactionOptions: InteractionOptions( flags: _rotationLocked ? InteractiveFlag.all & ~InteractiveFlag.rotate @@ -660,9 +639,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { urlTemplate: mapStyle.urlTemplate, subdomains: mapStyle.subdomains ?? const [], userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), + maxZoom: 19, + retinaMode: RetinaMode.isHighDensity(context), // Enable high-res tiles on retina displays + tileProvider: SilentCancellableNetworkTileProvider(), // Silently handles tile errors ); }, ), @@ -670,13 +649,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // MeshMapper coverage overlay (only when zone code available and overlay enabled) if (appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', + urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}', userAgentPackageName: 'com.meshmapper.app', - minZoom: 3, - maxZoom: 17, - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, // Keep old tile visible until new one loads - ), + maxZoom: 19, tileProvider: SilentCancellableNetworkTileProvider(), ), @@ -692,21 +667,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // DISC markers (purple circles for discovery observations) MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), - ), - - // Trace markers (cyan/red circles for targeted ping results) - MarkerLayer( - markers: _buildTraceMarkers(appState.traceLogEntries), + markers: _buildDiscMarkers(appState.discLogEntries), ), - // Repeater markers (magenta with ID, rotate with map) + // Repeater markers (magenta circles with ID) MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - ), + markers: _buildRepeaterMarkers(appState.repeaters), ), // Current position marker (car icon) @@ -729,128 +695,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// Color for the overlay ping-type dot - static Color _overlayTypeColor(OverlayPingType type) { - return switch (type) { - OverlayPingType.tx => Colors.green, - OverlayPingType.disc => Colors.purple, - OverlayPingType.trace => Colors.cyan, - OverlayPingType.rx => Colors.blue, - }; - } - - /// Build a single overlay table row with colored dot, repeater ID, and SNR - TableRow _overlayRow(String repeaterId, double snr, Color dotColor) { - return TableRow( - children: [ - TableCell( - verticalAlignment: TableCellVerticalAlignment.middle, - child: Padding( - padding: const EdgeInsets.only(right: 4), - child: Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: dotColor, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Text( - repeaterId, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - ), - const SizedBox(), - Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Text( - snr.toStringAsFixed(1), - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: _snrColor(snr), - ), - ), - ), - ], - ); - } - /// GPS info overlay (top-left corner) - /// Top heard repeaters overlay (bottom-right of map) - Widget _buildTopRepeatersOverlay(AppStateProvider appState) { - final topRepeaters = appState.topRepeatersBySnr; - final rxSlot = appState.rxOverlaySlot; - final isEmpty = topRepeaters.isEmpty && rxSlot == null; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Top Heard', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: Colors.white54, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 2), - if (isEmpty) - const Text( - '---', - style: TextStyle( - fontSize: 11, - fontFamily: 'monospace', - color: Colors.white38, - ), - ), - if (!isEmpty) - Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - columnWidths: const { - 0: IntrinsicColumnWidth(), // dot - 1: IntrinsicColumnWidth(), // ID - 2: FixedColumnWidth(8), // spacer - 3: IntrinsicColumnWidth(), // SNR - }, - children: [ - for (final r in topRepeaters) - _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), - if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), - ], - ), - ], - ), - ); - } - - /// SNR color: green > 5, orange -1..5, red <= -1 - static Color _snrColor(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } - Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; final hasGps = position != null; @@ -1255,26 +1100,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: Colors.cyan, - label: 'TRC', - description: 'Location where a trace reached the repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, color: Colors.grey, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: Colors.grey, - label: 'TRC', - description: 'Location where a trace got no response', - ), ], ), ), @@ -1719,7 +1550,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } - List _buildDiscMarkers(List entries, bool discDropEnabled) { + List _buildDiscMarkers(List entries) { return entries.map((entry) { return Marker( point: LatLng(entry.latitude, entry.longitude), @@ -1729,36 +1560,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showDiscPingDetails(entry), child: Container( decoration: BoxDecoration( - color: entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - ), - ), - ); - }).toList(); - } - - List _buildTraceMarkers(List entries) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.success ? Colors.cyan : Colors.grey, + color: entry.nodeCount == 0 ? Colors.grey : _discMarkerColor, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ @@ -1775,257 +1577,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } - void _showTraceDetails(TraceLogEntry entry) { - showModalBottomSheet( - context: context, - useSafeArea: true, - isScrollControlled: true, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) => ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.75, - ), - child: SingleChildScrollView( - child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.cyan.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), - ), - child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Trace', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(entry.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Target repeater section header - Text( - entry.success - ? 'Target Repeater' - : 'No response from target repeater', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (entry.success) ...[ - const SizedBox(height: 12), - // Table with headers - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: _nodeColumnWidth(), - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - Builder(builder: (context) { - final localSnr = entry.localSnr ?? 0; - final localRssi = entry.localRssi ?? 0; - final remoteSnr = entry.remoteSnr ?? 0; - - Color rxSnrColor; - if (localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (localRssi >= -70) { - rssiColor = Colors.green; - } else if (localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), - // RX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: localSnr.toStringAsFixed(1), - color: rxSnrColor, - ), - ), - ), - // RX RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: '$localRssi', - color: rssiColor, - ), - ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: remoteSnr.toStringAsFixed(1), - color: txSnrColor, - ), - ), - ), - ], - ), - ), - ); - }), - ], - ), - ), - ], - ], - ), - ), - ), - ), - ); - } - /// DISC marker color (#7B68EE - medium slate blue/purple) static const Color _discMarkerColor = Color(0xFF7B68EE); @@ -2065,39 +1616,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers(List repeaters, int? regionHopBytesOverride) { + List _buildRepeaterMarkers(List repeaters) { final duplicateIds = _getDuplicateRepeaterIds(repeaters); return repeaters.map((repeater) { final isDuplicate = duplicateIds.contains(repeater.id); final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); - // Display hex ID based on per-repeater hop_bytes (or regional admin override) - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; - final isLongId = displayId.length > 2; - final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; - - // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded - final borderRadius = effectiveBytes >= 3 - ? BorderRadius.circular(8) - : effectiveBytes == 2 - ? BorderRadius.circular(6) - : BorderRadius.circular(4); - return Marker( point: LatLng(repeater.lat, repeater.lon), - width: markerWidth, + width: 28, height: 28, child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), + onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate), child: Container( - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 4) - : EdgeInsets.zero, decoration: BoxDecoration( color: markerColor, - borderRadius: borderRadius, + shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ BoxShadow( @@ -2109,12 +1644,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), alignment: Alignment.center, child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, + repeater.id, + style: const TextStyle( + fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white, - fontFamily: 'monospace', ), ), ), @@ -2138,21 +1672,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// Compute node column width based on hop byte count. - /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). - double _nodeColumnWidth({double extraPadding = 0}) { - final appState = context.read(); - final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; - switch (hopBytes) { - case 2: - return 70 + extraPadding; - case 3: - return 80 + extraPadding; - default: - return 60 + extraPadding; - } - } - /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing @@ -2161,221 +1680,209 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, - isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.75, - ), - child: SingleChildScrollView( - child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + builder: (context) => Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( children: [ - // Header with icon badge - Row( - children: [ - // Icon badge - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), - ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(ping.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip + // Icon badge Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Repeaters section header - Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.withValues(alpha: 0.4)), ), + child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TX Ping', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(ping.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // Repeaters table - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + // Location chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Repeaters section header + Text( + heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (heardRepeaters.isNotEmpty) ...[ + const SizedBox(height: 12), + // Repeaters table + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...heardRepeaters.map((repeater) { + // Calculate SNR chip color + Color snrColor; + if (repeater.snr <= -1) { + snrColor = Colors.red; + } else if (repeater.snr <= 5) { + snrColor = Colors.orange; + } else { + snrColor = Colors.green; + } + + // Calculate RSSI chip color + Color rssiColor; + if (repeater.rssi >= -70) { + rssiColor = Colors.green; + } else if (repeater.rssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ - SizedBox( - width: _nodeColumnWidth(), - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + // Repeater ID + RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: 60), + // SNR Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: Center( + child: _buildStatChip( + value: repeater.snr.toStringAsFixed(1), + color: snrColor, ), ), ), + // RSSI Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: Center( + child: _buildStatChip( + value: '${repeater.rssi}', + color: rssiColor, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr == null) { - snrColor = Colors.grey; - } else if (repeater.snr! <= -1) { - snrColor = Colors.red; - } else if (repeater.snr! <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi == null) { - rssiColor = Colors.grey; - } else if (repeater.rssi! >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), - // SNR - Expanded( - child: Center( - child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', - color: snrColor, - ), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', - color: rssiColor, - ), - ), - ), - ], - ), - ), - ); - }), - ], - ), - ), - ], - ], - ), - ), + ); + }), + ], + ), + ), + ], + ], ), ), ); @@ -2514,7 +2021,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: 60, child: Text( 'Node', style: TextStyle( @@ -2558,7 +2065,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: 60), // SNR Expanded( child: Center( @@ -2595,262 +2102,254 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, - isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.75, - ), - child: SingleChildScrollView( - child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + builder: (context) => Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( children: [ - // Header with icon badge - Row( - children: [ - // Icon badge - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _discMarkerColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), - ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(entry.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip + // Icon badge Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + color: _discMarkerColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: Row( + child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), + Text( + 'Disc Request', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ), - const SizedBox(height: 16), - - // Discovered nodes section header - Text( - entry.discoveredNodes.isEmpty - ? 'No nodes discovered' - : 'Discovered Nodes (${entry.discoveredNodes.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), + ], + ), + const SizedBox(height: 20), - if (entry.discoveredNodes.isNotEmpty) ...[ - const SizedBox(height: 12), - // Table with headers - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + // Location chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Discovered nodes section header + Text( + entry.discoveredNodes.isEmpty + ? 'No nodes discovered' + : 'Discovered Nodes (${entry.discoveredNodes.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (entry.discoveredNodes.isNotEmpty) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.discoveredNodes.map((node) { + // Calculate colors + Color rxSnrColor; + if (node.localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (node.localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (node.localRssi >= -70) { + rssiColor = Colors.green; + } else if (node.localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (node.remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (node.remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ + // Node ID with type SizedBox( - width: _nodeColumnWidth(extraPadding: 20), - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + width: 60, + child: Row( + children: [ + RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + Text( + node.nodeTypeLabel, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _discMarkerColor, + ), + ), + ], ), ), + // RX SNR Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: Center( + child: _buildStatChip( + value: node.localSnr.toStringAsFixed(1), + color: rxSnrColor, ), ), ), + // RSSI Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: Center( + child: _buildStatChip( + value: '${node.localRssi}', + color: rssiColor, ), ), ), + // TX SNR Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: Center( + child: _buildStatChip( + value: node.remoteSnr.toStringAsFixed(1), + color: txSnrColor, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Node ID with type - SizedBox( - width: _nodeColumnWidth(extraPadding: 20), - child: Row( - children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), - Text( - node.nodeTypeLabel, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _discMarkerColor, - ), - ), - ], - ), - ), - // RX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: node.localSnr.toStringAsFixed(1), - color: rxSnrColor, - ), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: '${node.localRssi}', - color: rssiColor, - ), - ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), - color: txSnrColor, - ), - ), - ), - ], - ), - ), - ); - }), - ], - ), - ), - ], - ], - ), - ), + ); + }), + ], + ), + ), + ], + ], ), ), ); @@ -2877,7 +2376,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -2911,33 +2410,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Header with icon badge (containing ID) and name Row( children: [ - // Icon badge with hex ID (mirrors map marker) - Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final isLongId = displayId.length > 2; - return Container( - constraints: const BoxConstraints(minWidth: 44), - height: 44, - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 8) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: iconColor, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: isLongId ? 13 : 16, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), + // Icon badge with ID (mirrors map marker) + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + alignment: Alignment.center, + child: Text( + repeater.id, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, ), - ); - }), + ), + ), const SizedBox(width: 12), Expanded( child: Text( diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index aa760b46..31ba9332 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -221,8 +221,6 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', - PingEventType.traceSuccess => 'Trace Success', - PingEventType.traceFail => 'Trace Failed', }; final eventDescription = switch (marker.type) { @@ -231,8 +229,6 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', - PingEventType.traceSuccess => 'Trace got response from target', - PingEventType.traceFail => 'Trace got no response from target', }; final hasLocation = marker.latitude != null && marker.longitude != null; @@ -852,8 +848,7 @@ class InteractiveNoiseFloorChartState extends State _legendItem(context, Colors.red, 'TX Fail'), _legendItem(context, Colors.blue, 'RX'), _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.cyan, 'Trace Success'), - _legendItem(context, Colors.grey, 'No Response'), + _legendItem(context, Colors.grey, 'DISC Fail'), ], ); } diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart deleted file mode 100644 index b54aec58..00000000 --- a/lib/widgets/offline_mode_toggle.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../providers/app_state_provider.dart'; - -/// Shared offline mode toggle widget -/// Compact button style — icon + label, tappable to toggle with confirmation -class OfflineModeToggle extends StatelessWidget { - const OfflineModeToggle({super.key}); - - /// Handle offline mode toggle with progress dialog when connected - static Future handleOfflineModeToggle( - BuildContext context, - AppStateProvider appState, - bool currentOfflineMode, - bool isConnected, - ) async { - final newMode = !currentOfflineMode; - - // Always show confirmation dialog - final confirmed = await _showConfirmDialog(context, newMode); - if (confirmed != true || !context.mounted) return; - - // If connected, show progress dialog during mode switch - if (isConnected) { - final statusText = newMode - ? 'Switching to offline mode...' - : 'Switching to online mode...'; - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ); - - // Perform the mode switch - final result = await appState.setOfflineMode(newMode); - - // Close the progress dialog (check if context is still valid) - if (context.mounted) { - Navigator.of(context).pop(); - } - - // Show error dialog if switch failed - if (!result.success && context.mounted) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Mode Switch Failed'), - content: Text( - result.error ?? 'An unknown error occurred', - style: const TextStyle(fontSize: 14), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } else { - // Not connected - simple toggle without dialog - await appState.setOfflineMode(newMode); - } - } - - /// Show confirmation dialog explaining what the mode does - static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { - final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; - final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; - final iconColor = switchingToOffline ? Colors.orange : Colors.green; - final description = switchingToOffline - ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' - 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' - 'You can upload saved data later from the Settings tab.' - : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' - 'This requires an active internet connection.'; - final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; - - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Row( - children: [ - Icon(icon, color: iconColor, size: 22), - const SizedBox(width: 8), - Flexible(child: Text(title)), - ], - ), - content: Text( - description, - style: const TextStyle(fontSize: 14, height: 1.5), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - style: switchingToOffline - ? FilledButton.styleFrom(backgroundColor: Colors.orange) - : null, - child: Text(confirmLabel), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final appState = context.watch(); - final offlineMode = appState.offlineMode; - final isConnected = appState.isConnected; - - final color = offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : (isDark ? Colors.green.shade400 : Colors.green.shade700); - final bgColor = offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : Colors.green.withValues(alpha: 0.15); - final borderColor = offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : Colors.green.withValues(alpha: 0.4); - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: borderColor), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: color, - ), - const SizedBox(width: 8), - Text( - offlineMode ? 'Go Online' : 'Go Offline', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 805417de..ce7c6709 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; -import 'repeater_picker_sheet.dart'; /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -23,7 +22,6 @@ class PingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode @@ -36,7 +34,6 @@ class PingControls extends StatelessWidget { final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -111,7 +108,7 @@ class PingControls extends StatelessWidget { ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow onPressed: () => _sendPing(context, appState), @@ -148,7 +145,7 @@ class PingControls extends StatelessWidget { : rxWindowActive ? 'Listening ${rxWindowRemaining}s' // TX RX window : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') + ? 'Next ping ${autoPingRemaining}s' : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') : rxWindowActive ? 'Cooldown ${rxWindowRemaining}s' @@ -160,7 +157,7 @@ class PingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), showCooldown: false, @@ -182,7 +179,7 @@ class PingControls extends StatelessWidget { ? (discoveryWindowActive ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + ? 'Next Disc ${autoPingRemaining}s' // Waiting for next discovery : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping @@ -194,7 +191,7 @@ class PingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases @@ -227,11 +224,15 @@ class PingControls extends StatelessWidget { else const SizedBox(height: 8), - // Targeted Ping controls - _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, + // Offline Mode and Sound toggles row + const Row( + children: [ + // Offline Mode toggle (expanded) + Expanded(child: _OfflineModeToggle()), + SizedBox(width: 8), + // Sound toggle (compact, right side) + _SoundToggle(), + ], ), ], ); @@ -444,245 +445,370 @@ class _ActionButtonState extends State<_ActionButton> } } -/// Targeted Ping controls - hex text field + start/stop button -class _TargetedPingSection extends StatefulWidget { - final bool isAnyModeRunning; - final bool cooldownActive; - final int cooldownRemaining; - final bool compact; - - const _TargetedPingSection({ - required this.isAnyModeRunning, - required this.cooldownActive, - required this.cooldownRemaining, - this.compact = false, - }); +/// Compact sound notification toggle - matches height of _OfflineModeToggle +class _SoundToggle extends StatelessWidget { + const _SoundToggle(); @override - State<_TargetedPingSection> createState() => _TargetedPingSectionState(); + Widget build(BuildContext context) { + final appState = context.watch(); + final soundEnabled = appState.isSoundEnabled; + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => appState.toggleSoundEnabled(), + borderRadius: BorderRadius.circular(10), + child: Container( + // Match _OfflineModeToggle: padding 10v + icon container (6+18+6) + text adds more height + // _OfflineModeToggle content: icon 30px, text column ~34px, toggle 26px + // Use same padding and let IntrinsicHeight from Row handle matching + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: soundEnabled + ? Colors.blue.withValues(alpha: 0.15) + : colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: soundEnabled + ? Colors.blue.withValues(alpha: 0.4) + : colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: soundEnabled + ? Colors.blue.withValues(alpha: 0.2) + : colorScheme.onSurface.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + soundEnabled ? Icons.volume_up : Icons.volume_off, + size: 18, + color: soundEnabled + ? (isDark ? Colors.blue.shade400 : Colors.blue.shade700) + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + // Add text column to match offline mode toggle height + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sound', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: soundEnabled + ? (isDark ? Colors.blue.shade300 : Colors.blue.shade800) + : colorScheme.onSurface, + ), + ), + Text( + soundEnabled ? 'On' : 'Off', + style: TextStyle( + fontSize: 11, + color: soundEnabled + ? (isDark ? Colors.blue.shade400 : Colors.blue.shade600) + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } } -class _TargetedPingSectionState extends State<_TargetedPingSection> { - final _controller = TextEditingController(); - bool _isStarting = false; +/// Compact offline mode toggle matching app design language +class _OfflineModeToggle extends StatelessWidget { + const _OfflineModeToggle(); + + // Offline mode is now enabled + static const bool _isEnabled = true; + + /// Handle offline mode toggle with progress dialog when connected + static Future _handleOfflineModeToggle( + BuildContext context, + AppStateProvider appState, + bool currentOfflineMode, + bool isConnected, + ) async { + final newMode = !currentOfflineMode; + + // If connected, show progress dialog during mode switch + if (isConnected) { + final statusText = newMode + ? 'Switching to offline mode...' + : 'Switching to online mode...'; + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + + // Perform the mode switch + final result = await appState.setOfflineMode(newMode); - @override - void initState() { - super.initState(); - // Restore any previously set target ID - WidgetsBinding.instance.addPostFrameCallback((_) { - final appState = context.read(); - final existing = appState.targetRepeaterId; - if (existing != null && existing.isNotEmpty && _controller.text != existing) { - _controller.text = existing; + // Close the progress dialog (check if context is still valid) + if (context.mounted) { + Navigator.of(context).pop(); } - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - Future _showRepeaterPicker() async { - final appState = context.read(); - final repeater = await showRepeaterPicker(context); - if (repeater == null || !mounted) return; - - final maxLen = appState.traceHopBytes * 2; - final trimmed = repeater.hexId.length >= maxLen - ? repeater.hexId.substring(0, maxLen).toUpperCase() - : repeater.hexId.toUpperCase(); - _controller.text = trimmed; - appState.setTargetRepeaterId(trimmed); - setState(() {}); + // Show error dialog if switch failed + if (!result.success && context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Mode Switch Failed'), + content: Text( + result.error ?? 'An unknown error occurred', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } else { + // Not connected - simple toggle without dialog + await appState.setOfflineMode(newMode); + } } @override Widget build(BuildContext context) { - final appState = context.watch(); - final isTargetedRunning = appState.isTargetedModeRunning; - final maxLen = appState.traceHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; - - // Sync controller when provider clears target (e.g. trace bytes changed) - if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { - _controller.clear(); - } - - // Determine if the start button should be enabled - final hexText = _controller.text.trim(); - final isValidHex = hexText.isNotEmpty && - hexText.length == maxLen && - RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); - final canStart = isValidHex && - !widget.isAnyModeRunning && - !isTargetedRunning && - !widget.cooldownActive && - appState.isConnected; - - // Status text for when targeted mode is running - String? statusText; - if (isTargetedRunning) { - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; - final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; - final autoPingWaiting = appState.autoPingTimer.isRunning; - final autoPingRemaining = appState.autoPingTimer.remainingSec; - - if (discoveryWindowActive) { - statusText = 'Listening ${discoveryRemaining}s'; - } else if (autoPingWaiting) { - statusText = appState.autoPingTimer.skipReason != null - ? 'Skipped ${autoPingRemaining}s' - : 'Next in ${autoPingRemaining}s'; - } + final isDark = Theme.of(context).brightness == Brightness.dark; + + // When disabled, always show as "off" state + if (!_isEnabled) { + return Opacity( + opacity: 0.5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + // Icon + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.cloud_queue, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + // Label and "Coming soon" + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Offline Mode', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + 'Coming soon', + style: TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Toggle indicator (always off) + Container( + width: 44, + height: 26, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(13), + color: isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1), // slate-600/300 + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 22, + height: 22, + margin: const EdgeInsets.all(2), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); } - final isEnabled = (canStart || isTargetedRunning) && !_isStarting; - final buttonColor = (isTargetedRunning || _isStarting) - ? const Color(0xFF22C55E) // green-500 when running/starting - : Colors.cyan; - final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; - - return Container( - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), + // Original implementation when enabled + final appState = context.watch(); + final offlineMode = appState.offlineMode; + final offlinePingCount = appState.offlinePingCount; + final isConnected = appState.isConnected; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleOfflineModeToggle(context, appState, offlineMode, isConnected), borderRadius: BorderRadius.circular(10), - border: Border.all( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), - width: isTargetedRunning ? 1.5 : 1, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Targeted button - Expanded( - child: GestureDetector( - onTap: isEnabled - ? () async { - HapticFeedback.lightImpact(); - if (!isTargetedRunning) { - setState(() => _isStarting = true); - appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); - } - await appState.toggleAutoPing(AutoMode.targeted); - if (mounted) setState(() => _isStarting = false); - } - : null, - behavior: HitTestBehavior.opaque, - child: Row( - children: [ - Icon( - Icons.route, - size: 18, - color: effectiveColor, - ), - if (!widget.compact) ...[ - const SizedBox(width: 8), - Expanded( - child: Text( - _isStarting - ? 'Starting...' - : isTargetedRunning - ? (statusText ?? 'Stop') - : widget.cooldownActive - ? 'Cooldown ${widget.cooldownRemaining}s' - : 'Trace Mode', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + // Icon + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: offlineMode + ? Colors.orange.withValues(alpha: 0.2) + : colorScheme.onSurface.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + // Label and count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Offline Mode', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: offlineMode + ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) + : colorScheme.onSurface, + ), + ), + if (offlineMode && offlinePingCount > 0) + Text( + '$offlinePingCount pings saved locally', style: TextStyle( - fontSize: 13, - fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, - color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontSize: 11, + color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, + ), + ) + else + Text( + offlineMode + ? 'Data saved locally' + : 'Uploads immediately', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, ), - overflow: TextOverflow.ellipsis, ), - ), ], - ], - ), - ), - ), - const SizedBox(width: 8), - // Hex text field - SizedBox( - width: 80, - child: TextField( - controller: _controller, - enabled: !isTargetedRunning, - maxLength: maxLen, - textCapitalization: TextCapitalization.characters, - style: TextStyle( - fontSize: 14, - fontFamily: 'monospace', - color: isTargetedRunning - ? colorScheme.onSurfaceVariant - : colorScheme.onSurface, + ), ), - decoration: InputDecoration( - hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', - hintStyle: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + // Toggle indicator + Container( + width: 44, + height: 26, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(13), + color: offlineMode + ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) + : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), // slate-600/300 ), - counterText: '', - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: offlineMode + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + width: 22, + height: 22, + margin: const EdgeInsets.all(2), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), ), ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), - _UpperCaseTextFormatter(), - ], - onChanged: (value) { - appState.setTargetRepeaterId(value.trim().toUpperCase()); - setState(() {}); - }, - ), - ), - const SizedBox(width: 6), - // Choose repeater button - SizedBox( - width: 32, - height: 32, - child: IconButton( - icon: Icon( - Icons.list, - size: 18, - color: (!isTargetedRunning && appState.repeaters.isNotEmpty) - ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - ), - onPressed: (!isTargetedRunning && appState.repeaters.isNotEmpty) - ? _showRepeaterPicker - : null, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - tooltip: 'Choose repeater', - ), + ], ), - ], + ), ), ); } } -/// Text formatter that converts input to uppercase -class _UpperCaseTextFormatter extends TextInputFormatter { - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - return TextEditingValue( - text: newValue.text.toUpperCase(), - selection: newValue.selection, - ); - } -} - /// Compact ping controls for minimized panel view /// Shows 3 small horizontal pill buttons in a row /// Active button expands to show context (e.g., "Listening 5s") @@ -694,7 +820,7 @@ class CompactPingControls extends StatefulWidget { } /// Tracks which button should stay expanded during cooldown -enum _LastActiveButton { none, sendPing, activeMode, passiveMode, targeted } +enum _LastActiveButton { none, sendPing, activeMode, passiveMode } class _CompactPingControlsState extends State { // Static so it persists across widget rebuilds (e.g., expand/minimize panel) @@ -711,7 +837,6 @@ class _CompactPingControlsState extends State { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -724,7 +849,6 @@ class _CompactPingControlsState extends State { final isPingInProgress = appState.isPingInProgress; final autoPingWaiting = appState.autoPingTimer.isRunning; final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -749,11 +873,9 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.activeMode; } else if (passiveModeCurrentlyActive) { _lastActiveButton = _LastActiveButton.passiveMode; - } else if (isTargetedRunning) { - _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { + if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive) { _lastActiveButton = _LastActiveButton.none; } @@ -768,36 +890,25 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + final sendPingEnabled = canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet); final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; - // Trace Mode (only relevant when a repeater ID has been entered) - final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; - final targetedCurrentlyActive = isTargetedRunning; - final traceModeExpanded = targetedCurrentlyActive || - (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); - final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && - !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && - !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; - final traceModeActive = isTargetedRunning; - final traceModeShowColor = traceModeEnabled || traceModeActive; - // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; + final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); + final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor; // Build the buttons final sendPingButton = _CompactActionButton( @@ -842,7 +953,6 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: activeModeExpanded && cooldownActive, - isSkipped: autoPingSkipped, discoveryWindowActive: discoveryWindowActive, discoveryWindowRemaining: discoveryWindowRemaining, ), @@ -876,7 +986,6 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, - isSkipped: autoPingSkipped, ), color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 @@ -894,40 +1003,6 @@ class _CompactPingControlsState extends State { onPressed: () => _toggleRxAuto(context, appState), ); - // Build trace mode button (only used when hasTargetRepeaterId) - final traceModeButton = _CompactActionButton( - icon: Icons.route, - label: _getTraceModeLabel( - isTargetedRunning: isTargetedRunning, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - autoPingWaiting: autoPingWaiting, - autoPingRemaining: autoPingRemaining, - showFullText: traceModeExpanded, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - isExpandedDuringCooldown: traceModeExpanded && cooldownActive, - isSkipped: autoPingSkipped, - ), - color: isTargetedRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF06B6D4), // cyan-500 - enabled: traceModeEnabled || isTargetedRunning, - isActive: traceModeActive, - isExpanded: traceModeExpanded, - progress: discoveryWindowActive && isTargetedRunning - ? appState.discoveryWindowTimer.progress - : autoPingWaiting && isTargetedRunning - ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.targeted - ? appState.cooldownTimer.progress - : null, - onPressed: () { - HapticFeedback.lightImpact(); - appState.toggleAutoPing(AutoMode.targeted); - }, - ); - // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally @@ -959,17 +1034,6 @@ class _CompactPingControlsState extends State { Expanded(child: passiveModeButton) else passiveModeButton, - - // Trace Mode (only shown when a repeater ID has been entered) - if (hasTargetRepeaterId) ...[ - const SizedBox(width: 6), - if (traceModeExpanded) - Expanded(child: traceModeButton) - else if (!anyExpanded && (traceModeShowColor || allDisabled)) - Expanded(child: traceModeButton) - else - traceModeButton, - ], ], ); } @@ -1010,7 +1074,6 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, - required bool isSkipped, bool discoveryWindowActive = false, int discoveryWindowRemaining = 0, }) { @@ -1023,7 +1086,7 @@ class _CompactPingControlsState extends State { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1044,37 +1107,10 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, - required bool isSkipped, }) { if (isPassiveModeRunning) { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; - } - // Show cooldown if this button caused it - if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; - } - return null; - } - - /// Get label for Trace Mode button - /// When showFullText is true: "Listening 5s", when false: "5s" - String? _getTraceModeLabel({ - required bool isTargetedRunning, - required bool discoveryWindowActive, - required int discoveryWindowRemaining, - required bool autoPingWaiting, - required int autoPingRemaining, - required bool showFullText, - required bool cooldownActive, - required int cooldownRemaining, - required bool isExpandedDuringCooldown, - required bool isSkipped, - }) { - if (isTargetedRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; - return showFullText ? 'Stop' : null; + if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1127,7 +1163,6 @@ class LandscapePingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -1151,6 +1186,11 @@ class LandscapePingControls extends StatelessWidget { final prefs = appState.preferences; final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + // Antenna selector + final soundEnabled = appState.isSoundEnabled; + final offlineMode = appState.offlineMode; + final isConnected = appState.isConnected; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1173,7 +1213,7 @@ class LandscapePingControls extends StatelessWidget { icon: Icons.cell_tower, tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, countdown: isPingSending @@ -1202,7 +1242,7 @@ class LandscapePingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, countdown: isTxModeRunning ? (discoveryWindowActive @@ -1228,7 +1268,7 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), @@ -1246,12 +1286,34 @@ class LandscapePingControls extends StatelessWidget { ), const SizedBox(height: 10), - // Targeted Ping controls (Trace Mode) - _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - compact: true, + // Compact row for toggles + Row( + children: [ + Expanded( + child: _LandscapeToggle( + icon: offlineMode ? Icons.cloud_off : Icons.cloud_queue, + label: 'Offline', + isOn: offlineMode, + color: Colors.orange, + onTap: () => _OfflineModeToggle._handleOfflineModeToggle( + context, + appState, + offlineMode, + isConnected, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _LandscapeToggle( + icon: soundEnabled ? Icons.volume_up : Icons.volume_off, + label: 'Sound', + isOn: soundEnabled, + color: Colors.blue, + onTap: () => appState.toggleSoundEnabled(), + ), + ), + ], ), ], ); @@ -1550,6 +1612,70 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> } /// Compact toggle button for landscape panel +class _LandscapeToggle extends StatelessWidget { + final IconData icon; + final String label; + final bool isOn; + final Color color; + final VoidCallback onTap; + + const _LandscapeToggle({ + required this.icon, + required this.label, + required this.isOn, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final activeColor = isOn ? color : colorScheme.onSurfaceVariant; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: isOn + ? activeColor.withValues(alpha: 0.12) + : colorScheme.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isOn + ? activeColor.withValues(alpha: 0.35) + : colorScheme.outline.withValues(alpha: 0.15), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 16, + color: isOn ? activeColor : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isOn ? FontWeight.w600 : FontWeight.w500, + color: isOn ? activeColor : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + /// Compact action button for minimized panel - horizontal pill layout /// Supports expanding to show label when active class _CompactActionButton extends StatefulWidget { diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 10a909e1..4c504837 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -6,26 +6,18 @@ class RegionalConfigCard extends StatelessWidget { final String? zoneName; final String? zoneCode; final List channels; - final String? scope; final bool isOfflineMode; - final bool compact; const RegionalConfigCard({ super.key, this.zoneName, this.zoneCode, this.channels = const [], - this.scope, this.isOfflineMode = false, - this.compact = false, }); @override Widget build(BuildContext context) { - if (compact) { - return _buildCompact(context); - } - // When offline mode is enabled, show "-" for zone fields final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; @@ -80,16 +72,6 @@ class RegionalConfigCard extends StatelessWidget { isOffline: isOfflineMode), const SizedBox(height: 12), - // Scope - _buildInfoRow( - context, - Icons.filter_alt, - 'Scope', - isOfflineMode ? '-' : (scope ?? 'Global'), - isOffline: isOfflineMode, - ), - const SizedBox(height: 12), - // Channels header _buildInfoRow(context, Icons.tag, 'RX Channels', null), const SizedBox(height: 8), @@ -114,93 +96,6 @@ class RegionalConfigCard extends StatelessWidget { ); } - /// Compact mode: "Regional Settings" header, scope row, channel chips - Widget _buildCompact(BuildContext context) { - final displayZone = isOfflineMode ? null : zoneName; - final displayScope = isOfflineMode ? '-' : (scope ?? 'Global'); - - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Icon( - Icons.public, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - 'Regional Settings', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (displayZone != null) - Text( - displayZone, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 10), - - // Scope row - _buildCompactRow(context, 'Scope', [ - _buildChannelChip(context, displayScope, isDefault: true), - ]), - const SizedBox(height: 8), - - // Channels row - _buildCompactRow(context, 'Channels', [ - _buildChannelChip(context, 'Public', isDefault: true), - _buildChannelChip(context, '#wardriving', isDefault: true), - if (!isOfflineMode) - ...channels.map((c) => _buildChannelChip(context, c)), - ]), - ], - ), - ), - ); - } - - /// Compact labeled row: small label on left, chips on right - Widget _buildCompactRow(BuildContext context, String label, List chips) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 70, - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ), - Expanded( - child: Wrap( - spacing: 6, - runSpacing: 6, - children: chips, - ), - ), - ], - ); - } - Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( @@ -223,12 +118,8 @@ class RegionalConfigCard extends StatelessWidget { } Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { - // Public channel doesn't use # prefix; scope/plain values pass through as-is + // Public channel doesn't use # prefix final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); - // If it doesn't look like a channel name, show raw value (e.g. scope "Global") - final isChannel = name.startsWith('#') || name == 'Public'; - final label = isChannel ? displayName : name; - return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -243,10 +134,11 @@ class RegionalConfigCard extends StatelessWidget { ), ), child: Text( - label, + displayName, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, + // Use onPrimaryContainer for proper contrast on primaryContainer background color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, ), ), diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 0088da57..b6c61bae 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -10,11 +10,11 @@ import '../utils/distance_formatter.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// -/// Displays the hex repeater ID (2/4/6 chars) in monospace style. Use together -/// with [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so -/// the entire row is the tap target. +/// Displays the 2-char hex repeater ID in monospace style. Use together with +/// [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so the +/// entire row is the tap target. class RepeaterIdChip extends StatelessWidget { - /// The hex repeater ID (e.g., "4E", "4F5D", "4F5D82") + /// The 2-char hex repeater ID (e.g., "4e") final String repeaterId; /// Font size for the ID text (11 for log screens, 13 for map popups) @@ -32,22 +32,13 @@ class RepeaterIdChip extends StatelessWidget { @override Widget build(BuildContext context) { - // Scale font size down for longer IDs - final effectiveFontSize = repeaterId.length > 4 - ? fontSize - 2.0 // 6-char IDs (3-byte) - : repeaterId.length > 2 - ? fontSize - 1.0 // 4-char IDs (2-byte) - : fontSize; // 2-char IDs (1-byte) - final child = Row( mainAxisSize: MainAxisSize.min, children: [ Text( repeaterId, - softWrap: false, - overflow: TextOverflow.clip, style: TextStyle( - fontSize: effectiveFontSize, + fontSize: fontSize, fontWeight: FontWeight.w600, fontFamily: 'monospace', color: Theme.of(context).colorScheme.onSurface, @@ -133,11 +124,10 @@ class RepeaterIdChip extends StatelessWidget { }); } - final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, position: position, regionHopBytesOverride: regionOverride)) + .map((r) => _buildRepeaterRow(context, r, position: position)) .toList(), ); } @@ -198,7 +188,6 @@ class RepeaterIdChip extends StatelessWidget { BuildContext context, Repeater repeater, { Position? position, - int? regionHopBytesOverride, }) { final isActive = repeater.isActive; final badgeColor = isActive ? Colors.green : Colors.grey; @@ -229,8 +218,27 @@ class RepeaterIdChip extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - // Colored badge — circle for short IDs, pill for longer - _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), + // Colored circle badge + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: badgeColor, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + repeater.hexId.length >= 2 + ? repeater.hexId.substring(0, 2).toUpperCase() + : repeater.hexId.toUpperCase(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -294,31 +302,4 @@ class RepeaterIdChip extends StatelessWidget { ), ); } - - /// Build a hex ID badge — circle for 2-char, pill for longer IDs - static Widget _buildHexBadge(String displayId, Color color) { - final isLong = displayId.length > 2; - - return Container( - constraints: const BoxConstraints(minWidth: 28), - height: 28, - padding: isLong - ? const EdgeInsets.symmetric(horizontal: 5) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(14), - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 ? 8 : 10, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), - ); - } } diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart deleted file mode 100644 index a10fa2da..00000000 --- a/lib/widgets/repeater_picker_sheet.dart +++ /dev/null @@ -1,341 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:provider/provider.dart'; - -import '../models/repeater.dart'; -import '../providers/app_state_provider.dart'; -import '../services/gps_service.dart'; -import '../utils/distance_formatter.dart'; - -/// Show a bottom sheet repeater picker and return the selected repeater. -Future showRepeaterPicker(BuildContext context) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.95, - minChildSize: 0.4, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => _RepeaterPickerBody( - scrollController: scrollController, - ), - ), - ); -} - -class _RepeaterPickerBody extends StatefulWidget { - final ScrollController scrollController; - - const _RepeaterPickerBody({required this.scrollController}); - - @override - State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); -} - -class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { - final _searchController = TextEditingController(); - String _query = ''; - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - List _filterAndSort(List repeaters, Position? position) { - List filtered; - if (_query.isNotEmpty) { - final q = _query.toLowerCase(); - filtered = repeaters - .where((r) => - r.name.toLowerCase().contains(q) || - r.hexId.toLowerCase().startsWith(q)) - .toList(); - } else { - filtered = List.of(repeaters); - } - - // Sort: active first, then by distance (if GPS), then alphabetically - filtered.sort((a, b) { - // Active first - if (a.isActive != b.isActive) return a.isActive ? -1 : 1; - // By distance if GPS available - if (position != null) { - final distA = GpsService.distanceBetween( - position.latitude, position.longitude, a.lat, a.lon, - ); - final distB = GpsService.distanceBetween( - position.latitude, position.longitude, b.lat, b.lon, - ); - return distA.compareTo(distB); - } - // Alphabetically - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - }); - - return filtered; - } - - @override - Widget build(BuildContext context) { - final appState = context.watch(); - final repeaters = appState.repeaters; - final position = appState.currentPosition; - final isImperial = appState.preferences.isImperial; - final colorScheme = Theme.of(context).colorScheme; - - final filtered = _filterAndSort(repeaters, position); - - return Column( - children: [ - // Drag handle - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Icon(Icons.cell_tower, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - 'Select Repeater', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - // Search field - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search by name or hex ID...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: _query.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - _searchController.clear(); - setState(() => _query = ''); - }, - ) - : null, - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - onChanged: (value) => setState(() => _query = value), - ), - ), - // Count label - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Showing ${filtered.length} of ${repeaters.length} repeaters', - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ), - const Divider(height: 1), - // List - Expanded( - child: filtered.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Text( - repeaters.isEmpty - ? 'No repeaters available' - : 'No repeaters match your search', - style: TextStyle( - fontSize: 14, - fontStyle: FontStyle.italic, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ) - : ListView.builder( - controller: widget.scrollController, - itemCount: filtered.length, - itemBuilder: (context, index) { - final r = filtered[index]; - return _RepeaterTile( - repeater: r, - position: position, - isImperial: isImperial, - onTap: () => Navigator.pop(context, r), - ); - }, - ), - ), - ], - ); - } -} - -class _RepeaterTile extends StatelessWidget { - final Repeater repeater; - final Position? position; - final bool isImperial; - final VoidCallback onTap; - - const _RepeaterTile({ - required this.repeater, - required this.position, - required this.isImperial, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; - - // Distance text - String? distanceText; - if (position != null) { - final meters = GpsService.distanceBetween( - position!.latitude, position!.longitude, repeater.lat, repeater.lon, - ); - if (meters < 1000) { - distanceText = formatMeters(meters, isImperial: isImperial); - } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); - } - } - - // Always show 4-byte (8-char) hex ID for identification - final displayHex = repeater.hexId.length >= 8 - ? repeater.hexId.substring(0, 8).toUpperCase() - : repeater.hexId.toUpperCase(); - - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - // Hex badge - Container( - constraints: const BoxConstraints(minWidth: 28), - height: 28, - padding: const EdgeInsets.symmetric(horizontal: 5), - decoration: BoxDecoration( - color: badgeColor, - borderRadius: BorderRadius.circular(14), - ), - alignment: Alignment.center, - child: Text( - displayHex, - style: TextStyle( - fontSize: displayHex.length > 4 ? 8 : 10, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(width: 12), - // Name + distance - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - repeater.name, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - if (distanceText != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.near_me, - size: 10, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 3), - Text( - distanceText, - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Active/Stale chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: badgeColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: - Border.all(color: badgeColor.withValues(alpha: 0.4)), - ), - child: Text( - isActive ? 'Active' : 'Stale', - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 563d28f6..c0904ff6 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -57,7 +57,7 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -158,9 +158,6 @@ class _StatusBarState extends State { case 'disc': return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); - case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); - case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -265,16 +262,6 @@ class _StatusBarState extends State { const SizedBox(width: 8), - // Trace count chip (animated) - _AnimatedStatChip( - icon: Icons.route, - value: appState.pingStats.traceCount, - color: Colors.cyan, - onTap: () => _showInfoPopup(context, 'trace'), - ), - - const SizedBox(width: 8), - // Uploaded count chip (animated) _AnimatedStatChip( icon: Icons.cloud_done, diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart deleted file mode 100644 index 4ba980ea..00000000 --- a/lib/widgets/upload_logs_dialog.dart +++ /dev/null @@ -1,825 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; - -import '../providers/app_state_provider.dart'; -import '../services/debug_file_logger.dart'; -import '../services/debug_submit_service.dart'; -import '../utils/constants.dart'; -import '../utils/debug_logger_io.dart'; - -/// Result of a log upload operation -class UploadLogsResult { - final bool success; - final int uploadedCount; - final int failedCount; - final String? errorMessage; - - const UploadLogsResult({ - required this.success, - this.uploadedCount = 0, - this.failedCount = 0, - this.errorMessage, - }); -} - -/// Bottom sheet for uploading debug logs with a mandatory description -class UploadLogsSheet extends StatefulWidget { - final AppStateProvider appState; - final ScrollController scrollController; - - const UploadLogsSheet({ - super.key, - required this.appState, - required this.scrollController, - }); - - @override - State createState() => _UploadLogsSheetState(); -} - -class _UploadLogsSheetState extends State { - final _formKey = GlobalKey(); - final _descriptionController = TextEditingController(); - - final Set _selectedLogFiles = {}; - bool _isSubmitting = false; - String? _errorMessage; - - // Progress tracking - double _progress = 0.0; - String _progressStatus = ''; - int? _currentFile; - int? _totalFiles; - - // Uploadable log files - List _availableLogFiles = []; - bool _isLoadingFiles = true; - - @override - void initState() { - super.initState(); - _loadUploadableFiles(); - } - - Future _loadUploadableFiles() async { - try { - final files = await widget.appState.prepareDebugLogsForUpload(); - if (mounted) { - setState(() { - _availableLogFiles = files; - _isLoadingFiles = false; - // Select all by default - _selectedLogFiles.addAll(files.map((f) => f.path)); - }); - } - } catch (e) { - debugError('[DEBUG] Failed to load uploadable files: $e'); - if (mounted) { - setState(() { - _availableLogFiles = []; - _isLoadingFiles = false; - }); - } - } - } - - @override - void dispose() { - _descriptionController.dispose(); - super.dispose(); - } - - void _toggleFile(String path) { - setState(() { - if (_selectedLogFiles.contains(path)) { - _selectedLogFiles.remove(path); - } else { - _selectedLogFiles.add(path); - } - }); - } - - void _onProgressUpdate(BugReportProgress progress) { - if (mounted) { - setState(() { - _progress = progress.progress; - _progressStatus = progress.status; - _currentFile = progress.currentFile; - _totalFiles = progress.totalFiles; - }); - } - } - - Future _submit() async { - if (!_formKey.currentState!.validate()) return; - - if (_selectedLogFiles.isEmpty) { - setState(() { - _errorMessage = 'Please select at least one log file to upload'; - }); - return; - } - - // Warn user about GPS data in debug logs - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Location Data Warning'), - content: const Text( - 'Debug logs may contain your approximate GPS coordinates ' - 'from your wardriving session. This location history will ' - 'be included in the uploaded files.\n\n' - 'Do you want to continue?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Continue'), - ), - ], - ), - ); - if (confirmed != true) return; - - setState(() { - _isSubmitting = true; - _errorMessage = null; - _progress = 0.0; - _progressStatus = 'Preparing logs...'; - _currentFile = null; - _totalFiles = null; - }); - - try { - // Rotate the current log file now that the user has committed to uploading - final freshFiles = await widget.appState.prepareDebugLogsForUpload(); - - // Build the upload list using the user's selection applied to the freshly rotated files. - // Selected paths from before rotation still match, plus any newly rotated file is included. - final selectedPaths = Set.from(_selectedLogFiles); - final filesToUpload = freshFiles - .where((f) => selectedPaths.contains(f.path)) - .toList(); - - // If the rotation produced a new file that wasn't in the original selection - // (i.e. the previously-active log that just got rotated), include it too - // since the user selected "all" initially and this file has new content. - final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); - if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { - filesToUpload.addAll(newFiles); - } - - if (filesToUpload.isEmpty) { - if (mounted) { - setState(() { - _errorMessage = 'No log files to upload after preparation'; - _isSubmitting = false; - _progress = 0.0; - _progressStatus = ''; - }); - } - return; - } - - final service = DebugSubmitService(); - - final publicKey = widget.appState.devicePublicKey ?? - widget.appState.lastConnectedPublicKey ?? - 'not-connected'; - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; - final userNotes = _descriptionController.text.trim(); - - int uploadedCount = 0; - int failedCount = 0; - final totalFiles = filesToUpload.length; - - for (int i = 0; i < totalFiles; i++) { - final file = filesToUpload[i]; - final progressBase = i / totalFiles; - final progressPerFile = 1.0 / totalFiles; - - _onProgressUpdate(BugReportProgress( - status: 'Uploading file ${i + 1} of $totalFiles...', - progress: progressBase, - currentFile: i + 1, - totalFiles: totalFiles, - )); - - final success = await service.uploadDebugFileOnly( - file: file, - deviceId: deviceName, - publicKey: publicKey, - appVersion: AppConstants.appVersion, - devicePlatform: DebugSubmitService.getDevicePlatform(), - userNotes: userNotes, - onProgress: (p) { - _onProgressUpdate(BugReportProgress( - status: p.status, - progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), - currentFile: i + 1, - totalFiles: totalFiles, - )); - }, - ); - - if (success) { - uploadedCount++; - } else { - failedCount++; - } - } - - service.dispose(); - - if (!mounted) return; - - final result = UploadLogsResult( - success: uploadedCount > 0, - uploadedCount: uploadedCount, - failedCount: failedCount, - errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, - ); - - Navigator.of(context).pop(result); - } catch (e) { - debugError('[DEBUG] Upload logs error: $e'); - if (mounted) { - setState(() { - _errorMessage = 'Error: $e'; - _isSubmitting = false; - _progress = 0.0; - _progressStatus = ''; - }); - } - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - if (_isSubmitting) { - return _buildProgressView(theme); - } - - return Column( - children: [ - // Drag handle - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(top: 12, bottom: 8), - decoration: BoxDecoration( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Row( - children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), - const SizedBox(width: 12), - Text('Upload Logs', style: theme.textTheme.titleLarge), - const Spacer(), - IconButton( - icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), - tooltip: 'Close', - ), - ], - ), - ), - - const Divider(height: 1), - - // Scrollable content - Expanded( - child: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - behavior: HitTestBehavior.opaque, - child: Form( - key: _formKey, - child: ListView( - controller: widget.scrollController, - padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - // Explanation text - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.info_outline, - size: 20, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Upload your debug logs directly to the MeshMapper developers. ' - 'This helps us diagnose issues and improve the app.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Description field (mandatory) - _buildSectionLabel(theme, Icons.description, 'Description'), - const SizedBox(height: 8), - TextFormField( - controller: _descriptionController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Briefly describe why you\'re uploading these logs...', - alignLabelWithHint: true, - ), - maxLines: 3, - maxLength: 500, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'A description is required'; - } - if (value.trim().length < 10) { - return 'Please provide more detail (at least 10 characters)'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Log files section - _buildSectionLabel(theme, Icons.folder_open, 'Log Files'), - const SizedBox(height: 8), - - if (_isLoadingFiles) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Text( - 'Preparing log files...', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ) - else if (_availableLogFiles.isEmpty) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - size: 20, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Text( - 'No log files available to upload', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ) - else - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Column( - children: [ - // Select all / deselect all header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Text( - '${_selectedLogFiles.length} of ${_availableLogFiles.length} selected', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - TextButton( - onPressed: () { - setState(() { - if (_selectedLogFiles.length == _availableLogFiles.length) { - _selectedLogFiles.clear(); - } else { - _selectedLogFiles.clear(); - _selectedLogFiles.addAll( - _availableLogFiles.map((f) => f.path), - ); - } - }); - }, - child: Text( - _selectedLogFiles.length == _availableLogFiles.length - ? 'Deselect All' - : 'Select All', - ), - ), - ], - ), - ), - Divider( - height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - // File list - ...List.generate(_availableLogFiles.length, (index) { - final file = _availableLogFiles[index]; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); - - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - - return ListTile( - dense: true, - leading: Checkbox( - value: isSelected, - onChanged: _isSubmitting - ? null - : (_) => _toggleFile(file.path), - ), - title: Text( - filename, - style: const TextStyle(fontSize: 13), - ), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sizeDisplay, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), - ); - }), - ], - ), - ), - - // Error message - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme.colorScheme.error, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle(color: theme.colorScheme.error), - ), - ), - ], - ), - ), - ], - - SizedBox(height: MediaQuery.of(context).padding.bottom + 80), - ], - ), - ), - ), - ), - - // Sticky bottom action bar - Container( - padding: EdgeInsets.fromLTRB( - 20, - 12, - 20, - MediaQuery.of(context).padding.bottom + 12, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - top: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - ), - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: const Text('Cancel'), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: FilledButton.icon( - onPressed: _isSubmitting || _availableLogFiles.isEmpty - ? null - : _submit, - icon: const Icon(Icons.cloud_upload, size: 18), - label: Text( - _selectedLogFiles.isEmpty - ? 'Upload' - : 'Upload ${_selectedLogFiles.length} Log${_selectedLogFiles.length == 1 ? '' : 's'}', - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildProgressView(ThemeData theme) { - return Column( - children: [ - // Drag handle - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(top: 12, bottom: 8), - decoration: BoxDecoration( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Row( - children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), - const SizedBox(width: 12), - Text('Uploading...', style: theme.textTheme.titleLarge), - ], - ), - ), - - const Divider(height: 1), - - // Progress content - Expanded( - child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Center( - child: SizedBox( - width: 40, - height: 40, - child: CircularProgressIndicator( - strokeWidth: 3, - color: theme.colorScheme.primary, - ), - ), - ), - ), - const SizedBox(height: 32), - - Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - - if (_totalFiles != null && _currentFile != null) - Text( - 'File $_currentFile of $_totalFiles', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 24), - - SizedBox( - width: 250, - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, - color: theme.colorScheme.primary, - minHeight: 8, - ), - ), - const SizedBox(height: 8), - Text( - '${(_progress * 100).toInt()}%', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - - Padding( - padding: EdgeInsets.fromLTRB( - 20, - 12, - 20, - MediaQuery.of(context).padding.bottom + 12, - ), - child: Text( - 'Please don\'t close this screen', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ); - } - - Widget _buildSectionLabel(ThemeData theme, IconData icon, String label) { - return Row( - children: [ - Icon(icon, size: 18, color: theme.colorScheme.primary), - const SizedBox(width: 8), - Text( - label, - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } - - InputDecoration _buildInputDecoration( - ThemeData theme, { - String? hintText, - bool alignLabelWithHint = false, - }) { - return InputDecoration( - hintText: hintText, - alignLabelWithHint: alignLabelWithHint, - filled: true, - fillColor: theme.colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.primary, width: 2), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.error), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.error, width: 2), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ); - } -} - -/// Show the upload logs dialog and return the result -Future showUploadLogsDialog( - BuildContext context, - AppStateProvider appState, -) async { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => UploadLogsSheet( - appState: appState, - scrollController: scrollController, - ), - ), - ); -} From f03600bf2e1c8965de94a6f412f36a330ec07a56 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:09:20 -0400 Subject: [PATCH 037/360] v1.2.0 Release merge (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add offline mode prompt when zone check fails due to no internet When the zone status check fails with a network error, show a full-screen UI with an "Enable Offline Mode" button (matching the maintenance mode pattern) instead of the generic "Zone Check Failed" message. Also adds a red "No Internet" indicator in the zone status bar. Non-network errors retain the existing behavior. Co-Authored-By: Claude Opus 4.6 * Added option to disable carpeater RSSI filter under settings * Multi-hop CARpeater packets now strip the CARpeater hop and report coverage from the underlying repeater with null SNR/RSSI, instead of being dropped entirely. Single-hop packets and discovery responses are still dropped. The -30 dBm failsafe is bypassed for packets matching your CARpeater prefix but stays active for everything else. * Added Code of Conduct & Security * Update links * Exclude .vscode/ from version control Editor-specific configs shouldn't be tracked in a public repo so contributors can use their own setups. Co-Authored-By: Claude Opus 4.6 * Bug fixes: - [#98](https://github.com/MeshMapper/MeshMapper_Project/issues/98) - The heard repeaters panel now scrolls and dynamically expands to accommodate larger lists of heard repeaters - [#95](https://github.com/MeshMapper/MeshMapper_Project/issues/95) - Fixed the IATA popup panel being hidden behind the Android navigation bar - Fixed a bug where a stale BLE device name was used instead of the current one * Anonymous Mode — New privacy option in Settings that renames your device to "Anonymous" for all mesh pings. When enabled, other mesh users cannot see your companion name in wardrive broadcasts. Your device's public key is still used to authenticate your session with the MeshMapper API (geo-auth), but neither your sessions nor your pings are linked to it on the server. Toggling Anonymous Mode while connected automatically disconnects and reconnects with the new identity, switching to the Connection tab so you can see reconnection progress. On proper disconnect, the original device name is restored. Persisted across app restarts. * Update repeater endpoint from /repeaters.json to /get_repeaters.php Co-Authored-By: Claude Opus 4.6 * ### New Features - Automatic map tile refresh after successful data upload, so new coverage appears on the map within the app near real-time - Deterministic 5-second upload timer for TX and discovery pings, replacing the old batch system that could take up to 20 seconds ### Bug Fixes - Fixed tile refresh race condition that caused half-rendered tiles and ghost tiles at certain zoom levels - Fixed Light (OSM) and Satellite (ArcGIS) tiles failing to load at low zoom on iOS retina displays ### Improvements - Faster ping cycle with reduced listen window (7s to 5s) for TX echo, discovery response, and auto-ping cooldown - Map zoom range adjusted to 3-17 - Updated repeater endpoint from /repeaters.json to /get_repeaters.php - Immediate upload on disconnect with no wait period * Regional Flood Scoping: TX pings(Active Mode) are now scoped to your region's flood zone. Regional Admins set a scope in the admin panel, so only repeaters in your region forward wardriving pings. Discovery pings are unaffected. If no scope is assigned, pings flood globally as before. * New Features Regional admins can now enforce hybrid mode per-zone via the API; when enabled, hybrid mode is automatically activated and the toggle is locked on the Settings screen with a "Forced Enabled by Regional Admin" label Regional admins can set a minimum auto-ping interval per-zone; interval options below the minimum are greyed out in Settings with a "Disabled by Regional Admin" label If a user's saved interval falls below the admin-configured minimum, it is automatically bumped up on connect * ### Improvements - Admin-enforced settings now include reworded messaging with a brief explanation of why the constraint benefits the mesh * ## Bug Fixes - Fixed RX carpeater detection checking the wrong hop position. On RX packets, the carpeater is co-located with the wardriver so it only appears as the last hop (delivery repeater), not the first. The check now correctly evaluates the last hop. * Debug logging is now enabled by default on all builds (not just dev builds), and the user's toggle preference is persisted across app restarts via Hive * The API returns "scopes":["#*"] for zones with no regional scope. The code only checked for '*' as the wildcard, so '#*' fell through to the scope-setting path. * Added dialog to upload logs window * Add Discovery Drop feature — failed discovery requests can now be reported to the API as failed pings (repeater_id: "None"), helping identify dead zones. Includes user toggle in Settings (default off), API-enforced override via disc_drop auth field, red markers on map/noise floor chart for failed discoveries when enabled. * New Feature MeshCore firmware v1.14.0+ supports multi-byte path hashes (1, 2, or 3 bytes per hop), expanding repeater ID space from 256 to 65K or 16M unique IDs. The app now fully supports this protocol change. * Add hopBytes support and update repeater display logic - Introduced hopBytes property in Repeater model to manage bytes per hop. - Updated AppStateProvider to reflect changes in path hash mode. - Enhanced connection_screen and settings_screen to accommodate hopBytes settings. - Modified map_widget to display repeater markers based on hopBytes. - Improved repeater_id_chip to show hex ID badges with dynamic sizing. * Refactor settings screen theme and unit toggles to use SwitchListTile; improve UI text for clarity * Repeater markers now rotate with the map and stay upright Co-Authored-By: Claude Opus 4.6 * Prevent duplicate GPS stream subscriptions on restart (#6) Clean fix, guarding startWatching() against stacked subscriptions. Placement at the top of the method is correct (before any early returns). Pattern matches existing subscription management elsewhere in the codebase. * Own and dispose app-level stream subscriptions (#7) Good catch. These three stream subscriptions were the last unowned listeners in AppStateProvider. The fix mirrors the existing pattern used for _adapterStateSubscription, _logRxDataSubscription, _noiseFloorSubscription, and _batterySubscription. Clean and consistent. * Fix mobile BLE scan stream completion and cleanup (#8) Fixes a real issue: the scan stream had no termination path, which could leave callers hanging. The three cleanup paths (natural stop, manual stop, finally) cover all cases well. The identical() check on _scanController is a nice detail for rapid re-scan scenarios. Minor note: the unawaited watcher on isScanning could theoretically fire before scan results arrive if isScanning briefly emits false during startup, but the !controller.isClosed guards prevent any actual breakage. * Bump version to 1.2.0 and enhance authentication flow with staged public_key and contact_uri registration * - **Hive corruption fallback for ping queue:** When the `api_queue` Hive box fails to open (e.g. corruption), `_safeWrite()` was silently returning false and dropping all enqueued pings. A full wardrive session could complete with zero ping data uploaded, as all POSTs would be heartbeats only. Fixed by adding a `_memoryQueue` in-memory fallback: failed writes go to memory instead of being dropped, `_uploadBatch` drains both Hive and memory queues, `queueSize` reflects both, and all clear/disconnect/init paths also flush `_memoryQueue`. * Add heartbeat retry, local session expiry tracking, and offline queue preservation - Heartbeat retries on network failure with exponential backoff (5 attempts, 30s-120s) - Local session deadline timer fires at expires_at, triggers session error if backend is unreachable - On session expiry, queued wardrive data is saved as an offline session instead of discarded * Cancel stale auto-ping restore timers (#10) LGTM. Right fix, matches the pattern used by _reconnectTimer and _reconnectTimeoutTimer. The generation counter is slightly defensive given Dart's single-threaded model but adds no complexity so no complaints. * Recheck location permission after disclosure (#11) Clean separation of disclosure (show once) from permission (always recheck), plus proper permanently-denied handling * - Discovery pings now extract repeater IDs using the region's hop byte count instead of always assuming 1 byte. In multi-byte regions, TX/RX logs showed full IDs like "4E7A" while discovery logs still showed the truncated "4E" * fixed overflow in disc request byte count fix * - Added option to keep the #wardriving channel after exiting a session (#149, @Skye) * - Minimum ping distance is now configurable for TX and discovery pings. The default 25m floor stays the same, but users can increase it to reduce unnecessary pings in dense city driving or stop-and-go traffic. RX batch flushing distance is unaffected. * - GPS-related zone check errors (inaccurate/stale) no longer retry every 5 seconds or auto-switch to the error tab. The next zone check waits until the user moves 100m instead of hammering retries while stationary. The zone status bar now shows a GPS icon with "GPS Unavailable" in orange, and the full-screen error view displays a specific title and message with a manual "Retry Zone Check" button. * lib/screens/settings_screen.dart — Replaced the radio button dialog with a text field. Users type any number they want (minimum 25m enforced). The field shows "meters" as a suffix and "Minimum 25m" as helper text. Save button only accepts valid integers >= 25. * - Hybrid mode now defaults to enabled for new users and existing users who haven't explicitly toggled it off. Saved preferences are preserved. * Implmented Trace Mode * - Fixed a regression where device firmware version was no longer being parsed correctly * - Redesigned the Connection tab for clarity. The previous layout was getting cluttered and hard to navigate. - Offline data session management is now always visible under Settings. * ### Bug Fixes - Fixed offline wardrive pings being lost when the app was killed by the OS, backgrounded, or disconnected mid-session - Offline pings now auto-save periodically, so at most ~60 seconds of data is lost in a worst-case app kill - Disconnecting while in offline mode now saves accumulated pings before cleanup - App backgrounding/suspension now triggers an immediate save of offline ping data * Bug 1 — Trace Mode fails after path byte mode switch: - lib/services/ping_service.dart:71: Changed final int _hopBytes to int _hopBytes and added a set hopBytes setter - lib/providers/app_state_provider.dart:1961: Added _pingService?.hopBytes = newHopBytes; in _applyLivePathHashMode() so PingService stays in sync when the user changes path mode at runtime Bug 2 — "No log files available" when only active log exists: - lib/widgets/upload_logs_dialog.dart:67: Changed DebugFileLogger.listUploadableLogFiles() to widget.appState.prepareDebugLogsForUpload() — this rotates the active log first (same pattern as BugReportSheet), so the rotated file appears in the list * ### Improvements - Auto-ping now shows "Skipped Xs" when a ping is skipped due to insufficient movement (25m minimum), replacing the generic countdown label across all UI layouts * ### New Features - Auto-stop after idle: auto-ping now automatically stops after 30 minutes of continuous skipped pings (no movement), saving battery and radio activity - New "Auto-Stop After Idle" toggle in Settings (default: ON), located under Min Ping Distance * fix trace bytes * Added Repeater Picker dropdown for traces * Final fixes for trace mode * Log tab redesign * Resigned log & settings screen * **New Features** - Live repeater snapshot: enable it in Settings to see the top 3 responded repeaters from your last ping, updating live as you wardrive **Improvements** - Redesigned the Log tab - Redesigned the Settings tab * ### Bug Fixes - Connection screen now shows a "Server Unreachable" message with offline mode suggestion when auth fails due to network errors, instead of a generic error. - Fixed zone check flooding when driving with no network. Previously, every GPS update triggered an API call when the network was down. Position is now tracked even on failed calls, so retries are handled by the 5-second timer instead. ### Improvements - Online/offline toggle redesigned to look more clearly like a button. * - Updated landscape controls to match the new portrait layout and added Trace mode support. - Updated the UI for the top heard repeaters display. * - Added text scale clamping for device accessibility settings. Previously the system text scale factor was applied unclamped across the entire app, which could break layouts at larger text sizes. * Fixing some font sizing and layouts * Fixed trace mode chip and trace icon * ### New Features - Persistent radio power overrides: custom TX power settings are now saved per device, similar to antenna selection. On connect, your previously set power override is automatically applied with a visual indicator showing it's been overridden. A "Reset to Auto" button in the power selector restores the auto-detected power for your device model. ### Improvements - Redesigned the Top Heard overlay. Now shows the repeaters that heard your most recent ping sorted by SNR, with color-coded indicators per ping type (green for TX, purple for Discovery, cyan for Trace, blue for RX). Top 3 slots show the best results from your latest ping and fully replace on each new ping. A separate RX slot shows the strongest passively overheard repeater within a rolling 5-second window. Overlay clears on auto-ping stop, mode switch, disconnect, or log clear. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Rob Ekl --- .build_version | 2 +- .gitignore | 6 +- .vscode/tasks.json | 21 - CODE_OF_CONDUCT.md | 104 + DEVELOPMENT.md | 13 +- SECURITY.md | 62 + lib/main.dart | 13 +- lib/models/api_queue_item.dart | 81 +- lib/models/log_entry.dart | 134 +- lib/models/noise_floor_session.dart | 11 + lib/models/ping_data.dart | 12 +- lib/models/repeater.dart | 17 + lib/models/user_preferences.dart | 88 +- lib/providers/app_state_provider.dart | 1399 ++++++++++-- lib/screens/connection_screen.dart | 1061 +++++---- lib/screens/graph_screen.dart | 5 +- lib/screens/home_screen.dart | 31 +- lib/screens/log_screen.dart | 1476 +++++++------ lib/screens/main_scaffold.dart | 66 +- lib/screens/settings_screen.dart | 1898 +++++++++++------ lib/services/api_queue_service.dart | 270 ++- lib/services/api_service.dart | 308 ++- lib/services/background_service.dart | 2 + lib/services/bluetooth/mobile_bluetooth.dart | 29 +- lib/services/gps_service.dart | 30 +- lib/services/meshcore/connection.dart | 127 +- lib/services/meshcore/crypto_service.dart | 14 + lib/services/meshcore/disc_tracker.dart | 20 +- lib/services/meshcore/packet_metadata.dart | 129 +- lib/services/meshcore/packet_validator.dart | 31 +- lib/services/meshcore/protocol_constants.dart | 2 + lib/services/meshcore/rx_logger.dart | 89 +- lib/services/meshcore/trace_tracker.dart | 211 ++ lib/services/meshcore/tx_tracker.dart | 91 +- lib/services/meshcore/unified_rx_handler.dart | 23 +- lib/services/offline_session_service.dart | 75 + lib/services/ping_service.dart | 386 +++- lib/utils/debug_logger_stub.dart | 4 +- lib/widgets/map_widget.dart | 1361 ++++++++---- lib/widgets/noise_floor_chart.dart | 7 +- lib/widgets/offline_mode_toggle.dart | 183 ++ lib/widgets/ping_controls.dart | 790 +++---- lib/widgets/regional_config_card.dart | 114 +- lib/widgets/repeater_id_chip.dart | 73 +- lib/widgets/repeater_picker_sheet.dart | 341 +++ lib/widgets/status_bar.dart | 15 +- lib/widgets/upload_logs_dialog.dart | 825 +++++++ 47 files changed, 8776 insertions(+), 3274 deletions(-) delete mode 100644 .vscode/tasks.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md create mode 100644 lib/services/meshcore/trace_tracker.dart create mode 100644 lib/widgets/offline_mode_toggle.dart create mode 100644 lib/widgets/repeater_picker_sheet.dart create mode 100644 lib/widgets/upload_logs_dialog.dart diff --git a/.build_version b/.build_version index 7dea76ed..26aaba0e 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.0.1 +1.2.0 diff --git a/.gitignore b/.gitignore index 25d86f82..d7c37fee 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,7 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +.vscode/ # Flutter/Dart/Pub related **/doc/api/ @@ -75,6 +72,7 @@ ios/Flutter/flutter_export_environment.sh *.g.dart # Debug logs +debug/ debuglog/ meshmapper-debug-*.txt diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index b7bd3c4d..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Flutter: Build & Serve Web", - "type": "shell", - "command": " - ", - "isBackground": true, - "problemMatcher": [], - "group": "build", - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": false - } - } - ] -} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..89cf71ba --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,104 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a welcoming experience for everyone, regardless of background or +identity. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- Trolling, insulting or derogatory comments, and personal attacks +- Public or private harassment of any kind +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of unacceptable behavior may be reported to the project maintainers at +the [MeshMapper Project issue tracker](https://github.com/MeshMapper/MeshMapper_Project/issues). + +All complaints will be reviewed and investigated promptly and fairly. All +community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels. +Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, or targeted conduct +against an individual or group. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ac712180..b7b1ad2d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -295,10 +295,21 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s ### Packet Structure - Custom binary protocol with header byte (0x11 = GROUP_TEXT, 0x21 = ADVERT) -- Path encoding: hop count + repeater IDs (4 bytes each) +- Path encoding: `pathLen` byte encodes hash size (top 2 bits) + hop count (bottom 6 bits), followed by `hopCount * hashSize` path bytes + - `pathHashSize = (pathLen >> 6) + 1` → 1, 2, 3, or 4 bytes per hop + - `pathHashCount = pathLen & 63` → 0-63 hops - SNR/RSSI metadata in BLE event payload - Encrypted message payload (AES-ECB with channel key) +### Multi-Byte Path Support (v1.14.0+) +- **Purpose**: Expands repeater ID space from 256 (1-byte) to 65K (2-byte) or 16M (3-byte) unique IDs +- **TX mode**: Configured via `CMD_SET_PATH_HASH_MODE = 61 (0x3D)` — `[0x3D][0x00][mode]` where mode=0→1-byte, 1→2-byte, 2→3-byte +- **RX auto-detect**: Each received packet's `pathLen` byte is decoded to determine hash size, regardless of the user's TX setting +- **DeviceInfo**: v10+ firmware includes `path_hash_mode` byte after manufacturer + firmware version fields +- **API enforcement**: Auth response may include `hop_bytes` (1/2/3) to enforce regional path byte size +- **Lifecycle**: Radio mode is set during connection and restored to original on clean disconnect. Unclean disconnect leaves radio in configured mode. +- **Discovery pings**: NOT affected — multi-byte paths apply only to TX/RX channel messages + ## Platform-Specific Notes ### Web (Chrome/Edge only) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..812bab3d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| Latest | Yes | + +Only the latest release on the `main` branch receives security updates. + +## Reporting a Vulnerability + +If you discover a security vulnerability in MeshMapper Flutter App, please report it responsibly. **Do not open a public GitHub issue for security vulnerabilities.** + +### How to Report + +1. Open a **private security advisory** at: + https://github.com/MeshMapper/MeshMapper_Project/security/advisories/new + +2. Include the following information: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +### What to Expect + +- **Acknowledgment**: We will acknowledge receipt of your report within 72 hours. +- **Assessment**: We will assess the severity and impact of the vulnerability. +- **Fix timeline**: Critical issues will be addressed as quickly as possible. We aim to release a fix within 30 days of confirmation. +- **Disclosure**: We will coordinate with you on public disclosure timing after a fix is available. + +## Security Considerations + +### API Key Handling + +- API keys are injected at build time via `--dart-define=API_KEY=...` and are **never** hardcoded in source code. + +### Bluetooth Security + +- The app communicates with MeshCore devices over Bluetooth Low Energy (BLE) using the MeshCore companion protocol. +- Channel messages are encrypted using AES-ECB with SHA-256 derived channel keys (encryption mode is dictated by the MeshCore protocol). + +### Data Privacy + +- GPS location data is sent to the MeshMapper API for community mesh coverage mapping. +- Users must be within a valid geographic zone (server-side validation) to submit data. +- No personal information beyond device name/public key and location data is transmitted. + +## Scope + +The following are **in scope** for security reports: + +- Vulnerabilities in the Flutter application code +- API key or secret exposure +- Authentication or session management issues +- Data leakage or privacy concerns + +The following are **out of scope**: + +- Vulnerabilities in third-party dependencies (report to the upstream project) +- Issues with the MeshCore firmware or radio protocol (report to the MeshCore project) \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index f29e80eb..08ef6efb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,15 +15,14 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; -import 'utils/constants.dart'; import 'utils/debug_logger_io.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Enable debug file logging FIRST on mobile for dev builds + // Enable debug file logging FIRST on mobile to capture early logs // This must happen before DebugLogger.initialize() to capture early logs - if (!kIsWeb && AppConstants.isDevelopmentBuild) { + if (!kIsWeb) { await DebugFileLogger.enable(); } @@ -248,6 +247,14 @@ class _ThemedAppState extends State<_ThemedApp> { return MaterialApp( title: 'MeshMapper', + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ), + child: child!, + ); + }, theme: ThemeData( colorScheme: lightColorScheme, scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index f2e4bcd8..337cab1e 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -44,7 +44,7 @@ class ApiQueueItem extends HiveObject { final String heardRepeats; /// Earliest time this item can be uploaded (milliseconds since epoch) - /// TX items have 5-second delay; RX/DISC are immediate + /// All items are immediate; upload timing is controlled by flush timers @HiveField(13) final int canUploadAfter; @@ -81,7 +81,7 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch + 5000, // 5 seconds from now + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, ); @@ -138,10 +138,87 @@ class ApiQueueItem extends HiveObject { ); } + /// Create from a successful TRACE ping (targeted zero-hop trace) + /// heardRepeats format: "repeaterId:localSnr:localRssi:remoteSnr" + factory ApiQueueItem.fromTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + return ApiQueueItem( + type: 'TRACE', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: heardRepeats, + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + + /// Create from a failed DISC discovery (no nodes responded) + factory ApiQueueItem.fromDiscDrop({ + required double latitude, + required double longitude, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + return ApiQueueItem( + type: 'DISC', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: 'None', + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + /// Convert to API JSON format (matches WebClient exactly) Map toApiJson() { + // For TRACE type, parse the heardRepeats field to extract individual values + if (type == 'TRACE') { + // Format: "repeaterId:localSnr:localRssi:remoteSnr" + final parts = heardRepeats.split(':'); + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': parts.isNotEmpty ? parts[0] : '', + 'local_snr': parts.length > 1 ? double.tryParse(parts[1]) : null, + 'local_rssi': parts.length > 2 ? int.tryParse(parts[2]) : null, + 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // For DISC type, parse the heardRepeats field to extract individual values if (type == 'DISC') { + // Failed discovery (no nodes responded) + if (heardRepeats == 'None') { + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': 'None', + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final parts = heardRepeats.split(':'); return { diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 758d4abe..26429beb 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,7 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => '${e.repeaterId}(${e.snr.toStringAsFixed(2)})').join(','); + : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,21 +39,23 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double snr; // Signal-to-noise ratio in dB - final int rssi; // RSSI in dBm + final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ required this.repeaterId, - required this.snr, - this.rssi = 0, + this.snr, + this.rssi, }); /// Get SNR color severity (red, orange, green) + /// Returns null for CARpeater pass-through (no signal data) /// Reference: getSnrSeverityClass() in wardrive.js - SnrSeverity get severity { - if (snr <= -1) { + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) { return SnrSeverity.poor; // Red: -12 to -1 dB - } else if (snr <= 5) { + } else if (snr! <= 5) { return SnrSeverity.fair; // Orange: 0 to 5 dB } else { return SnrSeverity.good; // Green: 6 to 13+ dB @@ -66,8 +68,8 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double snr; // Signal-to-noise ratio in dB - final int rssi; // Received signal strength indicator in dBm + final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -76,8 +78,8 @@ class RxLogEntry { RxLogEntry({ required this.timestamp, required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.latitude, @@ -97,10 +99,12 @@ class RxLogEntry { } /// Get SNR color severity - SnrSeverity get severity { - if (snr <= -1) { + /// Returns null for CARpeater pass-through (no signal data) + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) { return SnrSeverity.poor; - } else if (snr <= 5) { + } else if (snr! <= 5) { return SnrSeverity.fair; } else { return SnrSeverity.good; @@ -109,12 +113,67 @@ class RxLogEntry { /// Get CSV row String toCsv() { - return '${timestamp.toIso8601String()},$repeaterId,$snr,$rssi,' + return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' '$latitude,$longitude'; } } +/// Trace Log Entry (targeted zero-hop trace result) +class TraceLogEntry { + final DateTime timestamp; + final double latitude; + final double longitude; + final String targetRepeaterId; + final int? noiseFloor; + final double? localSnr; + final double? remoteSnr; + final int? localRssi; + final bool success; + + TraceLogEntry({ + required this.timestamp, + required this.latitude, + required this.longitude, + required this.targetRepeaterId, + this.noiseFloor, + this.localSnr, + this.remoteSnr, + this.localRssi, + required this.success, + }); + + /// Get formatted timestamp (HH:MM:SS) + String get timeString { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + } + + /// Get formatted location (5 decimal places) + String get locationString { + return '${latitude.toStringAsFixed(5)},${longitude.toStringAsFixed(5)}'; + } + + /// Get SNR color severity based on local SNR + SnrSeverity? get severity { + if (localSnr == null) return null; + if (localSnr! <= -1) { + return SnrSeverity.poor; + } else if (localSnr! <= 5) { + return SnrSeverity.fair; + } else { + return SnrSeverity.good; + } + } + + /// Get CSV row + String toCsv() { + return '${timestamp.toIso8601String()},$targetRepeaterId,${localSnr ?? 'null'},${localRssi ?? 'null'},' + '${remoteSnr ?? 'null'},$latitude,$longitude,${noiseFloor ?? ''},$success'; + } +} + /// SNR Severity levels for color coding enum SnrSeverity { poor, // Red: SNR ≤ -1 dB @@ -122,6 +181,47 @@ enum SnrSeverity { good, // Green: SNR > 5 dB } +/// Ping type for unified log view +enum PingLogType { tx, rx, disc, trace } + +/// Wrapper for unified chronological ping log view +class UnifiedPingLogEntry implements Comparable { + final PingLogType type; + final DateTime timestamp; + final dynamic entry; + + UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + + TxLogEntry get asTx => entry as TxLogEntry; + RxLogEntry get asRx => entry as RxLogEntry; + DiscLogEntry get asDisc => entry as DiscLogEntry; + TraceLogEntry get asTrace => entry as TraceLogEntry; + + @override + int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + + String get timeString => switch (type) { + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; + + String get locationString => switch (type) { + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; + + String toCsv() => switch (type) { + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; +} + /// User Error Entry for error log class UserErrorEntry { final DateTime timestamp; @@ -197,7 +297,7 @@ class DiscLogEntry { /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final String nodeType; // "REPEATER" or "ROOM" final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index a9212162..9c6b574d 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -35,6 +35,12 @@ enum PingEventType { @HiveField(4) discFail, // Grey: Discovery no response + + @HiveField(5) + traceSuccess, // Cyan: Trace got response + + @HiveField(6) + traceFail, // Grey: Trace no response } /// Repeater info for graph markers @@ -99,6 +105,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => Colors.blue, PingEventType.discSuccess => Colors.purple, PingEventType.discFail => Colors.grey, + PingEventType.traceSuccess => Colors.cyan, + PingEventType.traceFail => Colors.grey, }; /// Get a display label for this event type @@ -108,6 +116,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => 'RX', PingEventType.discSuccess => 'DISC Success', PingEventType.discFail => 'DISC Fail', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Fail', }; } @@ -154,6 +164,7 @@ class NoiseFloorSession extends HiveObject { String get modeDisplay => switch (mode) { 'active' => 'Active Mode', 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', _ => 'Passive Mode', }; diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 2425d603..0e42d087 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -111,14 +111,14 @@ class RxPing { /// Repeater that heard a TX ping (from echo tracking) class HeardRepeater { final String repeaterId; // Hex ID (e.g., "4e", "77") - final double snr; // Best SNR observed - final int rssi; // RSSI in dBm + final double? snr; // Best SNR observed (null for CARpeater pass-through) + final int? rssi; // RSSI in dBm (null for CARpeater pass-through) final int seenCount; // How many times this repeater was heard const HeardRepeater({ required this.repeaterId, - required this.snr, - this.rssi = 0, + this.snr, + this.rssi, this.seenCount = 1, }); } @@ -139,6 +139,7 @@ class PingStats { final int txCount; final int rxCount; final int discCount; // Discovery count (Passive Mode) + final int traceCount; // Trace count (Targeted Mode) final int successfulUploads; final int failedUploads; final int queuedCount; @@ -147,6 +148,7 @@ class PingStats { this.txCount = 0, this.rxCount = 0, this.discCount = 0, + this.traceCount = 0, this.successfulUploads = 0, this.failedUploads = 0, this.queuedCount = 0, @@ -156,6 +158,7 @@ class PingStats { int? txCount, int? rxCount, int? discCount, + int? traceCount, int? successfulUploads, int? failedUploads, int? queuedCount, @@ -164,6 +167,7 @@ class PingStats { txCount: txCount ?? this.txCount, rxCount: rxCount ?? this.rxCount, discCount: discCount ?? this.discCount, + traceCount: traceCount ?? this.traceCount, successfulUploads: successfulUploads ?? this.successfulUploads, failedUploads: failedUploads ?? this.failedUploads, queuedCount: queuedCount ?? this.queuedCount, diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index cdf1867b..bbf76bd1 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -34,6 +34,9 @@ class Repeater { /// The repeater is active while `now < staleTime`. final int? staleTime; + /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) + final int hopBytes; + const Repeater({ required this.id, required this.hexId, @@ -45,6 +48,7 @@ class Repeater { this.iata, this.createdAt, this.staleTime, + this.hopBytes = 1, }); /// Parse from JSON object in repeaters.json @@ -78,6 +82,7 @@ class Repeater { iata: json['iata'] as String?, createdAt: createdAt, staleTime: staleTime, + hopBytes: (json['hop_bytes'] as int?) ?? 1, ); } @@ -93,6 +98,7 @@ class Repeater { 'iata': iata, 'created_at': createdAt, 'stale_time': staleTime, + 'hop_bytes': hopBytes, }; } @@ -130,6 +136,17 @@ class Repeater { /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; + /// Get display hex ID based on hop bytes (or override). + /// [overrideHopBytes] is used when regional admin enforces a byte size. + String displayHexId({int? overrideHopBytes}) { + final bytes = overrideHopBytes ?? hopBytes; + final hexChars = bytes * 2; // 1 byte = 2 hex chars + if (hexId.length >= hexChars) { + return hexId.substring(0, hexChars).toUpperCase(); + } + return id; // Fallback to short numeric ID + } + @override String toString() => 'Repeater(id=$id, name=$name, enabled=$isEnabled)'; } diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 3eb4f576..4063fc8b 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -64,6 +64,27 @@ class UserPreferences { /// Map rotation lock (disable rotation gestures) final bool mapRotationLocked; + /// Disable RSSI carpeater filter (allow all signal strengths) + final bool disableRssiFilter; + + /// Anonymous mode: rename companion to "Anonymous" during wardriving + final bool anonymousMode; + + /// Discovery drop: count failed discoveries as failed pings and report to API + final bool discDropEnabled; + + /// Delete wardriving channel from radio on disconnect + final bool deleteChannelOnDisconnect; + + /// Minimum ping distance in meters (25m floor, user can increase) + final int minPingDistanceMeters; + + /// Auto-stop auto-ping after 30 minutes of idle (no movement) + final bool autoStopAfterIdle; + + /// Show top 3 repeaters by SNR on the map during wardriving + final bool showTopRepeaters; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -82,10 +103,17 @@ class UserPreferences { this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', - this.hybridModeEnabled = false, + this.hybridModeEnabled = true, this.mapAutoFollow = false, this.mapAlwaysNorth = true, this.mapRotationLocked = false, + this.disableRssiFilter = false, + this.anonymousMode = false, + this.discDropEnabled = false, + this.deleteChannelOnDisconnect = true, + this.minPingDistanceMeters = 25, + this.autoStopAfterIdle = true, + this.showTopRepeaters = false, }); /// Create from JSON (for persistence) @@ -108,10 +136,17 @@ class UserPreferences { closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', - hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? false, + hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, mapAutoFollow: (json['mapAutoFollow'] as bool?) ?? false, mapAlwaysNorth: (json['mapAlwaysNorth'] as bool?) ?? true, mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, + disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, + anonymousMode: (json['anonymousMode'] as bool?) ?? false, + discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, + autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, + showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, ); } @@ -139,6 +174,13 @@ class UserPreferences { 'mapAutoFollow': mapAutoFollow, 'mapAlwaysNorth': mapAlwaysNorth, 'mapRotationLocked': mapRotationLocked, + 'disableRssiFilter': disableRssiFilter, + 'anonymousMode': anonymousMode, + 'discDropEnabled': discDropEnabled, + 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, + 'minPingDistanceMeters': minPingDistanceMeters, + 'autoStopAfterIdle': autoStopAfterIdle, + 'showTopRepeaters': showTopRepeaters, }; } @@ -165,6 +207,13 @@ class UserPreferences { bool? mapAutoFollow, bool? mapAlwaysNorth, bool? mapRotationLocked, + bool? disableRssiFilter, + bool? anonymousMode, + bool? discDropEnabled, + bool? deleteChannelOnDisconnect, + int? minPingDistanceMeters, + bool? autoStopAfterIdle, + bool? showTopRepeaters, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -188,6 +237,13 @@ class UserPreferences { mapAutoFollow: mapAutoFollow ?? this.mapAutoFollow, mapAlwaysNorth: mapAlwaysNorth ?? this.mapAlwaysNorth, mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, + disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, + anonymousMode: anonymousMode ?? this.anonymousMode, + discDropEnabled: discDropEnabled ?? this.discDropEnabled, + deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, + showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, ); } @@ -213,6 +269,9 @@ class UserPreferences { return '$autoPingInterval seconds'; } + /// Get min ping distance display string + String get minPingDistanceDisplay => '${minPingDistanceMeters}m'; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -236,12 +295,19 @@ class UserPreferences { other.hybridModeEnabled == hybridModeEnabled && other.mapAutoFollow == mapAutoFollow && other.mapAlwaysNorth == mapAlwaysNorth && - other.mapRotationLocked == mapRotationLocked; + other.mapRotationLocked == mapRotationLocked && + other.disableRssiFilter == disableRssiFilter && + other.anonymousMode == anonymousMode && + other.discDropEnabled == discDropEnabled && + other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && + other.minPingDistanceMeters == minPingDistanceMeters && + other.autoStopAfterIdle == autoStopAfterIdle && + other.showTopRepeaters == showTopRepeaters; } @override int get hashCode { - return Object.hash( + return Object.hashAll([ powerLevel, txPower, externalAntenna, @@ -262,7 +328,14 @@ class UserPreferences { mapAutoFollow, mapAlwaysNorth, mapRotationLocked, - ); + disableRssiFilter, + anonymousMode, + discDropEnabled, + deleteChannelOnDisconnect, + minPingDistanceMeters, + autoStopAfterIdle, + showTopRepeaters, + ]); } /// Check if using imperial units @@ -296,3 +369,8 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } + +/// Minimum ping distance (meters) +class MinPingDistance { + static const int min = 25; +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a91f16ff..745b96f0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -29,6 +29,7 @@ import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; +import '../services/meshcore/crypto_service.dart'; import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; @@ -47,8 +48,13 @@ enum AutoMode { passive, /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater + targeted, } +/// Ping type for the top-heard overlay dots +enum OverlayPingType { tx, disc, trace, rx } + /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully @@ -61,6 +67,8 @@ enum OfflineUploadResult { authFailed, /// Some pings failed to upload partialFailure, + /// Another upload is already in progress + uploadInProgress, } /// Main application state provider @@ -99,10 +107,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; StreamSubscription? _adapterStateSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _gpsStatusSubscription; + StreamSubscription? _gpsPositionSubscription; // GPS state GpsStatus _gpsStatus = GpsStatus.permissionDenied; @@ -114,7 +126,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Device info DeviceModel? _deviceModel; String? _manufacturerString; + String? _firmwareVersionString; String? _devicePublicKey; + String? _offlineContactUri; /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; @@ -131,6 +145,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { PingStats _pingStats = const PingStats(); bool _autoPingEnabled = false; AutoMode _autoMode = AutoMode.active; + DateTime? _idleAutoStopReference; + static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked int _queueSize = 0; int? _currentNoiseFloor; @@ -153,6 +169,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _txLogEntries = []; final List _rxLogEntries = []; final List _discLogEntries = []; + final List _traceLogEntries = []; + + // Top repeaters overlay — updated live on each ping event + List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + ({String repeaterId, double snr})? _rxOverlaySlot; + Timer? _rxOverlayWindowTimer; + + // Targeted mode state + String? _targetRepeaterId; // User error log entries final List _errorLogEntries = []; @@ -160,6 +185,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // User preferences UserPreferences _preferences = const UserPreferences(); + // Anonymous mode state + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -167,6 +196,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _antennaRestoredFromDevice = false; bool get antennaRestoredFromDevice => _antennaRestoredFromDevice; + /// Per-device power overrides: maps companion name → {powerLevel, txPower} + Map> _devicePowerOverrides = {}; + + /// Whether the current power setting was auto-restored from a saved override + bool _powerRestoredFromDevice = false; + bool get powerRestoredFromDevice => _powerRestoredFromDevice; + // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; @@ -199,6 +235,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; + // Tile refresh after upload + int _overlayCacheBust = 0; + Timer? _tileRefreshTimer; + // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -212,8 +252,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; + Timer? _restoreAutoPingTimer; + Timer? _offlineAutoSaveTimer; + Timer? _zoneRefreshTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; + int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); @@ -222,6 +266,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab + bool _requestConnectionTabSwitch = false; // Request switch to connection tab // Repeater markers state List _repeaters = []; @@ -231,6 +276,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels from API (for UI display) List _regionalChannels = []; + // Regional scope from API (for UI display and flood filtering) + String? _scope; + + // Path hash mode tracking (for multi-byte path support) + int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected + int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; List _storedNoiseFloorSessions = []; @@ -252,6 +306,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + } else if (state == AppLifecycleState.paused) { + debugLog('[APP] App paused (backgrounded)'); + // Save offline pings immediately on pause to prevent data loss if OS kills app + if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { + _autoSaveOfflinePings(); + } } } @@ -265,6 +325,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; bool get isAuthError => _isAuthError; + bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; @@ -273,6 +334,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; + String? get firmwareVersionString => _firmwareVersionString; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -284,6 +346,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isPendingDisable => _pingService?.pendingDisable ?? false; /// True when running any mode that does TX (Active or Hybrid) bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) + bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; int? get currentBatteryPercent => _currentBatteryPercent; @@ -291,14 +356,71 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isScanning => _isScanning; List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); + + /// Top 3 repeaters by best SNR from TX/DISC/Trace pings + List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window + ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; + + /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. + /// Replaces all 3 slots entirely (no carryover from previous pings). + void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + final bestSnr = {}; + for (final r in current) { + final key = r.repeaterId.toUpperCase(); + if (!bestSnr.containsKey(key) || r.snr > bestSnr[key]!) { + bestSnr[key] = r.snr; + } + } + final fresh = bestSnr.entries + .map((e) => (repeaterId: e.key, snr: e.value, type: type)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + _topRepeatersOverlay = fresh.take(3).toList(); + } + + /// Update the RX overlay slot with a 5-second rolling window (best SNR wins). + void _updateRxOverlaySlot(String repeaterId, double snr) { + final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); + if (_rxOverlayWindowTimer?.isActive ?? false) { + if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { + _rxOverlaySlot = entry; + } + } else { + _rxOverlaySlot = entry; + _rxOverlayWindowTimer = Timer(const Duration(seconds: 5), () { + // Window closed — slot stays until next RX or cleared + }); + } + } + + /// Clear all overlay state (top 3 + RX slot). + void _clearOverlayState() { + _topRepeatersOverlay = []; + _rxOverlaySlot = null; + _rxOverlayWindowTimer?.cancel(); + _rxOverlayWindowTimer = null; + } List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); + List get traceLogEntries => List.unmodifiable(_traceLogEntries); List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get unifiedPingLogEntries { + final merged = [ + ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ]; + merged.sort(); + return merged; + } ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; + bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -318,6 +440,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; + int get overlayCacheBust => _overlayCacheBust; int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -341,6 +464,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isSwitchingMode => _isSwitchingMode; String? get modeSwitchError => _modeSwitchError; + // Anonymous mode getter + bool get isAnonymousRenamed => _isAnonymousRenamed; + // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; @@ -351,6 +477,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); + // Regional scope getter (for UI) + String? get scope => _scope; + // Noise floor session getters NoiseFloorSession? get currentNoiseFloorSession => _currentNoiseFloorSession; List get storedNoiseFloorSessions => @@ -369,10 +498,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get rxAllowed => _apiService.rxAllowed; bool get hasApiSession => _apiService.hasSession; bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; + bool get enforceHybrid => _apiService.enforceHybrid; + bool get enforceDiscDrop => _apiService.enforceDiscDrop; + bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + int get minModeInterval => _apiService.minModeInterval; + bool get enforceHopBytes => _apiService.enforceHopBytes; + int get hopBytes => _hopBytes; + int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get traceHopBytes => _traceHopBytes; + bool get supportsMultiBytePaths => _originalPathHashMode != null; // Offline mode bool get offlineMode => _preferences.offlineMode; List get offlineSessions => _offlineSessionService.sessions; + bool _isUploadingOfflineSession = false; + bool get isUploadingOfflineSession => _isUploadingOfflineSession; // Developer mode bool get developerModeEnabled => _preferences.developerModeEnabled; @@ -429,8 +569,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); - // Auto-enable debug logging for development builds - await _autoEnableDebugLogsIfDevelopmentBuild(); + // Initialize debug logging (enabled by default, respects user preference) + await _initDebugLogs(); // Initialize channel service with Public channel only (regional channels added after auth) await ChannelService.initializePublicChannel(); @@ -453,7 +593,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -469,6 +610,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); + + // Schedule overlay tile refresh after server has time to regenerate tiles + // Cache buster change + notifyListeners triggers flutter_map's reloadImages() + // which updates tile URLs in-place and refetches cleanly + _tileRefreshTimer?.cancel(); + _tileRefreshTimer = Timer(const Duration(seconds: 5), () { + _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; + debugLog('[MAP] Refreshing overlay tiles'); + notifyListeners(); + }); }; // Initialize offline session service @@ -490,6 +641,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Loading preferences...'); await _loadPreferences(); await _loadDeviceAntennaPreferences(); + await _loadDevicePowerOverrides(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -514,7 +666,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); - _bluetoothService.connectionStream.listen((status) async { + await _connectionSubscription?.cancel(); + _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -538,7 +691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to GPS changes debugLog('[INIT] Setting up GPS status listener...'); - _gpsService.statusStream.listen((status) { + await _gpsStatusSubscription?.cancel(); + _gpsStatusSubscription = _gpsService.statusStream.listen((status) { final previousStatus = _gpsStatus; _gpsStatus = status; @@ -563,7 +717,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); - _gpsService.positionStream.listen((position) async { + await _gpsPositionSubscription?.cancel(); + _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -698,6 +853,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveredDevices = []; _connectionError = null; _isAuthError = false; + _isNetworkError = false; notifyListeners(); // Listen for discovered devices using subscription so stopScan() can cancel @@ -708,8 +864,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ).listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { - _discoveredDevices.add(device); - selectedDevice = device; + // Prefer remembered device name (from SelfInfo) over BLE cache + var enrichedDevice = device; + if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + device.name != _rememberedDevice!.name) { + enrichedDevice = DiscoveredDevice( + id: device.id, + name: _rememberedDevice!.name, + rssi: device.rssi, + ); + debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + } + _discoveredDevices.add(enrichedDevice); + selectedDevice = enrichedDevice; notifyListeners(); } }, @@ -754,6 +921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _connectionError = null; _isAuthError = false; + _isNetworkError = false; // Clean up any previous connection first if (_meshCoreConnection != null) { @@ -780,8 +948,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; } - // Use SelfInfo name (user's configured name) if available, otherwise fall back to BLE advertisement name - final deviceName = _meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" + if (_preferences.anonymousMode && !_isAnonymousRenamed) { + final realName = _meshCoreConnection!.selfInfo?.name; + if (realName != null && realName.isNotEmpty) { + _originalDeviceName = realName; + try { + await _meshCoreConnection!.setAdvertName('Anonymous'); + _isAnonymousRenamed = true; + _displayDeviceName = 'Anonymous'; + debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + // Short delay for firmware to process + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugError('[CONN] Anonymous mode: rename failed: $e'); + // Continue with real name if rename fails + } + } + } + + // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot request auth: could not retrieve device name'); return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; @@ -831,6 +1020,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(result); + return result; } @@ -920,6 +1112,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(registerResult); + return registerResult; }; } else { @@ -934,14 +1129,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected - // Use companion name (selfInfo.name) or BLE device name with MeshCore- prefix stripped - var deviceName = _meshCoreConnection!.selfInfo?.name ?? - connectedDeviceName; + // Use original name (not "Anonymous") for bug report identification + var deviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); if (deviceName != null) { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); @@ -949,6 +1146,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } + + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } } notifyListeners(); }); @@ -981,8 +1188,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { powerLevel: device.power, txPower: device.txPower, autoPowerSet: true, // Indicates power was auto-detected from device model + powerLevelSet: false, // Clear stale manual flag from previous session ); - // TODO: Persist to SharedPreferences when implemented notifyListeners(); debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } @@ -1010,12 +1217,54 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - final newValidator = PacketValidator(allowedChannels: allowedChannels); + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); _unifiedRxHandler!.updateValidator(newValidator); debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } + // Set flood scope from API response (regional TX filtering) + // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) + // Any other value (e.g., "ottawa") → derive TransportKey and set scope + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[CONN] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + debugLog('[CONN] Flood scope set successfully'); + } else { + _scope = null; + debugLog('[CONN] No regional scope — using unscoped flood'); + } + + // Enforce hybrid mode if required by regional admin + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } + + // Enforce discovery drop if required by regional admin + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } + + // Enforce minimum auto-ping interval if required by regional admin + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + } + + // Configure multi-byte path hash mode on radio + await _configurePathHashMode(); + // Create ping service with wakelock (create new instance per connection) _pingService = PingService( gpsService: _gpsService, @@ -1029,19 +1278,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceId: _deviceId, txTracker: _txTracker, audioService: _audioService, + disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, shouldIgnoreRepeater: (String repeaterId) { - // Same filter as RxLogger - check user preferences for ignored repeater ID final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - // Case-insensitive comparison (both uppercase) - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current == ignored; + return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); } return false; }, ); + // Wire UnifiedRxHandler so trace payloads route to TraceTracker + _pingService!.unifiedRxHandler = _unifiedRxHandler; + // Set validation callbacks _pingService!.checkExternalAntennaConfigured = () { // External antenna must be explicitly set (yes or no) before pinging @@ -1062,6 +1313,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; + // Check if discovery drop is enabled + _pingService!.getDiscDropEnabled = () => discDropEnabled; + _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -1096,6 +1350,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + // Update RX overlay slot with this RX observation + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + notifyListeners(); }; @@ -1112,7 +1369,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1125,7 +1383,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr}, isNew: $isNew)'); + debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events @@ -1165,6 +1423,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + + // Update top repeaters overlay with current TX echoes + _updateTopRepeaters(existingEvents + .where((e) => e.snr != null) + .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), OverlayPingType.tx); + debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); @@ -1182,6 +1447,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up auto ping scheduled callback for countdown display _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { _autoPingTimer.startWithSkipReason(intervalMs, skipReason); + + // Track idle time for auto-stop + if (skipReason != null) { + // Ping was skipped — check if idle too long + if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + final elapsed = DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); + } + } + } else { + // Successful ping — reset idle reference + _idleAutoStopReference = DateTime.now(); + } }; // Wire up discovery ping callback - fires immediately (like onTxPing) @@ -1195,6 +1474,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isNew) { _audioService.playReceiveSound(); } + + // Update top repeaters overlay with all discovered nodes from this ping + _updateTopRepeaters(discPing.discoveredNodes + .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), OverlayPingType.disc); + notifyListeners(); }; @@ -1212,8 +1497,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastTx.events.isNotEmpty) { repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( repeaterId: e.repeaterId, - snr: e.snr, - rssi: e.rssi, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, )).toList(); } } @@ -1248,8 +1533,64 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } + + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; + + // Wire up trace ping callback (for log entry creation) + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; + + // Wire up trace window complete callback for noise floor graph + _pingService!.onTraceWindowComplete = (result) { + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + )]; + // Update the log entry with success data + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + notifyListeners(); + } + } + recordPingEvent( - success ? PingEventType.discSuccess : PingEventType.discFail, + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, latitude: lat, longitude: lon, repeaters: repeaters, @@ -1293,6 +1634,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update local state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -1305,12 +1647,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // BLE advertisement name may be cached/stale after device rename final selfInfoName = _meshCoreConnection?.selfInfo?.name; if (selfInfoName != null && selfInfoName.isNotEmpty) { - _displayDeviceName = selfInfoName; - debugLog('[APP] Display name set from SelfInfo: "$selfInfoName"'); + // Keep "Anonymous" display name if anonymous mode is active + _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; + debugLog('[APP] Display name set: "$_displayDeviceName"'); + + // Update remembered device with real name (not "Anonymous") + // BLE advertisement name may be stale after device rename + final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { + final updatedName = 'MeshCore-$realName'; + if (_rememberedDevice!.name != updatedName) { + await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); + debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + } + } } // Restore per-device antenna preference if previously saved - final resolvedName = displayDeviceName; + // Use original name for keying, not "Anonymous" + final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( @@ -1323,6 +1678,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Restore per-device power override if previously saved + if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } + // Log connection status based on TX/RX permissions if (hasApiSession) { if (txAllowed && rxAllowed) { @@ -1332,6 +1702,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else { debugLog('[CONN] Connected with limited access'); } + + // Start periodic zone refresh to keep slot counts current + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); + } } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1374,12 +1749,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { _connectionError = 'Authentication failed'; } } else { _isAuthError = false; + _isNetworkError = false; // Provide clean user-facing messages for common BLE errors if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; @@ -1398,6 +1775,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create TX tracker (stored for use by PingService) _txTracker = TxTracker(); + _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; + + // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) + _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1407,37 +1789,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; debugLog('[APP] TxTracker.onCarpeaterDrop callback SET'); - // Function to check if repeater should be ignored (carpeater filter) - _txTracker!.shouldIgnoreRepeater = (String repeaterId) { - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - return repeaterId.toUpperCase() == ignored; - } - return false; - }; - debugLog('[APP] TxTracker.shouldIgnoreRepeater callback SET'); - // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( - // Function to check if repeater should be ignored (carpeater filter) - shouldIgnoreRepeater: (String repeaterId) { - // Check user preferences for ignored repeater ID - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - // Case-insensitive comparison (both uppercase) - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current == ignored; - } - return false; - }, + // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) + carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' - 'snr=${observation.snr}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); + 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); @@ -1452,8 +1813,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: observation.lon, repeaterId: observation.repeaterId, timestamp: DateTime.now(), - snr: observation.snr, - rssi: observation.rssi, + snr: observation.snr ?? 0.0, + rssi: observation.rssi ?? 0, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1465,6 +1826,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); + // Update RX overlay slot immediately + if (observation.snr != null) { + _updateRxOverlaySlot(repeaterKey, observation.snr!); + } // Play receive sound for new RX observation _audioService.playReceiveSound(); // Record RX event for noise floor graph with location and repeater info @@ -1475,8 +1840,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: [ MarkerRepeaterInfo( repeaterId: observation.repeaterId, - snr: observation.snr, - rssi: observation.rssi, + snr: observation.snr ?? 0.0, + rssi: observation.rssi ?? 0, ), ], ); @@ -1496,7 +1861,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' - 'snr=${entry.snr}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); + 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1513,20 +1878,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastPinIndex != -1) { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; - if (entry.snr > existingPin.snr) { + // Only update if new SNR is non-null and better (null never replaces non-null) + final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr, // UPDATE to best SNR from batch - rssi: entry.rssi, + snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + rssi: entry.rssi ?? existingPin.rssi, ); debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' - '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr.toStringAsFixed(2)}'); + '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' - 'batch best ${entry.snr.toStringAsFixed(2)} <= pin ${existingPin.snr.toStringAsFixed(2)}'); + 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { // Edge case: pin not found (should have been created in onObservation) @@ -1535,8 +1902,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: entry.lon, repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr, - rssi: entry.rssi, + snr: entry.snr ?? 0.0, + rssi: entry.rssi ?? 0, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1566,13 +1933,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogEntries.add(rxLogEntry); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' - 'snr=${entry.snr}, pathLen=${entry.pathLength}'); + 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); + + // Update RX overlay slot with this RX observation + if (entry.snr != null) { + _updateRxOverlaySlot(entry.repeaterId, entry.snr!); + } // Note: RX count is incremented in onObservation when pin is created (immediate feedback) // Enqueue to API with formatted heard_repeats string - // Format: "repeaterId(snr)" e.g. "4e(12.25)" - final heardRepeats = '${entry.repeaterId}(${entry.snr.toStringAsFixed(2)})'; + // Format: "repeaterId(snr)" e.g. "4e(12.25)" or "4e(null)" for CARpeater pass-through + final heardRepeats = entry.snr != null + ? '${entry.repeaterId}(${entry.snr!.toStringAsFixed(2)})' + : '${entry.repeaterId}(null)'; await _apiQueueService.enqueueRx( latitude: entry.lat, longitude: entry.lon, @@ -1617,8 +1991,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); - final validator = PacketValidator(allowedChannels: allowedChannels); - + final validator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + // Create unified handler _unifiedRxHandler = UnifiedRxHandler( txTracker: _txTracker!, @@ -1639,19 +2016,174 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Full disconnect cleanup - called on normal BLE disconnect (user-requested or no remembered device) /// Extracted from the original BLE disconnect listener + /// Configure multi-byte path hash mode on the radio during connection + /// Reads device's current mode, determines effective mode, and sends command if needed + Future _configurePathHashMode() async { + final deviceInfo = _meshCoreConnection?.deviceInfo; + if (deviceInfo == null) return; + + // Store the device's current mode (from DeviceInfo response) + _originalPathHashMode = deviceInfo.pathHashMode; + + // Sync runtime hopBytes from device's current mode + if (_originalPathHashMode != null) { + final deviceHopBytes = _originalPathHashMode! + 1; + _hopBytes = deviceHopBytes; + // Map TX bytes to trace bytes (3-byte traces not possible, use 4) + _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + } else { + _hopBytes = 1; + _traceHopBytes = 1; + } + + final effective = effectiveHopBytes; + final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceHopBytes = deviceMode + 1; + + if (effective != deviceHopBytes && _originalPathHashMode != null) { + // Need to change the radio's path hash mode + try { + await _meshCoreConnection!.setPathHashMode(effective - 1); + _hopBytes = effective; // Update runtime state to reflect new mode + _traceHopBytes = effective == 3 ? 4 : effective; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + + // Show warning popup if changing from 1-byte to multi-byte + if (deviceMode == 0 && effective > 1) { + final reason = enforceHopBytes + ? 'set by your regional admin' + : 'set in your app preferences'; + _pendingPathHashWarning = (hopBytes: effective, reason: reason); + notifyListeners(); // Trigger UI to show warning + } + } catch (e) { + debugError('[PATH] Failed to set path hash mode: $e'); + } + } else if (_originalPathHashMode == null && effective > 1) { + // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte + debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + if (enforceHopBytes) { + _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + notifyListeners(); + } + } else { + debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + } + } + + /// Restore radio to original path hash mode on clean disconnect + /// Skipped if the user manually changed the setting — they know what they're doing + Future _restorePathHashMode() async { + if (_originalPathHashMode == null) return; + + if (_userChangedPathMode) { + debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + _originalPathHashMode = null; + _userChangedPathMode = false; + return; + } + + final originalMode = _originalPathHashMode!; + final originalHopBytes = originalMode + 1; + + // Compare current runtime mode against what the device had before we changed it + if (_hopBytes != originalHopBytes) { + try { + await _meshCoreConnection?.setPathHashMode(originalMode); + debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + } catch (e) { + debugError('[PATH] Failed to restore path hash mode: $e'); + } + } else { + debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + } + _originalPathHashMode = null; + _userChangedPathMode = false; + } + + /// Send path hash mode to radio immediately when user changes setting while connected + void _applyLivePathHashMode(int newHopBytes) { + if (_originalPathHashMode == null) { + // Old firmware — can't send command, show warning + debugWarn('[PATH] Cannot change path mode: firmware does not support it'); + _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _hopBytes = 1; // Force back to 1 + notifyListeners(); + return; + } + + _hopBytes = newHopBytes; + _userChangedPathMode = true; + _pingService?.hopBytes = newHopBytes; + // Auto-map trace bytes when TX bytes change (3→4, others stay same) + final oldTraceHopBytes = _traceHopBytes; + _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + // Clear target repeater if trace bytes changed — old hex ID has wrong byte length + if (_traceHopBytes != oldTraceHopBytes) { + _targetRepeaterId = null; + } + final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 + _meshCoreConnection?.setPathHashMode(mode); + debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + notifyListeners(); + } + + /// Set hop bytes (called from settings UI). Each companion device may differ. + void setHopBytes(int value) { + if (value < 1 || value > 3) return; + if (value == _hopBytes) return; + + if (isConnected) { + _applyLivePathHashMode(value); + } else { + _hopBytes = value; + notifyListeners(); + } + } + + /// Set trace hop bytes (called from settings UI). Valid values: 1, 2, 4. + void setTraceHopBytes(int value) { + if (value != 1 && value != 2 && value != 4) return; + if (value == _traceHopBytes) return; + _traceHopBytes = value; + _pingService?.traceHopBytes = value; + // Clear target repeater — old hex ID has wrong byte length + _targetRepeaterId = null; + debugLog('[TRACE] User changed trace bytes to $value'); + notifyListeners(); + } + + /// Pending path hash warning data (for UI to show dialog) + ({int hopBytes, String reason})? _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + + /// Clear the pending warning after UI has shown it + void clearPathHashWarning() { + _pendingPathHashWarning = null; + } + Future _fullDisconnectCleanup() async { + _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping timers _autoPingTimer.stop(); _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); } @@ -1688,6 +2220,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Reset anonymous mode state (BLE already gone, can't restore name) + _isAnonymousRenamed = false; + _originalDeviceName = null; + + // Clear top-heard overlay + _clearOverlayState(); + // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; @@ -1697,6 +2236,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { + _cancelPendingAutoPingRestore(); _isAutoReconnecting = true; _reconnectAttempt = 0; _connectionStep = ConnectionStep.reconnecting; @@ -1710,6 +2250,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; + _idleAutoStopReference = null; // Stop heartbeat _apiService.disableHeartbeat(); @@ -1818,9 +2359,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Restore auto-ping if it was active if (wasAutoPing) { + final restoreGeneration = _reconnectRestoreGeneration; // Use a short delay to ensure connection is fully set up - Timer(const Duration(milliseconds: 500), () { - if (_connectionStep == ConnectionStep.connected) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + restoreGeneration != _reconnectRestoreGeneration || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + return; + } + + if (!_autoPingEnabled) { toggleAutoPing(previousMode); debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } @@ -1843,17 +2396,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; - // Reset antenna setting so user must choose again on next connect + // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); + // Reset anonymous mode state (BLE already gone, can't restore name) + _isAnonymousRenamed = false; + _originalDeviceName = null; + // Do full disconnect cleanup (releases API session, etc.) _fullDisconnectCleanup(); notifyListeners(); @@ -1869,6 +2428,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; @@ -1876,10 +2436,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping if running (before releasing session) if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; + _idleAutoStopReference = null; } // End noise floor session on disconnect @@ -1896,6 +2460,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop RX wardriving if active (flushes batches to queue) _rxLogger?.stopWardriving(trigger: 'disconnect'); + // Save offline pings before clearing queue (no-op if not in offline mode or no pings) + await _saveOfflineSession(); + // ALWAYS START FRESH - clear any queued data on disconnect // Pings without a valid session cannot be uploaded later await _apiQueueService.clearOnDisconnect(); @@ -1915,9 +2482,37 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Restore original device name if anonymous mode renamed it (BLE must still be connected) + if (_isAnonymousRenamed && _originalDeviceName != null) { + try { + await _meshCoreConnection?.setAdvertName(_originalDeviceName!); + debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + } catch (e) { + debugError('[CONN] Anonymous mode: failed to restore name: $e'); + logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, autoSwitch: false); + } + _isAnonymousRenamed = false; + _originalDeviceName = null; + } + + // Restore original path hash mode before disconnect (while BLE still connected) + await _restorePathHashMode(); + + // Clear flood scope before disconnect (safety — BLE disconnect resets radio state anyway) + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[CONN] Failed to clear flood scope: $e'); + } + // Delete wardriving channel FIRST, while BLE connection is still active // This prevents "GATT Server is disconnected" errors - await _meshCoreConnection?.deleteWardrivingChannelEarly(); + if (_preferences.deleteChannelOnDisconnect) { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } else { + debugLog('[CHANNEL] Skipping channel deletion (user preference)'); + } // Cleanup unified RX handler and TX tracker _logRxDataSubscription?.cancel(); @@ -1944,18 +2539,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnected; _deviceModel = null; _manufacturerString = null; + _firmwareVersionString = null; _devicePublicKey = null; + _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; _authType = null; + _originalPathHashMode = null; + _userChangedPathMode = false; + _hopBytes = 1; + _traceHopBytes = 1; - // Clear regional channels (keeps only Public) + // Clear regional channels (keeps only Public) and scope ChannelService.clearRegionalChannels(); _regionalChannels = []; + _scope = null; // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -2036,14 +2639,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Toggle auto-ping mode (Active, Passive, or Hybrid) - /// Returns false if blocked by cooldown (Active/Hybrid Mode only - Passive Mode ignores cooldown) + /// Set the target repeater ID for targeted mode + void setTargetRepeaterId(String? id) { + _targetRepeaterId = id; + notifyListeners(); + } + + /// Auto-stop auto-ping after prolonged idle (no movement) + void _triggerIdleAutoStop() { + if (!_autoPingEnabled) return; + final elapsed = _idleAutoStopReference != null + ? DateTime.now().difference(_idleAutoStopReference!).inMinutes + : 30; + debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); + logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + _idleAutoStopReference = null; + toggleAutoPing(_autoMode); + } + + /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) + /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) Future toggleAutoPing(AutoMode mode) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; final isHybrid = mode == AutoMode.hybrid; - final isTxMode = !isPassive; // Active and Hybrid both do TX + final isTargeted = mode == AutoMode.targeted; + final isTxMode = !isPassive; // Active, Hybrid, and Targeted all do TX // If currently running the same mode, stop it (always allow stopping) if (_autoPingEnabled && _autoMode == mode) { @@ -2088,12 +2710,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); _autoPingEnabled = false; + _idleAutoStopReference = null; - // Start 7-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode + // Clear top-heard overlay on stop + _clearOverlayState(); + + // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed if (isTxMode) { - _cooldownTimer.start(7000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (7s) - blocks TX Ping and TX modes'); + _cooldownTimer.start(5000); + debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2121,6 +2747,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop countdown timers when switching modes _autoPingTimer.stop(); _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); // Save offline session if offline mode is enabled if (_preferences.offlineMode) { await _saveOfflineSession(); @@ -2138,7 +2766,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.setAutoPingInterval(intervalMs); debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); - final started = await _pingService!.enableAutoPing(passiveMode: isPassive, hybridMode: isHybrid); + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { @@ -2152,9 +2785,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reference: state.rxTracking.isWardriving = true in wardrive.js _rxLogger?.startWardriving(); _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active'; + final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -2174,7 +2808,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : 'Active Mode'; + final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -2191,6 +2825,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); } @@ -2200,7 +2835,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txLogEntries.clear(); _rxLogEntries.clear(); _discLogEntries.clear(); + _traceLogEntries.clear(); _errorLogEntries.clear(); + _clearOverlayState(); notifyListeners(); } @@ -2214,6 +2851,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Add a trace log entry (from Trace Mode) + void _addTraceLogEntry(TraceLogEntry entry) { + _traceLogEntries.insert(0, entry); + if (_traceLogEntries.length > _maxLogEntries) { + _traceLogEntries.removeLast(); + } + debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + + // Update top repeaters overlay with successful trace result + if (entry.success && entry.localSnr != null) { + // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay + final id = entry.targetRepeaterId.toUpperCase(); + final displayId = id.length > 6 ? id.substring(0, 6) : id; + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + } + + notifyListeners(); + } + /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { @@ -2282,6 +2938,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode _inZone = null; _currentZone = null; @@ -2289,6 +2947,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _lastZoneCheckPosition = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { + // Stop auto-save timer when leaving offline mode + _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); @@ -2341,6 +3001,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(offlineMode: true); _apiQueueService.offlineMode = true; + // 5b. Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -2375,8 +3038,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _saveOfflineSession(); // 4. Request new auth session - final deviceName = _meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Use "Anonymous" if renamed, otherwise real name + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot switch to online mode: no device name available'); @@ -2396,33 +3062,117 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - debugLog('[APP] Requesting auth for online mode'); - final result = await _apiService.requestAuth( + // ============================================================ + // STAGE 1: Try existing public_key authentication + // ============================================================ + debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + model: modelString, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, ); - if (result == null) { - debugError('[APP] Auth request failed: no response'); - _modeSwitchError = 'Unable to connect to server'; + // Check for maintenance mode + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } - if (result['success'] != true) { - final reason = result['reason'] as String?; - final message = result['message'] as String?; - debugError('[APP] Auth request failed: $reason - $message'); - _modeSwitchError = message ?? reason ?? 'Authentication failed'; + // Check if Stage 1 succeeded + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + } else if (result == null) { + // API unreachable (null = network/timeout error) + debugError('[APP] API unreachable - network error'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; return (success: false, error: _modeSwitchError); + } else { + // Stage 1 failed — check if Stage 2 is worth attempting + debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + _modeSwitchError = result['message'] as String? ?? 'GPS error'; + return (success: false, error: _modeSwitchError); + } + + // ============================================================ + // STAGE 2: Auth failed, attempt registration via signed contact_uri + // ============================================================ + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + _modeSwitchError = 'Companion not found in backend and failed to register via API'; + return (success: false, error: _modeSwitchError); + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: modelString, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; + return (success: false, error: _modeSwitchError); + } + + if (registerResult['success'] != true) { + final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + _modeSwitchError = serverMessage ?? 'Registration rejected by server'; + return (success: false, error: _modeSwitchError); + } + + // Registration successful + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + + result = registerResult; } // 5. Auth successful - update state @@ -2496,6 +3246,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } @@ -2535,17 +3286,59 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } - // Include device info for auth during upload (same priority as online auth: SelfInfo name → BLE name) + // Include device info for auth during upload (use real name, not "Anonymous" — sessions upload later) // Note: Connection already validates device name exists, so this should never be null + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, - deviceName: _meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''), + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, ); + _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); + _stopOfflineAutoSaveTimer(); notifyListeners(); } + /// Periodically auto-save offline pings to prevent data loss from app kill. + /// Uses a non-destructive snapshot so in-memory accumulation continues. + void _autoSaveOfflinePings() { + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + + final pings = _apiQueueService.getOfflinePingsSnapshot(); + if (pings.isEmpty) return; + + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + + _offlineSessionService.updateCurrentSession( + pings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + } + + void _startOfflineAutoSaveTimer() { + _offlineAutoSaveTimer?.cancel(); + _offlineAutoSaveTimer = Timer.periodic(const Duration(seconds: 60), (_) { + _autoSaveOfflinePings(); + }); + debugLog('[OFFLINE] Auto-save timer started (60s interval)'); + } + + void _stopOfflineAutoSaveTimer() { + if (_offlineAutoSaveTimer != null) { + _offlineAutoSaveTimer!.cancel(); + _offlineAutoSaveTimer = null; + debugLog('[OFFLINE] Auto-save timer stopped'); + } + } + /// Upload a stored offline session Future uploadOfflineSession(String filename) async { final sessionData = _offlineSessionService.getSessionData(filename); @@ -2583,14 +3376,42 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Upload an offline session with authenticated API session - /// Uses stored device credentials to authenticate before uploading + /// Uses stored device credentials to authenticate before uploading. + /// Session is fully isolated from the shared ApiService state — offline uploads + /// never touch _sessionId and cannot trigger BLE disconnect on failure. /// + /// @param onProgress Optional callback for progress updates (e.g., "Batch 1/3") /// Returns the result of the upload operation - Future uploadOfflineSessionWithAuth(String filename) async { + Future uploadOfflineSessionWithAuth( + String filename, { + void Function(String status)? onProgress, + }) async { + // Concurrency guard — only one offline upload at a time + if (_isUploadingOfflineSession) { + debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + return OfflineUploadResult.uploadInProgress; + } + + _isUploadingOfflineSession = true; + notifyListeners(); + + try { + return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + } finally { + _isUploadingOfflineSession = false; + notifyListeners(); + } + } + + /// Internal implementation of offline session upload with isolated session + Future _uploadOfflineSessionIsolated( + String filename, { + void Function(String status)? onProgress, + }) async { // 1. Get session with stored device credentials final session = _offlineSessionService.getSession(filename); if (session == null) { - debugLog('[APP] Offline session not found: $filename'); + debugLog('[OFFLINE] Session not found: $filename'); return OfflineUploadResult.notFound; } @@ -2601,25 +3422,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(); if (pings == null || pings.isEmpty) { - debugLog('[APP] Offline session has no pings: $filename'); + debugLog('[OFFLINE] Session has no pings: $filename'); return OfflineUploadResult.invalidSession; } // 2. Get device credentials from session final publicKey = session.devicePublicKey; if (publicKey == null) { - debugLog('[APP] Offline session missing device public key: $filename'); + debugLog('[OFFLINE] Session missing device public key: $filename'); return OfflineUploadResult.invalidSession; } final deviceName = session.deviceName; if (deviceName == null || deviceName.isEmpty) { - debugLog('[APP] Offline session missing device name: $filename'); + debugLog('[OFFLINE] Session missing device name: $filename'); return OfflineUploadResult.invalidSession; } - // 3. Authenticate with offline_mode: true - debugLog('[APP] Authenticating for offline upload with device: $deviceName'); + onProgress?.call('Authenticating...'); + + // 3. Authenticate with offline_mode: true, skipSessionStore: true + // This prevents writing to shared _sessionId/_txAllowed/etc. + debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -2632,54 +3456,105 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, offlineMode: true, + skipSessionStore: true, ); - if (authResult == null || authResult['success'] != true) { - final reason = authResult?['reason'] as String? ?? 'unknown'; - debugError('[API] Offline upload auth failed: $reason'); + Map? effectiveAuth = authResult; + + if (authResult == null) { + debugError('[OFFLINE] Auth failed: network error'); return OfflineUploadResult.authFailed; } - debugLog('[APP] Offline upload authenticated, session: ${authResult['session_id']}'); + if (authResult['success'] != true) { + final reason = authResult['reason'] as String? ?? 'unknown'; + debugLog('[OFFLINE] Stage 1 failed: $reason'); + + // Stage 2: If unknown_device and we have a stored contactUri, attempt registration + if (reason == 'unknown_device' && session.contactUri != null) { + debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: session.contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: 'Offline Upload', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + + if (registerResult == null || registerResult['success'] != true) { + final regReason = registerResult?['reason'] as String? ?? 'unknown'; + debugError('[OFFLINE] Stage 2 registration failed: $regReason'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + effectiveAuth = registerResult; + } else { + debugError('[OFFLINE] Auth failed: $reason'); + return OfflineUploadResult.authFailed; + } + } + + // Extract session_id into local variable — never stored in shared state + final offlineSessionId = effectiveAuth!['session_id'] as String?; + if (offlineSessionId == null) { + debugError('[OFFLINE] Auth succeeded but no session_id in response'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); - // 4. Upload pings in batches of 50 + // 4. Upload pings in batches of 50 using isolated session const batchSize = 50; var uploadedCount = 0; var failedBatches = 0; + final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { + final batchNum = (i ~/ batchSize) + 1; + onProgress?.call('Batch $batchNum/$totalBatches'); + final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatch(batch); + final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; - debugLog('[APP] Uploaded batch ${(i ~/ batchSize) + 1}: ${batch.length} pings'); + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); } else { failedBatches++; - debugError('[APP] Failed to upload batch ${(i ~/ batchSize) + 1}'); + debugError('[OFFLINE] Failed to upload batch $batchNum'); } } // Delay after posting before disconnect await Future.delayed(const Duration(seconds: 1)); - // 5. Release API session + // 5. Release isolated API session (does not clear shared state) + onProgress?.call('Finalizing...'); await _apiService.requestAuth( reason: 'disconnect', publicKey: publicKey, + sessionId: offlineSessionId, ); - debugLog('[APP] Offline upload session released'); + debugLog('[OFFLINE] Isolated upload session released'); // 6. Mark session as uploaded (don't delete) if all batches succeeded if (failedBatches == 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[API] Uploaded ${pings.length} pings from $filename'); + debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[API] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -2705,23 +3580,104 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void updatePreferences(UserPreferences preferences) { debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); + _preferences = preferences; - // Clear restored flag — user is making a manual choice now + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; - // Persist antenna choice per device name - final deviceName = displayDeviceName; + // Persist antenna choice per device name (use original name, not "Anonymous") + final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } + // Persist power override per device name + if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + _devicePowerOverrides[deviceName] = { + 'powerLevel': preferences.powerLevel, + 'txPower': preferences.txPower, + }; + _saveDevicePowerOverrides(); + debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + } else if (deviceName != null && preferences.autoPowerSet) { + // User re-selected the auto-detected value — clear any saved override + if (_devicePowerOverrides.remove(deviceName) != null) { + _saveDevicePowerOverrides(); + debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + } + } + + // Propagate RSSI filter setting to live trackers/validators + _syncRssiFilterSetting(preferences.disableRssiFilter); + + // Propagate CARpeater prefix to live trackers + _syncCarpeaterPrefix(); + + // Propagate min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = preferences.minPingDistanceMeters; + notifyListeners(); _savePreferences(); } + /// Set anonymous mode, disconnecting and reconnecting if currently connected + Future setAnonymousMode(bool enabled) async { + if (enabled == _preferences.anonymousMode) return; + + _preferences = _preferences.copyWith(anonymousMode: enabled); + _savePreferences(); + notifyListeners(); + + // If connected, disconnect and reconnect for clean auth session + if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + final deviceToReconnect = _bluetoothService.connectedDevice; + if (deviceToReconnect != null) { + _requestConnectionTabSwitch = true; + notifyListeners(); + await disconnect(); // Full cleanup (restores name if previously anonymous) + // Short delay for BLE cleanup + await Future.delayed(const Duration(milliseconds: 500)); + await connectToDevice(deviceToReconnect); + } + } + } + + /// Propagate carpeaterPrefix to live TxTracker and RxLogger + void _syncCarpeaterPrefix() { + final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + if (_txTracker != null) { + _txTracker!.carpeaterPrefix = prefix; + debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); + } + if (_rxLogger != null) { + _rxLogger!.carpeaterPrefix = prefix; + debugLog('[APP] Synced RxLogger.carpeaterPrefix = ${prefix ?? 'null'}'); + } + } + + /// Propagate disableRssiFilter to all active trackers and validators + void _syncRssiFilterSetting(bool disableRssiFilter) { + if (_txTracker != null) { + _txTracker!.disableRssiFilter = disableRssiFilter; + } + if (_unifiedRxHandler != null) { + final oldValidator = _unifiedRxHandler!.validator; + final newValidator = PacketValidator( + allowedChannels: oldValidator.allowedChannels, + disableRssiFilter: disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + } + if (_pingService != null) { + _pingService!.disableRssiFilter = disableRssiFilter; + } + } + /// Set developer mode (unlocked by tapping version 7 times) void setDeveloperMode(bool enabled) { _preferences = _preferences.copyWith(developerModeEnabled: enabled); @@ -2816,6 +3772,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } + /// Clear the connection tab switch request (called by main scaffold after switching) + void clearConnectionTabSwitchRequest() { + _requestConnectionTabSwitch = false; + } + // ============================================ // API Error Handling // ============================================ @@ -2907,6 +3868,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { authErrors.contains(reason) || zoneErrors.contains(reason)) { debugLog('[API] Session error requires disconnect: $reason'); + + // Preserve queued wardrive data to offline storage before disconnect clears it + if (sessionErrors.contains(reason)) { + try { + final queuedPings = await _apiQueueService.extractAllAsJson(); + if (queuedPings.isNotEmpty) { + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + await _offlineSessionService.saveSession( + queuedPings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + } + } catch (e) { + debugError('[APP] Failed to preserve queue to offline storage: $e'); + } + } + // Don't call requestAuth disconnect - session is already invalid on server // Just cleanup locally and disconnect await disconnect(); @@ -3115,6 +4099,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { + // Update position even on failure to prevent zone check flooding + // (without this, every GPS update re-triggers a zone check while driving) + _lastZoneCheckPosition = _currentPosition; debugError('[GEOFENCE] Zone status check failed: no response from API'); _scheduleZoneCheckRetry( seconds: 5, @@ -3153,11 +4140,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { - logError('GPS Accuracy Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_inaccurate'); + logError('GPS Accuracy Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_inaccurate'; + notifyListeners(); } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_stale'); + logError('GPS Stale Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_stale'; + notifyListeners(); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); @@ -3209,6 +4200,68 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Sync zone capacity display with auth result. + /// The /status API (pre-connection) and /auth API (during connection) can + /// return different capacity views. This keeps the connection screen's slot + /// display consistent with the map tab's txAllowed flag. + void _syncZoneCapacityFromAuth(Map authResult) { + if (_currentZone == null) return; + + // If auth response includes slot data, use it directly (forward-compatible) + if (authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = authResult['slots_available']; + debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + } + if (authResult.containsKey('slots_max')) { + _currentZone!['slots_max'] = authResult['slots_max']; + debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + } + + // Sync at_capacity with tx_allowed + final authTxAllowed = authResult['tx_allowed'] == true; + _currentZone!['at_capacity'] = !authTxAllowed; + + // If auth says TX not allowed and server didn't provide slot data, set slots to 0 + if (!authTxAllowed && !authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = 0; + debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + } + + // If auth says TX allowed and we have slot data but server didn't provide updated count, + // decrement by 1 (we just took a slot) + if (authTxAllowed && !authResult.containsKey('slots_available')) { + final available = _currentZone!['slots_available'] as int?; + if (available != null && available > 0) { + _currentZone!['slots_available'] = available - 1; + debugLog('[CAPACITY] Took a slot, slots_available=${available - 1}'); + } + } + + notifyListeners(); + } + + /// Start periodic zone status refresh while connected. + /// Keeps slot counts and capacity status fresh during a session. + void _startZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) async { + if (!isConnected || _preferences.offlineMode) { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + return; + } + debugLog('[CAPACITY] Periodic zone refresh'); + await checkZoneStatus(); + }); + debugLog('[CAPACITY] Started 60s zone refresh timer'); + } + + /// Stop zone status refresh timer. + void _stopZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -3235,21 +4288,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Debug File Logging (Mobile Only) // ============================================ - /// Auto-enable debug file logging for development builds - Future _autoEnableDebugLogsIfDevelopmentBuild() async { + /// Initialize debug file logging, respecting persisted user preference. + /// Enabled by default on all builds. If the user previously disabled it, + /// that preference is restored. + Future _initDebugLogs() async { if (kIsWeb) return; // File logging not available on web - if (AppConstants.isDevelopmentBuild) { - debugLog('[INIT] Development build detected (${AppConstants.appVersion}), auto-enabling debug logs'); - try { - await DebugFileLogger.enable(); + try { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) { + // Can't read preference — keep default (enabled, already started in main.dart) _debugLogsEnabled = true; await _refreshDebugLogFiles(); - } catch (e) { - debugError('[INIT] Failed to auto-enable debug logs: $e'); + return; } - } else { - debugLog('[INIT] Release build (${AppConstants.appVersion}), debug logs disabled by default'); + + final userDisabled = box.get('debug_logs_enabled') == false; + + if (userDisabled) { + debugLog('[INIT] Debug logs disabled by user preference, turning off'); + await DebugFileLogger.disable(); + _debugLogsEnabled = false; + DebugLogger.setEnabled(false); + } else { + debugLog('[INIT] Debug logging enabled (${AppConstants.appVersion})'); + // DebugFileLogger already enabled in main.dart + _debugLogsEnabled = true; + await _refreshDebugLogFiles(); + } + } catch (e) { + debugError('[INIT] Failed to init debug logs: $e'); + // Fallback: keep enabled (already started in main.dart) + _debugLogsEnabled = true; } } @@ -3266,6 +4336,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _debugLogsEnabled = true; DebugLogger.setEnabled(true); await _refreshDebugLogFiles(); + // Persist user preference + final box = await _openBoxSafely(_preferencesBoxName); + await box?.put('debug_logs_enabled', true); notifyListeners(); debugLog('[DEBUG] Debug file logging enabled'); } catch (e) { @@ -3285,6 +4358,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await DebugFileLogger.disable(); _debugLogsEnabled = false; DebugLogger.setEnabled(false); + // Persist user preference + final box = await _openBoxSafely(_preferencesBoxName); + await box?.put('debug_logs_enabled', false); notifyListeners(); } catch (e) { debugError('[DEBUG] Failed to disable debug file logging: $e'); @@ -3630,6 +4706,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); + + // Apply saved min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = _preferences.minPingDistanceMeters; } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -3683,6 +4763,40 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // Device Power Override Persistence + // ============================================ + + /// Load per-device power overrides from Hive storage + Future _loadDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_power_overrides'); + if (raw != null) { + _devicePowerOverrides = (raw as Map).map( + (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + ); + debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device power overrides: $e'); + } + } + + /// Save per-device power overrides to Hive storage + Future _saveDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_power_overrides', _devicePowerOverrides); + } catch (e) { + debugLog('[APP] Failed to save device power overrides: $e'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ @@ -3945,6 +5059,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Cleanup // ============================================ + void _cancelPendingAutoPingRestore() { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = null; + _reconnectRestoreGeneration++; + } + @override @override void notifyListeners() { @@ -3956,6 +5076,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); + _connectionSubscription?.cancel(); + _gpsStatusSubscription?.cancel(); + _gpsPositionSubscription?.cancel(); _logRxDataSubscription?.cancel(); _noiseFloorSubscription?.cancel(); _batterySubscription?.cancel(); @@ -3964,6 +5087,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneCheckCountdownTimer?.cancel(); _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); + _restoreAutoPingTimer?.cancel(); + _offlineAutoSaveTimer?.cancel(); + _zoneRefreshTimer?.cancel(); + _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index e186fe3e..ce97f871 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,7 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; /// BLE device selection and connection screen @@ -74,84 +75,50 @@ class _ConnectionScreenState extends State with WidgetsBinding @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; - - // Build FAB for scanning - show Cancel during scan, Scan when idle - // Hide FAB entirely during maintenance mode (maintenance UI has its own buttons) - // Hide FAB when Bluetooth is off (shows full-screen message instead) - // Hide FAB during auto-reconnect - Widget? fab; - if (appState.isScanning) { - // Show Cancel FAB during active scan - fab = isLandscape - ? FloatingActionButton.small( - onPressed: () => appState.stopScan(), - backgroundColor: Colors.red, - child: const Icon(Icons.close), - ) - : FloatingActionButton.extended( - onPressed: () => appState.stopScan(), - icon: const Icon(Icons.close), - label: const Text('Cancel'), - backgroundColor: Colors.red, - ); - } else if (appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff) { - // Offline mode bypasses both zone and maintenance checks - final canScan = appState.offlineMode || appState.inZone == true; - fab = isLandscape - ? FloatingActionButton.small( - onPressed: canScan ? () => appState.startScan() : null, - backgroundColor: canScan ? null : Colors.grey, - child: const Icon(Icons.bluetooth_searching), - ) - : FloatingActionButton.extended( - onPressed: canScan ? () => appState.startScan() : null, - icon: const Icon(Icons.bluetooth_searching), - label: Text(appState.offlineMode - ? 'Scan' - : appState.gpsStatus == GpsStatus.disabled - ? 'GPS Disabled' - : appState.gpsStatus == GpsStatus.permissionDenied - ? 'GPS Required' - : appState.isCheckingZone - ? 'Checking Zone...' - : appState.inZone == true - ? 'Scan' - : appState.inZone == false - ? 'Outside Zone' - : 'Checking Zone...'), - backgroundColor: canScan ? null : Colors.grey, - ); - } return Scaffold( appBar: AppBar( - title: const Text('Connection'), + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: _buildBody(context, appState), - floatingActionButton: fab, ); } Widget _buildBody(BuildContext context, AppStateProvider appState) { - // Show reconnecting UI + // Reconnecting and connection progress don't show the zone bar or bottom bar if (appState.connectionStep == ConnectionStep.reconnecting) { return _buildReconnectingView(context, appState); } - - // Show connection progress if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } + // All other states: zone bar top, content middle, action bar bottom + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildStateContent(context, appState)), + _buildBottomBar(context, appState), + ], + ); + } + + /// Routes to the correct sub-view (zone bar is already rendered above) + Widget _buildStateContent(BuildContext context, AppStateProvider appState) { // Show connected state if (appState.isConnected) { + final pathWarning = appState.pendingPathHashWarning; + if (pathWarning != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + appState.clearPathHashWarning(); + }); + } return _buildConnectedInfo(context, appState); } @@ -164,6 +131,99 @@ class _ConnectionScreenState extends State with WidgetsBinding return _buildDeviceList(context, appState); } + /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + ); + } + + /// The right-side action button: Scan, Cancel, or Disconnect + Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.isConnected) { + // Disconnect + return _buildBottomButton( + icon: Icons.bluetooth_disabled, + label: 'Disconnect', + color: Colors.red, + onPressed: () async => await appState.disconnect(), + ); + } + + if (appState.isScanning) { + // Cancel scan + return _buildBottomButton( + icon: Icons.close, + label: 'Cancel', + color: Colors.red, + onPressed: () => appState.stopScan(), + ); + } + + // Scan — disabled when can't scan + final canScan = appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + !appState.isBluetoothOff && + (appState.offlineMode || appState.inZone == true); + + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canScan ? () => appState.startScan() : null, + ); + } + + /// Styled button matching OfflineModeToggle shape + Widget _buildBottomButton({ + required IconData icon, + required String label, + required Color color, + VoidCallback? onPressed, + }) { + final enabled = onPressed != null; + final effectiveColor = enabled ? color : Colors.grey; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: effectiveColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: effectiveColor), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveColor, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; @@ -248,132 +308,129 @@ class _ConnectionScreenState extends State with WidgetsBinding // Get device name - uses displayDeviceName which prefers SelfInfo name over BLE advertisement name final deviceName = appState.displayDeviceName ?? 'Unknown'; - // Parse version from manufacturer string and use shortName from device model for hardware - // Format examples: - // - "MeshCore (Heltec V3) v1.10.0" - // - "Ikoka Stick-E22-30dBm (Xiao_nrf52) nightly-e31c46f" + // Extract version from firmware version string (v7+), fall back to manufacturer string String? version; - final manufacturerString = appState.manufacturerString; - if (manufacturerString != null) { - // Use regex to find version pattern directly instead of splitting - // Match: v followed by digits/dots, OR nightly- followed by hex, OR just digits.digits - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); - final match = versionRegex.firstMatch(manufacturerString); - if (match != null) { - version = match.group(1); + final fwString = appState.firmwareVersionString; + if (fwString != null && fwString.isNotEmpty) { + final semverMatch = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(fwString); + if (semverMatch != null) { + version = semverMatch.group(1); + } else { + final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + if (nightlyMatch != null) { + version = nightlyMatch.group(1); + } + } + } + if (version == null) { + final manufacturerString = appState.manufacturerString; + if (manufacturerString != null) { + final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final match = versionRegex.firstMatch(manufacturerString); + if (match != null) { + version = match.group(1); + } } } - // Use shortName from device model if available, otherwise fall back to manufacturer string - final hardware = appState.deviceModel?.shortName ?? manufacturerString ?? 'Unknown'; - + final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; + final platform = appState.deviceModel?.platform; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final prefs = appState.preferences; + final isAutoMode = appState.autoPingEnabled; + final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Build device info card - final deviceInfoCard = Card( + // Compact device summary card + final deviceSummaryCard = Card( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Header: BT icon + name/status Row( children: [ - const Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 28, - ), - const SizedBox(width: 12), + const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Connected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - ), + deviceName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, ), Text( - deviceName, - style: Theme.of(context).textTheme.bodyLarge, + 'Connected', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), ), ], ), ), ], ), - const Divider(height: 20), - _buildInfoRow('Hardware', hardware), - _buildInfoRow('Version', version ?? 'Unknown'), - if (appState.deviceModel != null) - _buildInfoRow('Platform', appState.deviceModel!.platform), - if (appState.devicePublicKey != null) + const SizedBox(height: 8), + + // Detail chips: hardware, version, platform + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _buildDetailChip(context, Icons.memory, hardware), + if (version != null) _buildDetailChip(context, Icons.code, version), + if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + ], + ), + + // Power level row + const SizedBox(height: 4), + _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + + // Public key row + if (appState.devicePublicKey != null) ...[ + const SizedBox(height: 8), _buildPublicKeyRow(context, appState.devicePublicKey!), - if (appState.authType != null && !appState.offlineMode) + ], + + // Registered via row + if (appState.authType != null && !appState.offlineMode) ...[ + const SizedBox(height: 4), _buildAuthTypeRow(context, appState.authType!), + ], ], ), ), ); - // Build disconnect button - final disconnectButton = ElevatedButton.icon( - onPressed: () async { - await appState.disconnect(); - }, - icon: const Icon(Icons.bluetooth_disabled), - label: const Text('Disconnect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), + // Compact channels card + final channelsCard = RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + scope: appState.offlineMode ? null : appState.scope, + isOfflineMode: appState.offlineMode, + compact: true, ); if (isLandscape) { - // Landscape: two-column layout return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left column: device info + disconnect Expanded( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: deviceInfoCard, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ], - ), + child: SingleChildScrollView(child: deviceSummaryCard), ), const SizedBox(width: 12), - // Right column: power + regional config Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildPowerLevelCard(context, appState), - const SizedBox(height: 12), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - isOfflineMode: appState.offlineMode, - ), - ], - ), - ), + child: SingleChildScrollView(child: channelsCard), ), ], ), @@ -381,55 +438,122 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Portrait: vertical layout - return Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), + // Portrait: compact vertical layout (bottom bar provided by _buildBody) + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + deviceSummaryCard, + const SizedBox(height: 12), + channelsCard, + ], + ), + ); + } + + /// Power level row matching Registered via / Public Key format + Widget _buildPowerRow( + BuildContext context, + AppStateProvider appState, + bool isPowerSet, + bool isAutoMode, + UserPreferences prefs, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: InkWell( + onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( children: [ - deviceInfoCard, - const SizedBox(height: 16), - _buildPowerLevelCard(context, appState), - const SizedBox(height: 16), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - isOfflineMode: appState.offlineMode, + const SizedBox( + width: 120, + child: Text( + 'Power Level', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + const SizedBox(width: 4), + Text( + isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + style: TextStyle( + fontWeight: FontWeight.w500, + color: isPowerSet ? null : Colors.orange, + ), + ), + if (prefs.autoPowerSet) ...[ + const SizedBox(width: 4), + const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const SizedBox(width: 2), + const Text( + 'Auto', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 14, color: Colors.orange), + const SizedBox(width: 2), + const Text( + 'Override', + style: TextStyle( + fontSize: 12, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + if (!isAutoMode) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + ], + ], ), ], ), ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ), - ], + ), ); } - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + /// Small detail chip with icon + text + Widget _buildDetailChip(BuildContext context, IconData icon, String text) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), + Icon(icon, size: 12, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, ), ), - Expanded(child: Text(value)), ], ), ); } + + Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -721,50 +845,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildPowerLevelCard(BuildContext context, AppStateProvider appState) { - final prefs = appState.preferences; - final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - return Card( - child: ListTile( - leading: const Icon(Icons.power), - title: const Text('Power Level'), - subtitle: Builder( - builder: (context) { - if (!isPowerSet) { - return Text( - 'Unknown hardware - select power', - style: TextStyle(color: Colors.orange.shade700), - ); - } - return Row( - children: [ - Text(prefs.powerLevelDisplay), - if (prefs.autoPowerSet) ...[ - const SizedBox(width: 8), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), - const SizedBox(width: 4), - const Text( - 'Auto', - style: TextStyle( - fontSize: 12, - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ); - }, - ), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), - ), - ); - } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; @@ -799,6 +879,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, // Clear auto flag on override + powerLevelSet: true, // Mark as manually overridden ), ); Navigator.pop(context); // Close confirmation @@ -831,23 +912,29 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Auto-detection info banner - if (prefs.autoPowerSet && deviceModel != null) + // Auto-detection / override info banner + if (deviceModel != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - border: Border.all(color: Colors.green), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), + border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const Icon(Icons.auto_awesome, size: 20, color: Colors.green), + Icon( + prefs.autoPowerSet ? Icons.auto_awesome : Icons.edit, + size: 20, + color: prefs.autoPowerSet ? Colors.green : Colors.orange, + ), const SizedBox(width: 8), Expanded( child: Text( - 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W', + prefs.autoPowerSet + ? 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W' + : 'Override active \u2014 ${deviceModel.shortName} auto-detects as ${deviceModel.power}W', style: const TextStyle(fontSize: 12), ), ), @@ -867,7 +954,7 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = prefs.autoPowerSet && deviceModel != null && power == deviceModel.power; + final isRecommended = deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -914,6 +1001,21 @@ class _ConnectionScreenState extends State with WidgetsBinding ], ), actions: [ + if (!prefs.autoPowerSet && deviceModel != null) + TextButton( + onPressed: () { + appState.updatePreferences( + prefs.copyWith( + powerLevel: deviceModel.power, + txPower: deviceModel.txPower, + autoPowerSet: true, + powerLevelSet: false, + ), + ); + Navigator.pop(context); + }, + child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), @@ -934,18 +1036,22 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.error_outline, + appState.isNetworkError ? Icons.cloud_off : Icons.error_outline, size: isLandscape ? 48 : 64, - color: Colors.red, + color: appState.isNetworkError ? Colors.orange : Colors.red, ), SizedBox(height: isLandscape ? 8 : 16), Text( - appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + appState.isNetworkError + ? 'Server Unreachable' + : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - appState.connectionError ?? 'Unknown error', + appState.isNetworkError + ? 'MeshMapper services unreachable, try again or use offline mode.' + : appState.connectionError ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), @@ -982,6 +1088,17 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; + // Network error: show wifi off indicator + } else if (appState.zoneCheckErrorReason == 'network') { + locationIcon = Icons.wifi_off; + locationText = 'No Internet'; + locationColor = Colors.red; + // GPS error: show GPS issue indicator + } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + locationIcon = Icons.gps_off; + locationText = 'GPS Unavailable'; + locationColor = Colors.orange; // Show "Checking Zone..." whenever a zone check is in progress // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { @@ -1152,103 +1269,89 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.engineering, + size: 64, + color: Colors.orange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Maintenance Mode', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 32), + // Primary action: Enable Offline Mode + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + if (appState.maintenanceUrl != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('More Info'), + ), + ], + const SizedBox(height: 32), + // Info note at bottom + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.engineering, - size: 64, - color: Colors.orange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'Maintenance Mode', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 32), - // Primary action: Enable Offline Mode - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - if (appState.maintenanceUrl != null) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('More Info'), - ), - ], - const SizedBox(height: 32), - // Info note at bottom - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } // Show Bluetooth off message (takes priority over zone checks) if (appState.isBluetoothOff) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_disabled, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Bluetooth is Off', - message: 'Please enable Bluetooth to scan for MeshCore devices.', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_disabled, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Bluetooth is Off', + message: 'Please enable Bluetooth to scan for MeshCore devices.', ); } @@ -1266,71 +1369,150 @@ class _ConnectionScreenState extends State with WidgetsBinding message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.public_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Region Not Available', - message: message, - action: OutlinedButton.icon( - onPressed: () => _launchOnboardingUrl(), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Request Region Onboarding'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.public_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Region Not Available', + message: message, + action: OutlinedButton.icon( + onPressed: () => _launchOnboardingUrl(), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Request Region Onboarding'), + ), ); } // Show zone checking status on initial startup (before zone is known) if (appState.inZone == null && !appState.offlineMode) { if (appState.zoneCheckError != null) { - // Zone check failed — show error with countdown final countdown = appState.zoneCheckRetryCountdown; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.cloud_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Zone Check Failed', - message: countdown > 0 - ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' - : '${appState.zoneCheckError}\n\nRetrying...', + + // Network error — show offline mode option (matches maintenance pattern) + if (appState.zoneCheckErrorReason == 'network') { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: Colors.deepOrange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'No Internet Connection', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + countdown > 0 + ? 'Retrying in ${countdown}s...' + : 'Retrying...', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + ), + ), + ], + ), + ), + ], ), ), - ], + ); + } + + // GPS errors — no auto-retry, show manual retry button + if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: appState.zoneCheckErrorReason == 'gps_inaccurate' + ? 'GPS Accuracy Error' + : 'GPS Stale Error', + message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + action: FilledButton.icon( + onPressed: () => appState.checkZoneStatus(), + icon: const Icon(Icons.refresh), + label: const Text('Retry Zone Check'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ); + } + + // Other non-network errors — show simple error with countdown + return _buildMessageContent( + context: context, + icon: Icons.cloud_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Zone Check Failed', + message: countdown > 0 + ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' + : '${appState.zoneCheckError}\n\nRetrying...', ); } // Zone check in progress or waiting for GPS - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_searching, - iconColor: Colors.blue.withValues(alpha: 0.7), - title: 'Checking MeshMapper Zone...', - message: 'Verifying your location with MeshMapper', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_searching, + iconColor: Colors.blue.withValues(alpha: 0.7), + title: 'Checking MeshMapper Zone...', + message: 'Verifying your location with MeshMapper', ); } if (appState.isScanning) { return Column( children: [ - _buildZoneStatusBar(context, appState), const LinearProgressIndicator(), Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), ], @@ -1341,12 +1523,7 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect)), - ], - ); + return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1354,74 +1531,48 @@ class _ConnectionScreenState extends State with WidgetsBinding // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', - action: isIOS - ? null - : ElevatedButton.icon( - onPressed: () => Geolocator.openLocationSettings(), - icon: const Icon(Icons.settings), - label: const Text('Open Location Settings'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Location Services Disabled', + message: 'Please enable Location Services to verify you\'re in an allowed zone.', + action: isIOS + ? null + : ElevatedButton.icon( + onPressed: () => Geolocator.openLocationSettings(), + icon: const Icon(Icons.settings), + label: const Text('Open Location Settings'), + ), ); } // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', - action: ElevatedButton.icon( - onPressed: () => _requestLocationPermission(appState), - icon: const Icon(Icons.location_on), - label: const Text('Enable Location'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'GPS Permission Required', + message: 'Location access is needed to verify you\'re in an allowed zone.', + action: ElevatedButton.icon( + onPressed: () => _requestLocationPermission(appState), + icon: const Icon(Icons.location_on), + label: const Text('Enable Location'), + ), ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_searching, - iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - title: 'No devices found', - message: 'Tap Scan to search for MeshCore devices', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_searching, + iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + title: 'No devices found', + message: 'Tap Scan to search for MeshCore devices', ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), - ], - ); + return _buildDeviceListView(context, appState, canConnect: canConnect); } void _launchOnboardingUrl() async { @@ -1543,6 +1694,82 @@ class _ConnectionScreenState extends State with WidgetsBinding }, ); } + + void _showPathHashWarning(BuildContext context, int hopBytes, String reason) { + if (reason == 'firmware_unsupported') { + // Firmware too old to support multi-byte paths + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber, size: 24, color: Colors.amber), + SizedBox(width: 8), + Flexible(child: Text('Firmware Update Recommended')), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your regional admin has enabled $hopBytes-byte path mode for this zone, ' + 'but your companion firmware does not support it.', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + 'The app will operate in 1-byte mode. Consider updating your companion ' + 'firmware to v1.14.0+ for more accurate repeater identification.', + style: TextStyle(fontSize: 13), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } else { + // Mode changed successfully + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.linear_scale, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Multi-Byte Paths Enabled')), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your radio has been updated from 1-byte to $hopBytes-byte paths for this session ($reason).', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + 'Your radio will remain in multi-byte mode until you change it.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } } class _DeviceListTile extends StatelessWidget { diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index b9a4141d..977ce8cf 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -19,12 +19,13 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Noise Floor History'), + toolbarHeight: 40, + title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) IconButton( - icon: const Icon(Icons.delete_outline), + icon: const Icon(Icons.delete_outline, size: 20), onPressed: () => _confirmClearSessions(context, appState), tooltip: 'Clear all sessions', ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index eacda815..d984a989 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -175,6 +175,14 @@ class _HomeScreenState extends State { onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), + // Trace count + _buildAppBarStatChip( + Icons.route, + appState.pingStats.traceCount, + Colors.cyan, + onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + ), + const SizedBox(width: 8), // Upload count _buildAppBarStatChip( Icons.cloud_done, @@ -230,7 +238,7 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -331,6 +339,9 @@ class _HomeScreenState extends State { case 'disc': return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -880,20 +891,12 @@ class _HomeScreenState extends State { description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), - // Offline mode toggle - _buildHelpItem( - icon: Icons.cloud_off, - color: Colors.orange, - title: 'Offline Mode', - description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.', - ), - - // Sound toggle + // Trace Mode _buildHelpItem( - icon: Icons.volume_up, - color: Colors.blue, - title: 'Sound', - description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.', + icon: Icons.gps_fixed, + color: Colors.cyan, + title: 'Trace Mode', + description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index bb8a6a39..97267e0b 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -3,10 +3,11 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../widgets/repeater_id_chip.dart'; -/// Log screen with tabs for TX Log, RX Log, and User Errors +/// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -16,11 +17,12 @@ class LogScreen extends StatefulWidget { class _LogScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final _allPingsKey = GlobalKey<_AllPingsTabState>(); @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 2, vsync: this); } @override @@ -36,227 +38,155 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 3) { - _tabController.animateTo(3); // Switch to Error tab + if (mounted && _tabController.index != 1) { + _tabController.animateTo(1); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); }); } + final totalPings = appState.txLogEntries.length + + appState.rxLogEntries.length + + appState.discLogEntries.length + + appState.traceLogEntries.length; + + final errorCount = appState.errorLogEntries.length; + return Scaffold( appBar: AppBar( - title: const Text('Logs'), + toolbarHeight: 40, + title: const Text('Logs', style: TextStyle(fontSize: 18)), actions: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () => _copyCurrentTabToCsv(context, appState), - tooltip: 'Copy CSV', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmClearLogs(context, appState), - tooltip: 'Clear all logs', + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == 'copy') _copyCurrentTabToCsv(context, appState); + if (value == 'clear') _confirmClearLogs(context, appState); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), + const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + ], ), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(32), + child: TabBar( + controller: _tabController, + indicatorSize: TabBarIndicatorSize.tab, + dividerHeight: 1, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + ], + ), + ), ), - body: Column( + body: TabBarView( + controller: _tabController, children: [ - // Secondary bar with tabs (full width) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(3, 'Errors', appState.errorLogEntries.length, isError: true)), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _TxLogTab(entries: appState.txLogEntries), - _RxLogTab(entries: appState.rxLogEntries), - _DiscLogTab(entries: appState.discLogEntries), - _ErrorLogTab(entries: appState.errorLogEntries), - ], - ), + _AllPingsTab( + key: _allPingsKey, + allEntries: appState.unifiedPingLogEntries, + repeaters: appState.repeaters, + txCount: appState.txLogEntries.length, + rxCount: appState.rxLogEntries.length, + discCount: appState.discLogEntries.length, + traceCount: appState.traceLogEntries.length, ), + _ErrorLogTab(entries: appState.errorLogEntries), ], ), ); } - /// Build a tab chip that matches StatusBar chip styling - Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false}) { - final theme = Theme.of(context); - // Colors matching status bar chips - const discColor = Color(0xFF7B68EE); // DISC purple - const txColor = Colors.green; // TX green (matches status bar) - const rxColor = Colors.blue; // RX blue (matches status bar) - - return GestureDetector( - onTap: () { - _tabController.animateTo(index); - setState(() {}); - }, - child: AnimatedBuilder( - animation: _tabController, - builder: (context, child) { - final isCurrentlySelected = _tabController.index == index; - Color currentColor; - if (isCurrentlySelected) { - if (isError) { - currentColor = Colors.red; - } else if (isDisc) { - currentColor = discColor; - } else if (isTx) { - currentColor = txColor; - } else if (isRx) { - currentColor = rxColor; - } else { - currentColor = theme.colorScheme.primary; - } - } else { - currentColor = theme.colorScheme.onSurfaceVariant; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.15 : 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.4 : 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, - color: currentColor, - ), - ), - if (count > 0) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: currentColor, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), - ); - }, - ), - ); - } - void _copyCurrentTabToCsv(BuildContext context, AppStateProvider appState) { - final currentTab = _tabController.index; - - switch (currentTab) { - case 0: // TX Log - _copyTxLogToCsv(context, appState.txLogEntries); - break; - case 1: // RX Log - _copyRxLogToCsv(context, appState.rxLogEntries); - break; - case 2: // DISC Log - _copyDiscLogToCsv(context, appState.discLogEntries); - break; - case 3: // Error Log - _copyErrorLogToCsv(context, appState.errorLogEntries); - break; + if (_tabController.index == 0) { + _copyAllPingsToCsv(context, appState); + } else { + _copyErrorLogToCsv(context, appState.errorLogEntries); } } - void _copyTxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { + // If a search filter is active, export only the filtered unified entries + final tabState = _allPingsKey.currentState; + final searchQuery = tabState?._searchQuery ?? ''; + if (searchQuery.isNotEmpty && tabState != null) { + final filtered = tabState._filteredEntries; + if (filtered.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + ); + return; + } + final buffer = StringBuffer(); + buffer.writeln('type,data'); + for (final entry in filtered) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No TX log entries to copy'), duration: Duration(seconds: 2)), + SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), ); return; } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,power,events'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } + final tx = appState.txLogEntries; + final rx = appState.rxLogEntries; + final disc = appState.discLogEntries; + final trace = appState.traceLogEntries; - void _copyRxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No RX log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), ); return; } final buffer = StringBuffer(); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + + if (tx.isNotEmpty) { + buffer.writeln('--- TX Log ---'); + buffer.writeln('timestamp,latitude,longitude,power,events'); + for (final entry in tx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('RX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - void _copyDiscLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No DISC log entries to copy'), duration: Duration(seconds: 2)), - ); - return; + if (rx.isNotEmpty) { + buffer.writeln('--- RX Log ---'); + buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + for (final entry in rx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + if (disc.isNotEmpty) { + buffer.writeln('--- DISC Log ---'); + buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + for (final entry in disc) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); + } + + if (trace.isNotEmpty) { + buffer.writeln('--- TRC Log ---'); + buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + for (final entry in trace) { + buffer.writeln(entry.toCsv()); + } } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('DISC log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), ); } @@ -284,7 +214,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, and error logs.'), + content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -303,231 +233,349 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } } -/// TX Log Tab -class _TxLogTab extends StatelessWidget { - final List entries; +// ============================================================================= +// All Pings Tab — unified chronological view with type filters +// ============================================================================= + +class _AllPingsTab extends StatefulWidget { + final List allEntries; + final List repeaters; + final int txCount; + final int rxCount; + final int discCount; + final int traceCount; + + const _AllPingsTab({ + super.key, + required this.allEntries, + required this.repeaters, + required this.txCount, + required this.rxCount, + required this.discCount, + required this.traceCount, + }); - const _TxLogTab({required this.entries}); + @override + State<_AllPingsTab> createState() => _AllPingsTabState(); +} + +class _AllPingsTabState extends State<_AllPingsTab> { + final Set _activeFilters = { + PingLogType.tx, + PingLogType.rx, + PingLogType.disc, + PingLogType.trace, + }; + + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + /// Current filtered entries (used by CSV export) + List _filteredEntries = []; @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.upload_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No TX pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } + void dispose() { + _searchController.dispose(); + super.dispose(); + } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildTxEntry(context, entry); - }, - ); + void _toggleFilter(PingLogType type) { + setState(() { + if (_activeFilters.contains(type)) { + // Don't allow deselecting the last filter + if (_activeFilters.length > 1) { + _activeFilters.remove(type); + } + } else { + _activeFilters.add(type); + } + }); } - Widget _buildTxEntry(BuildContext context, TxLogEntry entry) { - final appState = context.read(); + // --------------------------------------------------------------------------- + // Repeater name resolution & search matching + // --------------------------------------------------------------------------- + + /// Resolve a short repeater ID to known repeater names via prefix matching. + static ({List names, bool ambiguous}) _resolveRepeaterNames( + String repeaterId, List repeaters, + ) { + final idLower = repeaterId.toLowerCase(); + final matches = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(idLower)) + .map((r) => r.name) + .toList(); + return (names: matches, ambiguous: matches.length > 1); + } - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time and Power - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + /// Whether a hex ID has ambiguous name matches (maps to multiple repeaters). + static bool _isAmbiguousId(String repeaterId, List repeaters) { + return _resolveRepeaterNames(repeaterId, repeaters).ambiguous; + } + + /// True if the query looks like a hex string (only 0-9, a-f). + static bool _isHexQuery(String query) { + return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); + } + + /// Whether an entry matches the current search query. + bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty) return true; + final query = _searchQuery.toLowerCase(); + + switch (entry.type) { + case PingLogType.tx: + final tx = entry.asTx; + for (final event in tx.events) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.rx: + final rx = entry.asRx; + if (rx.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(rx.repeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + case PingLogType.disc: + final disc = entry.asDisc; + for (final node in disc.discoveredNodes) { + if (node.repeaterId.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.trace: + final trace = entry.asTrace; + if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + } + } + + /// Whether an entry should show the ambiguity indicator. + /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. + bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; + + switch (entry.type) { + case PingLogType.tx: + return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + case PingLogType.rx: + return _isAmbiguousId(entry.asRx.repeaterId, repeaters); + case PingLogType.disc: + return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + case PingLogType.trace: + return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); + } + } + + @override + Widget build(BuildContext context) { + final filtered = widget.allEntries + .where((e) => _activeFilters.contains(e.type)) + .where((e) => _matchesSearch(e, widget.repeaters)) + .toList(); + _filteredEntries = filtered; + + final hasEntries = widget.allEntries.isNotEmpty; + final hasResults = filtered.isNotEmpty; + + return Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: SizedBox( + height: 36, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: 'Search by repeater name or ID', + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + prefixIconConstraints: const BoxConstraints(minWidth: 36), + suffixIcon: _searchQuery.isNotEmpty + ? GestureDetector( + onTap: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: const Icon(Icons.close, size: 18), + ) + : null, + suffixIconConstraints: const BoxConstraints(minWidth: 36), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - const Spacer(), - // Power indicator (watts) - Text( - '${entry.power.toStringAsFixed(1)} W', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - ], + ), + onChanged: (value) => setState(() => _searchQuery = value.trim()), ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], + ), + ), + // Filter segmented row + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - - // Repeaters table - if (entry.events.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + children: [ + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _segmentDivider(context), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _segmentDivider(context), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _segmentDivider(context), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + ], + ), + ), + ), + ), + // List + Expanded( + child: !hasResults + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasEntries ? Icons.search_off : Icons.list_alt, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.events.map((event) => _buildRepeaterRow(context, event)), - ], + const SizedBox(height: 16), + Text( + hasEntries && _searchQuery.isNotEmpty + ? 'No results for \'$_searchQuery\'' + : 'No pings logged yet', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + itemCount: filtered.length, + itemBuilder: (context, index) { + final unified = filtered[index]; + final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + return switch (unified.type) { + PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + }; + }, ), - ), - ] else ...[ - const SizedBox(height: 8), + ), + ], + ); + } + + Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + final active = _activeFilters.contains(type); + return Expanded( + child: GestureDetector( + onTap: () => _toggleFilter(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7), + color: active ? color.withValues(alpha: 0.12) : Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - 'No repeaters heard', + label, style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), ), + if (count > 0) ...[ + const SizedBox(width: 4), + Container( + constraints: const BoxConstraints(minWidth: 18, minHeight: 16), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.0, + ), + ), + ), + ], ], - ], + ), ), ), - ), ); } - /// Build a table row for a repeater event - Widget _buildRepeaterRow(BuildContext context, RxEvent event) { - Color snrColor; - switch (event.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (event.rssi >= -70) { - rssiColor = Colors.green; - } else if (event.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + static Widget _segmentDivider(BuildContext context) { + return VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ); + } - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildTxChip(event.snr.toStringAsFixed(1), snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildTxChip('${event.rssi}', rssiColor), - ), - ), - ], + // --------------------------------------------------------------------------- + // Type badge + // --------------------------------------------------------------------------- + + static Widget _buildTypeBadge(PingLogType type) { + final (label, color) = switch (type) { + PingLogType.tx => ('TX', Colors.green), + PingLogType.rx => ('RX', Colors.blue), + PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), + PingLogType.trace => ('TRC', Colors.cyan), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, ), ), ); } - /// Build a small colored chip for TX table cells - Widget _buildTxChip(String value, Color color) { + // --------------------------------------------------------------------------- + // Shared chip builder + // --------------------------------------------------------------------------- + + static Widget _buildChip(String value, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -538,7 +586,7 @@ class _TxLogTab extends StatelessWidget { child: Text( value, style: TextStyle( - fontSize: 10, + fontSize: 11, fontWeight: FontWeight.w600, color: color, fontFamily: 'monospace', @@ -546,326 +594,172 @@ class _TxLogTab extends StatelessWidget { ), ); } -} - -/// RX Log Tab -class _RxLogTab extends StatelessWidget { - final List entries; - - const _RxLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No RX observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildRxEntry(context, entry); - }, - ); - } + // --------------------------------------------------------------------------- + // TX Card + // --------------------------------------------------------------------------- - Widget _buildRxEntry(BuildContext context, RxLogEntry entry) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); - - Color snrColor; - switch (entry.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (entry.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (entry.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } - return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time badge only - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + // Repeaters table + if (entry.events.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildRepeaterTable(context, entry.events), + ] else ...[ + const SizedBox(height: 8), Text( - entry.locationString, + 'No repeaters heard', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + fontStyle: FontStyle.italic, ), ), ], - ), - const SizedBox(height: 10), - - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildRxChip(entry.snr.toStringAsFixed(1), snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildRxChip('${entry.rssi}', rssiColor), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], + ], + ), ), ), - ), ); } - /// Build a small colored chip for RX table cells - Widget _buildRxChip(String value, Color color) { + Widget _buildRepeaterTable(BuildContext context, List events) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', - ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) => _buildTxRepeaterRow(context, event)), + ], ), ); } -} -/// DISC Log Tab (Discovery observations from Passive Mode) -class _DiscLogTab extends StatelessWidget { - final List entries; - - const _DiscLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( children: [ - Icon(Icons.radar_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No discovery observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text('Enable Passive Mode to discover nearby nodes', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12)), + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), ], ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; // Already sorted most recent first - return _buildDiscEntry(context, entry); - }, + ), ); } - Widget _buildDiscEntry(BuildContext context, DiscLogEntry entry) { + // --------------------------------------------------------------------------- + // RX Card + // --------------------------------------------------------------------------- + + Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); + final snrColor = _snrColor(entry.severity); + final rssiColor = _rssiColor(entry.rssi); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: Time and node count (matching TX log style) - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + const SizedBox(height: 10), + // Repeater table (single row) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], ), ), - ), - const Spacer(), - // Node count indicator (like power indicator in TX log) - Text( - '${entry.nodeCount} node${entry.nodeCount == 1 ? '' : 's'}', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + Divider(height: 1, color: Theme.of(context).dividerColor), + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), + Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + ], + ), + ), ), - ), - ], + ], + ), ), - const SizedBox(height: 8), + ], + ), + ), + ), + ); + } - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), + // --------------------------------------------------------------------------- + // DISC Card + // --------------------------------------------------------------------------- + + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + final appState = context.read(); + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), @@ -877,61 +771,19 @@ class _DiscLogTab extends StatelessWidget { ), child: Column( children: [ - // Header row Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) => _buildNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -940,7 +792,7 @@ class _DiscLogTab extends StatelessWidget { Text( 'No nodes discovered', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -953,20 +805,9 @@ class _DiscLogTab extends StatelessWidget { ); } - /// Build a table row for a discovered node - Widget _buildNodeRow(BuildContext context, DiscoveredNodeEntry node) { - // Color for RX SNR (what we received) - Color rxSnrColor; - switch (node.severity) { - case SnrSeverity.poor: - rxSnrColor = Colors.red; - case SnrSeverity.fair: - rxSnrColor = Colors.orange; - case SnrSeverity.good: - rxSnrColor = Colors.green; - } - - // TX SNR color (what they received from us) + Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + final rxSnrColor = _snrColorFromValue(node.localSnr); + final rssiColor = _rssiColor(node.localRssi); Color txSnrColor; if (node.remoteSnr <= -1) { txSnrColor = Colors.red; @@ -976,32 +817,21 @@ class _DiscLogTab extends StatelessWidget { txSnrColor = Colors.green; } - // RSSI color based on signal strength - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - // Node ID with type SizedBox( - width: 50, + width: 70, child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), + Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: const TextStyle( - fontSize: 9, + fontSize: 11, fontWeight: FontWeight.w500, color: Color(0xFF7B68EE), ), @@ -1009,53 +839,181 @@ class _DiscLogTab extends StatelessWidget { ], ), ), - // RX SNR - Expanded( - child: Center( - child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildChip('${node.localRssi}', rssiColor), - ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor), - ), - ), + Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), ); } - /// Build a small colored chip for table cells - Widget _buildChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + // --------------------------------------------------------------------------- + // Trace Card + // --------------------------------------------------------------------------- + + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + final colorScheme = Theme.of(context).colorScheme; + final appState = context.read(); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + // Results table + if (entry.success) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + _buildTraceNodeRow(context, entry), + ], + ), + ), + ] else ...[ + const SizedBox(height: 8), + Text( + 'No response', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', + ); + } + + Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); + final rssiColor = _rssiColor(entry.localRssi); + final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), + Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + return Row( + children: [ + _buildTypeBadge(type), + if (showAmbiguity) ...[ + const SizedBox(width: 2), + Tooltip( + message: 'Repeater ID matches multiple nodes', + child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + ), + ], + const SizedBox(width: 6), + Text( + timeString, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 2), + Text( + locationString, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), ), + ], + ); + } + + static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + return Text( + text, + textAlign: center ? TextAlign.center : TextAlign.left, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } + + static Color _snrColor(SnrSeverity? severity) { + return switch (severity) { + SnrSeverity.poor => Colors.red, + SnrSeverity.fair => Colors.orange, + SnrSeverity.good => Colors.green, + null => Colors.grey, + }; + } + + static Color _snrColorFromValue(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + + static Color _snrColorFromNullableValue(double? snr) { + if (snr == null) return Colors.grey; + return _snrColorFromValue(snr); + } + + static Color _rssiColor(int? rssi) { + if (rssi == null) return Colors.grey; + if (rssi >= -70) return Colors.green; + if (rssi >= -100) return Colors.orange; + return Colors.red; + } } -/// Error Log Tab +// ============================================================================= +// Error Log Tab (unchanged) +// ============================================================================= + class _ErrorLogTab extends StatelessWidget { final List entries; @@ -1080,7 +1038,7 @@ class _ErrorLogTab extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: entries.length, itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) + // Most recent first final entry = entries[entries.length - 1 - index]; return _buildErrorEntry(context, entry); }, diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index b0bc9034..87c2c435 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -26,6 +26,7 @@ class MainScaffold extends StatefulWidget { class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; + bool _hasShownLocationSettingsPrompt = false; final List _screens = [ const HomeScreen(), @@ -59,18 +60,15 @@ class _MainScaffoldState extends State { // Check if disclosure was already shown final hasShown = await PermissionDisclosureService.hasShownDisclosure(); - if (hasShown) { - debugLog('[DISCLOSURE] Already shown, skipping'); - return; + if (!hasShown) { + // Show the disclosure dialog + if (!mounted) return; + debugLog('[DISCLOSURE] Showing location disclosure dialog'); + await PermissionDisclosureService.showLocationDisclosure(context); } - // Show the disclosure dialog - if (!mounted) return; - debugLog('[DISCLOSURE] Showing location disclosure dialog'); - await PermissionDisclosureService.showLocationDisclosure(context); - - debugLog('[DISCLOSURE] User acknowledged, requesting permissions'); - await _requestPermissionsAfterDisclosure(); + debugLog('[DISCLOSURE] Ensuring location permission after disclosure'); + await _ensureLocationPermission(); } /// Request GPS permission on web (triggers browser's native prompt) @@ -93,8 +91,10 @@ class _MainScaffoldState extends State { } } - /// Request permissions after user accepts disclosure - Future _requestPermissionsAfterDisclosure() async { + /// Ensure location permission after disclosure has been shown. + /// Requests when possible, restarts GPS when granted, and surfaces a settings CTA + /// when the permission has been permanently denied. + Future _ensureLocationPermission() async { bool granted = false; if (Platform.isIOS) { @@ -104,12 +104,23 @@ class _MainScaffoldState extends State { permission = await Geolocator.requestPermission(); } debugLog('[DISCLOSURE] iOS location permission: $permission'); + if (permission == LocationPermission.deniedForever) { + _showLocationSettingsPrompt(); + return; + } granted = permission == LocationPermission.always || permission == LocationPermission.whileInUse; } else { - // Android: Request location via permission_handler - final status = await Permission.locationWhenInUse.request(); + // Android: only request if needed so previously granted permission just restarts GPS. + var status = await Permission.locationWhenInUse.status; + if (status.isDenied) { + status = await Permission.locationWhenInUse.request(); + } debugLog('[DISCLOSURE] Android location permission: $status'); + if (status.isPermanentlyDenied) { + _showLocationSettingsPrompt(); + return; + } granted = status.isGranted; } @@ -121,6 +132,21 @@ class _MainScaffoldState extends State { } } + void _showLocationSettingsPrompt() { + if (!mounted || _hasShownLocationSettingsPrompt) return; + _hasShownLocationSettingsPrompt = true; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission is disabled in system settings.'), + action: SnackBarAction( + label: 'Settings', + onPressed: Geolocator.openAppSettings, + ), + ), + ); + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -149,6 +175,18 @@ class _MainScaffoldState extends State { }); } + // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) + if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _selectedIndex = 3; // Switch to Connect tab + }); + appState.clearConnectionTabSwitchRequest(); + } + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b0132083..c99446f8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -14,17 +14,18 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../utils/web_file_helpers_stub.dart' if (dart.library.html) '../utils/web_file_helpers.dart'; +import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../models/user_preferences.dart'; import '../services/debug_file_logger.dart'; -import '../services/debug_submit_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/offline_session_service.dart'; import '../services/permission_disclosure_service.dart'; import '../utils/constants.dart'; import '../widgets/bug_report_dialog.dart'; +import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; @@ -41,93 +42,19 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - // Debug log upload tracking - String? _uploadingFilePath; - final Set _uploadedFiles = {}; + Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + final result = await showUploadLogsDialog(context, appState); - Future _uploadSingleLogFile(AppStateProvider appState, File file) async { - final filename = file.path.split('/').last; - - setState(() { - _uploadingFilePath = file.path; - }); - - try { - final service = DebugSubmitService(); - - // Use persistent device info - final publicKey = appState.devicePublicKey ?? - appState.lastConnectedPublicKey ?? - 'not-connected'; - final deviceName = appState.lastConnectedDeviceName ?? 'not-connected'; - - final success = await service.uploadDebugFileOnly( - file: file, - deviceId: deviceName, - publicKey: publicKey, - appVersion: AppConstants.appVersion, - devicePlatform: DebugSubmitService.getDevicePlatform(), - userNotes: 'Direct debug log upload from $deviceName', - ); - - service.dispose(); - - if (!mounted) return; - - if (success) { - setState(() { - _uploadedFiles.add(file.path); - _uploadingFilePath = null; - }); - AppToast.success(context, 'Uploaded $filename'); - } else { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Failed to upload $filename'); - } - } catch (e) { - debugError('[SETTINGS] Log file upload error: $e'); - if (mounted) { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Upload error: $e'); - } - } - } - - /// Upload the current (active) log file by rotating it first - Future _uploadCurrentLogFile(AppStateProvider appState, File currentFile) async { - final originalPath = currentFile.path; - - setState(() { - _uploadingFilePath = originalPath; - }); - - try { - // Rotate the log: closes current file, starts a new one - // The original file is now closed and safe to upload - await appState.prepareDebugLogsForUpload(); - - if (!mounted) return; - - // The original file still exists at the same path, now closed - final closedFile = File(originalPath); - if (!closedFile.existsSync()) { - throw Exception('Log file not found after rotation'); - } + if (!context.mounted || result == null) return; - // Now upload the closed file - await _uploadSingleLogFile(appState, closedFile); - } catch (e) { - debugError('[SETTINGS] Current log upload error: $e'); - if (mounted) { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Upload error: $e'); + if (result.success) { + String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + if (result.failedCount > 0) { + message += ' (${result.failedCount} failed)'; } + AppToast.success(context, message); + } else if (result.errorMessage != null) { + AppToast.error(context, result.errorMessage!); } } @@ -175,633 +102,807 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Settings'), + toolbarHeight: 40, + title: const Text('Settings', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ - // Appearance section - _buildSectionHeader(context, 'Appearance'), - ListTile( - leading: Icon( - prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + // Lock indicator + if (isAutoMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: const Row( + children: [ + Icon(Icons.lock, size: 16, color: Colors.amber), + SizedBox(width: 8), + Text( + 'Some settings locked during auto-ping', + style: TextStyle(fontSize: 12, color: Colors.amber), + ), + ], + ), + ), ), - title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), - trailing: Switch( + + // General + _buildSection(context, 'General', [ + SwitchListTile( + secondary: Icon( + prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + ), + title: const Text('Theme'), + subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - ), - ListTile( - leading: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - trailing: Switch( + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); }, ), - ), - - const Divider(), - - // Wardriving Settings section - _buildSectionHeader(context, 'Wardriving Settings'), - - // Auto-Ping Interval Selector - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Auto-Ping Interval'), - subtitle: Text(prefs.autoPingIntervalDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), - ), - - // Hybrid Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.compare_arrows), - title: Row( - children: [ - const Text('Hybrid Mode'), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHybridModeInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + SwitchListTile( + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, ), - subtitle: const Text('Combines Active and Passive modes'), - value: prefs.hybridModeEnabled, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, - ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + ]), - // Carpeater Ignore Setting - SwitchListTile( - secondary: const Icon(Icons.filter_alt), - title: const Text('Ignore Carpeater'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Filtering repeater 0x${prefs.ignoreRepeaterId}' - : 'Tap to set repeater ID to ignore'), - value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - // Show dialog to set repeater ID when enabling - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, - ), - - // Repeater ID to Ignore - show when enabled - if (prefs.ignoreCarpeater) + // Ping Settings + _buildSection(context, 'Ping Settings', [ + SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: const Text('Anonymous Mode'), + subtitle: Text(prefs.anonymousMode + ? 'Device broadcasts as "Anonymous"' + : 'Device uses its real name'), + value: prefs.anonymousMode, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, + ), ListTile( - leading: const SizedBox(width: 24), // Indent - title: const Text('Repeater ID'), - subtitle: Text(prefs.ignoreRepeaterId != null - ? '0x${prefs.ignoreRepeaterId}' - : 'Not set'), + leading: const Icon(Icons.timer), + title: const Text('Auto-Ping Interval'), + subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), ), + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Min Ping Distance'), + subtitle: Text(prefs.minPingDistanceDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + SwitchListTile( + secondary: const Icon(Icons.timer_off), + title: const Text('Auto-Stop After Idle'), + subtitle: const Text('Stops auto-ping after 30 min without movement'), + value: prefs.autoStopAfterIdle, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); + }, + ), + ]), - // Lock indicator - if (isAutoMode) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + // Modes + _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.compare_arrows), + title: Row( children: [ - Icon(Icons.lock, size: 16, color: Colors.orange.shade700), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Settings locked during auto-ping mode', - style: TextStyle( - fontSize: 12, - color: Colors.orange.shade700, - fontStyle: FontStyle.italic, - ), + const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHybridModeInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), + subtitle: appState.enforceHybrid + ? const Text( + 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Combines Active and Passive modes'), + value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, + onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { + appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); + }, ), - - // Background Mode - for "Always" location permission (iOS and Android) - if (!kIsWeb) ...[ - const Divider(), - _buildSectionHeader(context, 'Background Mode'), - _BackgroundModeToggle(appState: appState), - ], - - const Divider(), - - // Data Management section - _buildSectionHeader(context, 'Data Management'), - ListTile( - leading: const Icon(Icons.cloud_queue), - title: const Text('Queued Pings'), - subtitle: Text('${appState.queueSize} items waiting'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: appState.queueSize > 0 - ? () => appState.forceUploadQueue() - : null, - tooltip: 'Force upload', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: appState.queueSize > 0 - ? () => _confirmClearQueue(context, appState) - : null, - tooltip: 'Clear queue', - ), - ], - ), - ), - - // Clear Map Markers - ListTile( - leading: const Icon(Icons.delete_sweep), - title: const Text('Clear Map Markers'), - subtitle: const Text('Remove all TX/RX markers from map'), - onTap: () => _confirmClearPings(context, appState), - ), - - // Offline Sessions - if (appState.offlineSessions.isNotEmpty) ...[ - _buildSectionHeader(context, 'Offline Sessions'), - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), - ], - - const Divider(), - - // Device Info section - _buildSectionHeader(context, 'Device'), - ListTile( - leading: const Icon(Icons.perm_identity), - title: const Text('Device ID'), - subtitle: Text(appState.deviceId), - ), - - const Divider(), - - // About section - _buildSectionHeader(context, 'About'), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text(AppConstants.appName), - ), - GestureDetector( - onTap: () => _onVersionTap(appState), - child: ListTile( - leading: const Icon(Icons.new_releases_outlined), - title: const Text('Version'), - subtitle: Text(AppConstants.appVersion), - ), - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: const Text('Submit Feedback'), - subtitle: const Text('Report bugs or request features'), - onTap: () => _showBugReportDialog(context, appState), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.github), - title: const Text('GitHub'), - subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: const Text('Discord'), - subtitle: const Text('Join our community chat'), - onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), - ), - ListTile( - leading: const Icon(Icons.groups), - title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), - onTap: () => _launchUrl('https://ottawamesh.ca/'), - ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee ☕'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), - - // Exit Options (Android only) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ...[ - const Divider(), - _buildSectionHeader(context, 'Exit Options'), - - // Auto-close toggle SwitchListTile( - secondary: const Icon(Icons.exit_to_app), - title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), - value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + secondary: const Icon(Icons.signal_wifi_off), + title: Row( + children: [ + const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDiscDropInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: appState.enforceDiscDrop + ? const Text( + 'Set by Regional Admin — reports dead zones for network analysis.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Count failed discoveries as failed pings'), + value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, + onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); + } + }, ), + ]), - // Manual close button - ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), - title: const Text('Close App'), - subtitle: const Text('Exit the app completely'), - onTap: () => _showCloseAppConfirmation(context, appState), + // Filtering + _buildSection(context, 'Filtering', [ + SwitchListTile( + secondary: const Icon(Icons.filter_alt), + title: const Text('CARpeater Filter'), + subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), + value: prefs.ignoreCarpeater, + onChanged: isAutoMode ? null : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); + } + }, ), - ], - - // Developer Tools section - only visible when developer mode is enabled - if (appState.developerModeEnabled) ...[ - const Divider(), - - _buildSectionHeader(context, 'Developer Tools'), - - // Developer mode toggle (to disable) + if (prefs.ignoreCarpeater) + ListTile( + leading: const SizedBox(width: 24), + title: const Text('CARpeater ID'), + subtitle: Text(prefs.ignoreRepeaterId != null + ? '0x${prefs.ignoreRepeaterId}' + : 'Not set'), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + ), SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: const Text('Developer Mode'), - subtitle: const Text('Disable to hide developer tools'), - value: appState.developerModeEnabled, - onChanged: (value) { - appState.setDeveloperMode(value); + secondary: const Icon(Icons.shield_outlined), + title: const Text('Disable RSSI Filter'), + subtitle: Text(prefs.disableRssiFilter + ? 'Allows all signal strengths' + : 'Drops signals stronger than -30 dBm'), + value: prefs.disableRssiFilter, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); + } }, ), + ]), - // GPS Simulator Toggle - SwitchListTile( - secondary: Icon( - Icons.gps_fixed, - color: appState.isGpsSimulatorEnabled ? Colors.orange : null, - ), - title: Row( - children: [ - const Text('GPS Simulator'), - if (appState.isGpsSimulatorEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SIMULATED', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + // Radio Settings + _buildSection(context, 'Radio', [ + ListTile( + leading: const Icon(Icons.linear_scale), + title: Row( + children: [ + const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHopBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], - ], - ), - subtitle: Text(appState.isGpsSimulatorEnabled - ? 'Smooth simulated movement active' - : 'Use simulated GPS for testing'), - value: appState.isGpsSimulatorEnabled, - onChanged: (value) { - if (value) { - appState.enableGpsSimulator(); - } else { - appState.disableGpsSimulator(); - } - }, - ), - - // Simulator Settings (only when enabled) - if (appState.isGpsSimulatorEnabled) ...[ - // Speed Slider - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Simulation Speed'), - subtitle: Slider( - value: appState.gpsSimulatorSpeed, - min: 10, - max: 120, - divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - onChanged: (value) { - appState.setGpsSimulatorSpeed(value); - }, ), - trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - style: const TextStyle(fontWeight: FontWeight.bold), + subtitle: appState.enforceHopBytes + ? const Text( + 'Set by Regional Admin — larger IDs reduce collisions in your region.', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in TX/RX path hops'), + trailing: DropdownButton( + value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + ], + onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setHopBytes(value); + }, ), ), - - // Pattern Selector ListTile( - leading: const SizedBox(width: 24), - title: const Text('Movement Pattern'), - trailing: SizedBox( - width: 180, - child: DropdownButton( - value: appState.gpsSimulatorPattern, - underline: const SizedBox(), - isExpanded: true, - items: [ - const DropdownMenuItem( - value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + leading: const Icon(Icons.gps_fixed), + title: Row( + children: [ + const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showTraceBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - const DropdownMenuItem( - value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in trace path'), + trailing: DropdownButton( + value: appState.traceHopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 4, child: Text('4')), + ], + onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setTraceHopBytes(value); + }, + ), + ), + SwitchListTile( + secondary: const Icon(Icons.delete_sweep), + title: Row( + children: [ + const Flexible(child: Text('Delete Channel on Disconnect')), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDeleteChannelInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - if (appState.hasSimulatorRoute) - DropdownMenuItem( - value: SimulatorPattern.route, - child: Text( - 'Route: ${appState.simulatorRouteName ?? "Loaded"}', - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: (pattern) { - if (pattern != null) { - appState.setGpsSimulatorPattern(pattern); - } - }, - ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), + subtitle: Text(prefs.deleteChannelOnDisconnect + ? 'Removes #wardriving channel from device' + : 'Keeps #wardriving channel on device'), + value: prefs.deleteChannelOnDisconnect, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + }, ), + ]), - // Load Route File + // Data Management + _buildSection(context, 'Data', [ ListTile( - leading: const SizedBox(width: 24), - title: const Text('Load Route File'), - subtitle: Text(appState.hasSimulatorRoute - ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' - : 'KML or GPX file'), + leading: const Icon(Icons.cloud_queue), + title: const Text('Queued Pings'), + subtitle: Text('${appState.queueSize} items waiting'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () => _pickRouteFile(context, appState), - tooltip: 'Load route file', + icon: const Icon(Icons.cloud_upload), + onPressed: appState.queueSize > 0 + ? () => appState.forceUploadQueue() + : null, + tooltip: 'Force upload', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: appState.queueSize > 0 + ? () => _confirmClearQueue(context, appState) + : null, + tooltip: 'Clear queue', ), - if (appState.hasSimulatorRoute) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - appState.clearSimulatorRoute(); - AppToast.info(context, 'Route cleared'); - }, - tooltip: 'Clear route', - ), ], ), ), - - // Reset Position Button ListTile( - leading: const SizedBox(width: 24), - title: const Text('Reset Position'), - subtitle: Text(appState.hasSimulatorRoute - ? 'Reset to route start' - : 'Reset to Ottawa downtown'), - trailing: IconButton( - icon: const Icon(Icons.restart_alt), - onPressed: () { - appState.resetGpsSimulator(); - AppToast.info( - context, - appState.hasSimulatorRoute - ? 'Reset to route start' - : 'GPS simulator reset to Ottawa downtown', - ); - }, - ), + leading: const Icon(Icons.delete_sweep), + title: const Text('Clear Map Markers'), + subtitle: const Text('Remove all TX/RX markers from map'), + onTap: () => _confirmClearPings(context, appState), ), - ], - ], // Close developerModeEnabled conditional - - // Debug section (always visible on mobile) - if (!kIsWeb) ...[ - const Divider(), + ]), - _buildSectionHeader(context, 'Debug'), + // Offline Sessions + _buildSection(context, 'Offline Sessions', [ + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession(context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), + onDownload: () => _downloadOfflineSession(context, appState, session.filename), + )), + ]), + + // About + _buildSection(context, 'About', [ + const ListTile( + leading: Icon(Icons.info_outline), + title: Text(AppConstants.appName), + subtitle: Text('Mesh network coverage mapper'), + ), + ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: const Text('Version'), + subtitle: Text(AppConstants.appVersion), + onTap: () => _onVersionTap(appState), + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('Submit Feedback'), + subtitle: const Text('Report bugs or request features'), + onTap: () => _showBugReportDialog(context, appState), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.github), + title: const Text('GitHub'), + subtitle: const Text('View issues and source code'), + onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Discord'), + subtitle: const Text('Join our community chat'), + onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), + ), + ListTile( + leading: const Icon(Icons.groups), + title: const Text('Community'), + subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + onTap: () => _launchUrl('https://ottawamesh.ca/'), + ), + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), + ]), - SwitchListTile( - secondary: Icon( - Icons.bug_report, - color: appState.debugLogsEnabled ? Colors.orange : null, + // Exit Options (Android only) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + _buildSection(context, 'Exit', [ + SwitchListTile( + secondary: const Icon(Icons.exit_to_app), + title: const Text('Close App After Disconnect'), + subtitle: const Text('Automatically exit the app when disconnecting'), + value: prefs.closeAppAfterDisconnect, + onChanged: (value) => appState.setCloseAppAfterDisconnect(value), ), - title: Row( - children: [ - const Text('Debug Logs'), - if (appState.debugLogsEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LOGGING', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, + ListTile( + leading: const Icon(Icons.power_settings_new, color: Colors.red), + title: const Text('Close App'), + subtitle: const Text('Exit the app completely'), + onTap: () => _showCloseAppConfirmation(context, appState), + ), + ]), + + // Developer Tools - only visible when developer mode is enabled + if (appState.developerModeEnabled) + _buildSection(context, 'Developer Tools', [ + SwitchListTile( + secondary: const Icon(Icons.developer_mode), + title: const Text('Developer Mode'), + subtitle: const Text('Disable to hide developer tools'), + value: appState.developerModeEnabled, + onChanged: (value) { + appState.setDeveloperMode(value); + }, + ), + SwitchListTile( + secondary: Icon( + Icons.gps_fixed, + color: appState.isGpsSimulatorEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('GPS Simulator'), + if (appState.isGpsSimulatorEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SIMULATED', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), - ), + ], ], - ], - ), - subtitle: Text( - appState.debugLogsEnabled - ? AppConstants.isDevelopmentBuild - ? 'Auto-enabled for development build' - : 'Writing logs to file' - : 'Enable to save debug logs to device', + ), + subtitle: Text(appState.isGpsSimulatorEnabled + ? 'Smooth simulated movement active' + : 'Use simulated GPS for testing'), + value: appState.isGpsSimulatorEnabled, + onChanged: (value) { + if (value) { + appState.enableGpsSimulator(); + } else { + appState.disableGpsSimulator(); + } + }, ), - value: appState.debugLogsEnabled, - onChanged: (value) async { - if (value) { - await appState.enableDebugLogs(); - } else { - await appState.disableDebugLogs(); - } - }, - ), + if (appState.isGpsSimulatorEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Simulation Speed'), + subtitle: Slider( + value: appState.gpsSimulatorSpeed, + min: 10, + max: 120, + divisions: 11, + label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + onChanged: (value) { + appState.setGpsSimulatorSpeed(value); + }, + ), + trailing: Text( + formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Movement Pattern'), + trailing: SizedBox( + width: 180, + child: DropdownButton( + value: appState.gpsSimulatorPattern, + underline: const SizedBox(), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: SimulatorPattern.straight, + child: Text('Straight Line', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.circle, + child: Text('Circle', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.randomWalk, + child: Text('Random Walk', overflow: TextOverflow.ellipsis), + ), + if (appState.hasSimulatorRoute) + DropdownMenuItem( + value: SimulatorPattern.route, + child: Text( + 'Route: ${appState.simulatorRouteName ?? "Loaded"}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (pattern) { + if (pattern != null) { + appState.setGpsSimulatorPattern(pattern); + } + }, + ), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Load Route File'), + subtitle: Text(appState.hasSimulatorRoute + ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' + : 'KML or GPX file'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () => _pickRouteFile(context, appState), + tooltip: 'Load route file', + ), + if (appState.hasSimulatorRoute) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + appState.clearSimulatorRoute(); + AppToast.info(context, 'Route cleared'); + }, + tooltip: 'Clear route', + ), + ], + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Reset Position'), + subtitle: Text(appState.hasSimulatorRoute + ? 'Reset to route start' + : 'Reset to Ottawa downtown'), + trailing: IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + appState.resetGpsSimulator(); + AppToast.info( + context, + appState.hasSimulatorRoute + ? 'Reset to route start' + : 'GPS simulator reset to Ottawa downtown', + ); + }, + ), + ), + ], + ]), - // Log Files List (show when toggle is ON or when files exist) - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ - // Section header with Delete All button - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( + // Debug section (always visible on mobile) + if (!kIsWeb) + _buildSection(context, 'Debug', [ + SwitchListTile( + secondary: Icon( + Icons.bug_report, + color: appState.debugLogsEnabled ? Colors.orange : null, + ), + title: Row( children: [ - Text( - 'Debug Log Files', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.grey.shade600, + const Text('Debug Logs'), + if (appState.debugLogsEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LOGGING', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, ), - ), - const Spacer(), - if (appState.debugLogFiles.isNotEmpty) - TextButton.icon( - icon: const Icon(Icons.delete_sweep, size: 18), - label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + ), ), + ], ], ), - ), - - // Log files list or empty message - if (appState.debugLogFiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), - ), - ) - else - ...appState.debugLogFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isUploading = _uploadingFilePath == file.path; - final wasUploaded = _uploadedFiles.contains(file.path); - // First file (index 0) is the most recent/active log - can't upload - final isCurrentLog = index == 0; - // Parse unix timestamp from filename (meshmapper-debug-{timestamp}.txt) - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); - final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) - : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; + subtitle: Text( + appState.debugLogsEnabled + ? 'Writing logs to file' + : 'Enable to save debug logs to device', + ), + value: appState.debugLogsEnabled, + onChanged: (value) async { + if (value) { + await appState.enableDebugLogs(); } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - if (isCurrentLog) { - sizeDisplay = '$sizeDisplay (current)'; + await appState.disableDebugLogs(); } - - return ListTile( - leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), - subtitle: Text( - sizeDisplay, - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Upload button - if (wasUploaded) - Container( - width: 40, - height: 40, - alignment: Alignment.center, - child: const Icon( - Icons.check_circle, - size: 20, - color: Colors.green, - ), - ) - else if (isUploading) - Container( - width: 40, - height: 40, - alignment: Alignment.center, - child: const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + }, + ), + if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Text( + 'Log Files', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, ), - ) - else - IconButton( - icon: const Icon(Icons.cloud_upload, size: 20), - onPressed: _uploadingFilePath != null - ? null - : () => isCurrentLog - ? _uploadCurrentLogFile(appState, file) - : _uploadSingleLogFile(appState, file), - tooltip: isCurrentLog - ? 'Close log and upload' - : 'Upload to developer', - ), - // View button - IconButton( - icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), - tooltip: 'View', + ), + const Spacer(), + if (appState.debugLogFiles.isNotEmpty) ...[ + TextButton.icon( + icon: const Icon(Icons.cloud_upload, size: 18), + label: const Text('Upload'), + onPressed: () => _showUploadLogsDialog(context, appState), ), - // Share button - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + onPressed: () => _confirmDeleteAllLogs(context, appState), ), ], + ], + ), + ), + if (appState.debugLogFiles.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No debug logs yet', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), ), - ); - }), - ], - ], + ) + else + ...appState.debugLogFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isCurrentLog = index == 0; + final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final fileDate = timestampMatch != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + : null; + final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + if (isCurrentLog) { + sizeDisplay = '$sizeDisplay (current)'; + } - const SizedBox(height: 32), + return ListTile( + leading: const Icon(Icons.description, size: 20), + title: Text(dateStr, style: const TextStyle(fontSize: 13)), + subtitle: Text( + sizeDisplay, + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + onPressed: () => _showLogViewer(context, appState, file), + tooltip: 'View', + ), + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file), + tooltip: 'Share', + ), + ], + ), + ); + }), + ], + ]), ], ), ); } - Widget _buildSectionHeader(BuildContext context, String title) { + Widget _buildSection(BuildContext context, String title, List children) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + 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), + ], + ), ), ); } @@ -846,13 +947,104 @@ class _SettingsScreenState extends State { } } - void _confirmClearQueue(BuildContext context, AppStateProvider appState) { + void _confirmClearQueue(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Queue?'), + content: Text( + 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _confirmClearPings(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Map Markers?'), + content: const Text( + 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearPings(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Disable RSSI Filter?'), + content: const Text( + 'By disabling this filter, you are confirming that you are not operating ' + 'a carpeater (a repeater co-located with your device).\n\n' + 'If this filter is disabled while a carpeater is present, your device will ' + 'report false coverage data to the MeshMapper community map. This degrades ' + 'map accuracy for everyone.\n\n' + 'Only disable this if you are certain no co-located repeater is within range.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.updatePreferences( + appState.preferences.copyWith(disableRssiFilter: true), + ); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Disable Filter'), + ), + ], + ), + ); + } + + void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Queue?'), + title: const Text('Enable Anonymous Mode?'), content: Text( - 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', + 'Your device will be renamed to "Anonymous" for all mesh pings. ' + 'Other mesh users will not see your companion name.\n\n' + 'Your public key is still used to authenticate your session, but ' + 'neither your sessions nor your pings are linked to it on the server.\n\n' + '${isConnected ? 'Your device will disconnect and reconnect automatically.\n\n' : ''}' + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may remain named "Anonymous" until you reconnect and properly disconnect. ' + 'Always use the Disconnect button to restore your device name.', ), actions: [ TextButton( @@ -861,23 +1053,24 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { - appState.clearQueue(); Navigator.pop(context); + appState.setAnonymousMode(true); }, - child: const Text('Clear'), + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('Enable'), ), ], ), ); } - void _confirmClearPings(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Map Markers?'), + title: const Text('Disable Anonymous Mode?'), content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + 'This will disconnect and reconnect your device to restore your companion name. Continue?', ), actions: [ TextButton( @@ -886,10 +1079,10 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { - appState.clearPings(); Navigator.pop(context); + appState.setAnonymousMode(false); }, - child: const Text('Clear'), + child: const Text('Continue'), ), ], ), @@ -951,8 +1144,232 @@ class _SettingsScreenState extends State { ); } + void _showDiscDropInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith(discDropEnabled: true), + ); + }, + child: const Text('Enable'), + ), + ], + ), + ); + } + + void _showDeleteChannelInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.delete_sweep, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Delete Channel on Disconnect')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, the #wardriving channel is removed from your radio when you disconnect. ' + 'This keeps your radio\'s channel list clean.\n\n' + 'When disabled, the channel remains on the radio after disconnect.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may retain the #wardriving channel until you reconnect and properly disconnect.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showHopBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.linear_scale, size: 24), + SizedBox(width: 8), + Text('TX Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used to identify each repeater in TX/RX packet paths. ' + 'More bytes = more unique IDs, reducing collisions in large networks.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + '\u2022 1 byte: 256 unique IDs (default)\n' + '\u2022 2 bytes: 65,536 unique IDs\n' + '\u2022 3 bytes: 16 million unique IDs', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + 'Requires MeshCore firmware v1.14.0+. ' + 'RX always auto-detects the sender\'s byte size.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showTraceBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.gps_fixed, size: 24), + SizedBox(width: 8), + Text('Trace Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used for the repeater ID in trace path requests. ' + 'This is separate from TX Bytes because traces use a different encoding.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'TX/RX uses a simple counter:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 3 bytes\n\n' + 'Trace uses bitshift encoding:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 4 bytes', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + '3-byte traces are not supported by the MeshCore protocol. ' + 'When your region uses 3-byte TX paths, set Trace Bytes to 4.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showIntervalSelector(BuildContext context, AppStateProvider appState) { - final currentInterval = appState.preferences.autoPingInterval; + final minInterval = appState.minModeInterval; + var currentInterval = appState.preferences.autoPingInterval; + + // Auto-bump if current interval is below the admin minimum + if (currentInterval < minInterval) { + currentInterval = minInterval; + appState.updatePreferences( + appState.preferences.copyWith(autoPingInterval: minInterval), + ); + } showDialog( context: context, @@ -971,18 +1388,46 @@ class _SettingsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: AutoPingInterval.values.map((interval) { - final isSelected = interval == currentInterval; - - return RadioListTile( - title: Text('$interval seconds'), - subtitle: Text(interval == 15 - ? 'Fast (More coverage, causes more mesh load)' - : interval == 30 - ? 'Normal (Balanced coverage and mesh load)' - : 'Slow (Less coverage, little mesh load)'), + final isDisabled = interval < minInterval; + + String description; + if (interval == 15) { + description = 'Fast (More coverage, causes more mesh load)'; + } else if (interval == 30) { + description = 'Normal (Balanced coverage and mesh load)'; + } else { + description = 'Slow (Less coverage, little mesh load)'; + } + + final tile = RadioListTile( + title: Text( + '$interval seconds', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + subtitle: isDisabled + ? const Text( + 'Set by Regional Admin — slower intervals reduce congestion in your region', + style: TextStyle(fontSize: 12, color: Colors.amber), + ) + : Text( + description, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), value: interval, - selected: isSelected, ); + + if (isDisabled) { + return IgnorePointer( + child: Opacity( + opacity: 0.5, + child: tile, + ), + ); + } + return tile; }).toList(), ), ), @@ -996,7 +1441,49 @@ class _SettingsScreenState extends State { ); } + void _showDistanceSelector(BuildContext context, AppStateProvider appState) { + final currentDistance = appState.preferences.minPingDistanceMeters; + final controller = TextEditingController(text: currentDistance.toString()); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Min Ping Distance'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + suffixText: 'meters', + helperText: 'Minimum ${MinPingDistance.min}m', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final value = int.tryParse(controller.text); + if (value != null && value >= MinPingDistance.min) { + appState.updatePreferences( + appState.preferences.copyWith(minPingDistanceMeters: value), + ); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { + const maxHexChars = 6; + const hintText = 'FFFFFF'; + final controller = TextEditingController( text: appState.preferences.ignoreRepeaterId ?? '', ); @@ -1004,22 +1491,22 @@ class _SettingsScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Ignore Repeater ID'), + title: const Text('CARpeater Repeater ID'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Enter the repeater ID to ignore (2 hex digits):'), + const Text('Enter the full 3-byte repeater ID (6 hex digits):'), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( - labelText: 'Repeater ID', - hintText: 'FF', + labelText: 'CARpeater ID', + hintText: hintText, prefixText: '0x', border: OutlineInputBorder(), ), - maxLength: 2, + maxLength: maxHexChars, textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters @@ -1034,7 +1521,9 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Enter 2-character hex ID (e.g., FF) to ignore a specific repeater.\nLeave empty to disable.', + 'Enter all 6 hex digits of your CARpeater\'s ID. ' + 'The app will automatically truncate to match your region\'s hop byte size (1, 2, or 3 bytes). ' + 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater.', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1051,7 +1540,8 @@ class _SettingsScreenState extends State { onPressed: () { final value = controller.text.trim().toUpperCase(); final isValidHex = value.isEmpty || - (value.length == 2 && RegExp(r'^[0-9A-F]{2}$').hasMatch(value)); + (value.length == maxHexChars && + RegExp(r'^[0-9A-F]+$').hasMatch(value)); if (isValidHex) { // Enable ignoreCarpeater when setting a repeater ID @@ -1064,7 +1554,7 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Invalid hex value. Use 2 digits (00-FF).'); + AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1150,31 +1640,61 @@ class _SettingsScreenState extends State { } Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { - // Show loading indicator - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ), - SizedBox(width: 12), - Text('Uploading session...'), - ], + // Progress text notifier for updating dialog without rebuilding screen + final progressNotifier = ValueNotifier('Authenticating...'); + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + 'Uploading session...', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + filename, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, status, __) => Text( + status, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ], + ), ), - duration: Duration(seconds: 30), // Will be dismissed when upload completes ), ); - final result = await appState.uploadOfflineSessionWithAuth(filename); + final result = await appState.uploadOfflineSessionWithAuth( + filename, + onProgress: (status) => progressNotifier.value = status, + ); + // Close progress dialog if (context.mounted) { - // Dismiss loading indicator - ScaffoldMessenger.of(context).hideCurrentSnackBar(); + Navigator.of(context).pop(); + } - // Show result + progressNotifier.dispose(); + + if (context.mounted) { + // Show result via SnackBar String message; Color backgroundColor; @@ -1199,6 +1719,10 @@ class _SettingsScreenState extends State { message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.uploadInProgress: + message = 'Another upload is already in progress'; + backgroundColor = Colors.orange; + break; } ScaffoldMessenger.of(context).showSnackBar( @@ -1511,29 +2035,7 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Icons.location_on, color: _hasAlwaysPermission ? Colors.green : null, ), - title: Row( - children: [ - const Text('Background Location'), - if (_hasAlwaysPermission) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'ALWAYS', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), + title: const Text('Background Location'), subtitle: Text( _hasAlwaysPermission ? 'Location tracking works in background' @@ -1557,12 +2059,14 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> /// Widget for displaying an offline session in the list class _OfflineSessionTile extends StatelessWidget { final OfflineSession session; + final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; final VoidCallback onDownload; const _OfflineSessionTile({ required this.session, + this.uploadEnabled = true, required this.onUpload, required this.onDelete, required this.onDownload, @@ -1609,9 +2113,9 @@ class _OfflineSessionTile extends StatelessWidget { if (!isUploaded) IconButton( icon: const Icon(Icons.cloud_upload), - onPressed: onUpload, + onPressed: uploadEnabled ? onUpload : null, tooltip: 'Upload session', - color: Colors.green, + color: uploadEnabled ? Colors.green : Colors.grey, ), // Delete button - always available IconButton( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index d55fdca1..2cb45faf 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -25,9 +25,13 @@ class ApiQueueService { final ApiService _apiService; Box? _box; Timer? _batchTimer; + Timer? _pingFlushTimer; bool _isUploading = false; bool _isRecovering = false; + // In-memory fallback when Hive is corrupted/unavailable + final List _memoryQueue = []; + // Offline mode bool offlineMode = false; final List> _offlinePings = []; @@ -78,6 +82,7 @@ class ApiQueueService { debugError('[API QUEUE] Failed to clear stale items: $e - recovering'); await _recoverBox(); } + _memoryQueue.clear(); _rxBuffer.clear(); _offlinePings.clear(); @@ -206,8 +211,8 @@ class ApiQueueService { } } - /// Get current queue size - int get queueSize => _safeRead((box) => box.length, 0); + /// Get current queue size (Hive + in-memory fallback) + int get queueSize => _safeRead((box) => box.length, 0) + _memoryQueue.length; /// Enqueue a TX ping /// heardRepeats format: "4e(12.25),77(12.25)" or "None" @@ -235,10 +240,20 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); - _checkBatchUpload(); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); } /// Enqueue an RX observation @@ -316,10 +331,106 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a TRACE ping result (targeted zero-hop trace) + Future enqueueTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromTrace( + latitude: latitude, + longitude: longitude, + repeaterId: repeaterId, + localSnr: localSnr, + localRssi: localRssi, + remoteSnr: remoteSnr, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] TRACE enqueued (offline): $repeaterId'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); - _checkBatchUpload(); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a failed DISC discovery (no nodes responded) + Future enqueueDiscDrop({ + required double latitude, + required double longitude, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromDiscDrop( + latitude: latitude, + longitude: longitude, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] DISC drop enqueued (offline)'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); } // Guard to prevent concurrent RX buffer flushes @@ -341,10 +452,12 @@ class ApiQueueService { final bufferSize = _rxBuffer.length; _rxBuffer.clear(); - // Now add items to the box + // Now add items to the box (or memory fallback) for (final item in itemsToFlush) { final ok = await _safeWrite((box) => box.add(item)); - if (!ok) break; + if (!ok) { + _memoryQueue.add(item); + } } debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); @@ -373,25 +486,23 @@ class ApiQueueService { }); } - void _checkBatchUpload() { - if (queueSize >= _batchSize) { - _uploadBatch(); - } - } - /// Manually flush queue (called by TX-triggered flush timer) Future flushQueue() async { await _flushRxBuffer(); await _uploadBatch(); } - /// Upload batch of queued items + /// Upload batch of queued items (from Hive box or in-memory fallback) Future _uploadBatch() async { if (_isUploading) { debugLog('[API QUEUE] Upload skipped: already uploading'); return; } - if (_safeRead((box) => box.isEmpty, true)) { + + final hiveEmpty = _safeRead((box) => box.isEmpty, true); + final memoryEmpty = _memoryQueue.isEmpty; + + if (hiveEmpty && memoryEmpty) { debugLog('[API QUEUE] Upload skipped: queue empty'); return; } @@ -399,15 +510,8 @@ class ApiQueueService { _isUploading = true; try { - // Log if TX items are waiting in hold period - final pendingTx = _safeRead((box) => box.values.where((item) => - item.type == 'TX' && !item.isUploadEligible).length, 0); - if (pendingTx > 0) { - debugLog('[API QUEUE] $pendingTx TX items still in hold period'); - } - - // Get items ready for upload (must pass retry, retry delay, AND upload eligibility checks) - final items = _safeRead((box) => box.values + // Collect items from both Hive and memory queue + final hiveItems = _safeRead((box) => box.values .where((item) => item.retryCount < _maxRetries && item.isReadyForRetry && @@ -415,6 +519,16 @@ class ApiQueueService { .take(_batchSize) .toList(), []); + final memoryItems = _memoryQueue + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize - hiveItems.length) + .toList(); + + final items = [...hiveItems, ...memoryItems]; + if (items.isEmpty) { debugLog('[API QUEUE] Upload skipped: no items ready for upload'); _isUploading = false; @@ -430,30 +544,47 @@ class ApiQueueService { debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } - debugLog('[API QUEUE] Uploading ${items.length} items...'); + final memoryCount = memoryItems.length; + if (memoryCount > 0) { + debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + } else { + debugLog('[API QUEUE] Uploading ${items.length} items...'); + } // Attempt upload final result = await _apiService.uploadBatch(pings); if (result == UploadResult.success) { final uploadedCount = items.length; - // Remove successful items - for (final item in items) { - await item.delete(); + // Remove successful Hive items + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + // Remove successful memory items + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); onUploadSuccess?.call(uploadedCount); } else if (result == UploadResult.nonRetryable) { - // Data is permanently invalid (bad GPS, invalid request, etc.) — discard - for (final item in items) { - await item.delete(); + // Data is permanently invalid — discard + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried - for (final item in items) { + for (final item in hiveItems) { item.markRetried(); } + // Memory items: update retry fields directly (no Hive save) + for (final item in memoryItems) { + item.retryCount++; + item.lastRetryAt = DateTime.now(); + } debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } @@ -472,44 +603,18 @@ class ApiQueueService { await _uploadBatch(); } - /// Force upload after waiting for any TX items in hold period + /// Force upload all queued items immediately /// Used during BLE disconnect to ensure all data is uploaded before session release Future forceUploadWithHoldWait() async { + _pingFlushTimer?.cancel(); await _flushRxBuffer(); - - // Check if any TX items are still in hold period - try { - if (_box != null && _box!.isNotEmpty) { - final now = DateTime.now().millisecondsSinceEpoch; - int maxWaitMs = 0; - - for (final item in _box!.values) { - if (item.type == 'TX' && !item.isUploadEligible) { - final waitMs = item.canUploadAfter - now; - if (waitMs > maxWaitMs) { - maxWaitMs = waitMs; - } - } - } - - if (maxWaitMs > 0) { - // Cap wait time at 6 seconds (slightly more than 5s hold period) - final cappedWaitMs = maxWaitMs.clamp(0, 6000); - debugLog('[API QUEUE] Waiting ${cappedWaitMs}ms for TX hold period to expire'); - await Future.delayed(Duration(milliseconds: cappedWaitMs)); - } - } - } catch (e) { - debugError('[API QUEUE] Failed to check hold period: $e - skipping wait'); - await _recoverBox(); - } - await _uploadBatch(); } /// Clear all queued items Future clear() async { await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -518,16 +623,19 @@ class ApiQueueService { /// Called when device disconnects to ensure no stale pings remain /// Also stops the batch timer to prevent upload attempts without a session Future clearOnDisconnect() async { - // Stop the batch timer to prevent upload attempts without session + // Stop timers to prevent upload attempts without session _batchTimer?.cancel(); _batchTimer = null; - debugLog('[API QUEUE] Batch timer stopped on disconnect'); + _pingFlushTimer?.cancel(); + _pingFlushTimer = null; + debugLog('[API QUEUE] Timers stopped on disconnect'); final count = queueSize + _rxBuffer.length; if (count > 0) { debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -541,6 +649,7 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count stale items before connect'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); @@ -553,10 +662,18 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { - return _safeRead( + final hiveItems = _safeRead( (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); + final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + return [...hiveItems, ...memoryItems]; + } + + /// Get a snapshot of accumulated offline pings without clearing. + /// Used for periodic auto-saves to persist data without losing the in-memory accumulator. + List> getOfflinePingsSnapshot() { + return List>.from(_offlinePings); } /// Get accumulated offline pings and clear the accumulator @@ -572,9 +689,28 @@ class ApiQueueService { _offlinePings.clear(); } + /// Extract all queued items as API JSON without clearing the queue. + /// Used to preserve data before session-expiry disconnect. + Future>> extractAllAsJson() async { + // Flush RX buffer first so all items are in the main queue + await _flushRxBuffer(); + + final hiveItems = _safeRead( + (box) => box.values.toList(), + [], + ); + + final allItems = [...hiveItems, ..._memoryQueue]; + + if (allItems.isEmpty) return []; + + return allItems.map((item) => item.toApiJson()).toList(); + } + /// Dispose of resources void dispose() { _batchTimer?.cancel(); + _pingFlushTimer?.cancel(); _box?.close(); } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 717b232b..064c25a5 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:http/http.dart' as http; @@ -38,8 +39,17 @@ class ApiService { bool _rxAllowed = false; int? _sessionExpiresAt; Timer? _heartbeatTimer; + Timer? _heartbeatRetryTimer; + Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; + static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; List _channels = []; + List _scopes = []; + bool _enforceHybrid = false; + bool _enforceDiscDrop = false; + int _minModeInterval = 15; + int _apiHopBytes = 1; /// Callback to get current GPS coordinates for heartbeat /// Returns (lat, lon) or null if GPS is not available @@ -48,6 +58,24 @@ class ApiService { /// Regional channels from auth response (e.g., ['public', 'ottawa', 'testing']) List get channels => List.unmodifiable(_channels); + /// Regional scopes from auth response (e.g., ['ottawa']) + List get scopes => List.unmodifiable(_scopes); + + /// Whether hybrid mode is enforced by regional admin + bool get enforceHybrid => _enforceHybrid; + + /// Whether discovery drop is enforced by regional admin + bool get enforceDiscDrop => _enforceDiscDrop; + + /// Minimum auto-ping interval enforced by regional admin (seconds) + int get minModeInterval => _minModeInterval; + + /// Path hop bytes enforced by regional admin (1, 2, or 3) + int get apiHopBytes => _apiHopBytes; + + /// Whether hop bytes are enforced by regional admin (only 2 or 3 enforces) + bool get enforceHopBytes => _apiHopBytes > 1; + ApiService({http.Client? client}) : _client = client ?? http.Client(); /// Sanitize payload by removing sensitive fields for logging @@ -196,6 +224,8 @@ class ApiService { /// @param publicKey Device public key (for existing auth flow) /// @param contactUri Signed contact URI (for registration flow) /// @param offlineMode Set to true when uploading offline session data + /// @param skipSessionStore When true, does not write to shared _sessionId/_txAllowed/etc. Caller manages session locally. + /// @param sessionId Explicit session ID for disconnect. When provided, disconnect uses this instead of _sessionId and skips _clearSession(). /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, @@ -210,6 +240,8 @@ class ApiService { double? lon, double? accuracyMeters, bool offlineMode = false, + bool skipSessionStore = false, + String? sessionId, }) async { final stopwatch = Stopwatch()..start(); try { @@ -250,8 +282,8 @@ class ApiService { 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, }; } else { - // For disconnect: add session_id - payload['session_id'] = _sessionId; + // For disconnect: use explicit sessionId if provided, otherwise shared _sessionId + payload['session_id'] = sessionId ?? _sessionId; } final response = await _client.post( @@ -288,25 +320,71 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) if ((reason == 'connect' || reason == 'register') && data['success'] == true) { - _sessionId = data['session_id'] as String?; - _txAllowed = data['tx_allowed'] == true; - _rxAllowed = data['rx_allowed'] == true; - _sessionExpiresAt = data['expires_at'] as int?; - - // Parse channels array from auth response - final channelsData = data['channels']; - if (channelsData is List) { - _channels = channelsData.cast().toList(); - debugLog('[API] Regional channels: $_channels'); - } else { - _channels = []; - } + if (!skipSessionStore) { + _sessionId = data['session_id'] as String?; + _txAllowed = data['tx_allowed'] == true; + _rxAllowed = data['rx_allowed'] == true; + _sessionExpiresAt = data['expires_at'] as int?; + + // Parse channels array from auth response + final channelsData = data['channels']; + if (channelsData is List) { + _channels = channelsData.cast().toList(); + debugLog('[API] Regional channels: $_channels'); + } else { + _channels = []; + } + + // Parse scopes array from auth response + final scopesData = data['scopes']; + if (scopesData is List && scopesData.isNotEmpty) { + _scopes = scopesData.cast().toList(); + debugLog('[API] Regional scopes: $_scopes'); + } else { + _scopes = []; + } + + // Parse enforce_hybrid flag from auth response + _enforceHybrid = data['enforce_hybrid'] == true; + if (_enforceHybrid) { + debugLog('[API] Regional admin enforces hybrid mode'); + } + + // Parse disc_drop flag from auth response + _enforceDiscDrop = data['disc_drop'] == true; + if (_enforceDiscDrop) { + debugLog('[API] Regional admin enforces discovery drop'); + } + + // Parse min_mode_interval from auth response + final minInterval = data['min_mode_interval']; + if (minInterval is int && minInterval > 0) { + _minModeInterval = minInterval; + debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); + } else { + _minModeInterval = 15; + } - // Note: Heartbeat is enabled by AppStateProvider when auto mode starts - // (not on initial auth, since heartbeat is only for auto mode) + // Parse hop_bytes from auth response + final hopBytes = data['hop_bytes']; + if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { + _apiHopBytes = hopBytes; + if (_apiHopBytes > 1) { + debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + } + } else { + _apiHopBytes = 1; + } + + // Note: Heartbeat is enabled by AppStateProvider when auto mode starts + // (not on initial auth, since heartbeat is only for auto mode) + } } else if (reason == 'disconnect') { - // Clear session on disconnect - _clearSession(); + // Only clear shared session when no explicit sessionId was provided + // (explicit sessionId means caller manages its own session lifecycle) + if (sessionId == null) { + _clearSession(); + } } return data; @@ -526,6 +604,11 @@ class ApiService { _gpsProvider = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -533,9 +616,12 @@ class ApiService { /// Matches scheduleHeartbeat() in wardrive.js /// @param expiresAt Unix timestamp when session expires void scheduleHeartbeat(int expiresAt) { - // Cancel any existing heartbeat timer + // Cancel any existing heartbeat timer and reset retry state _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; if (!_heartbeatEnabled) return; @@ -548,15 +634,17 @@ class ApiService { // Session is about to expire or already expired - send heartbeat immediately debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); - return; - } + } else { + debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); + } - _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { - debugLog('[HEARTBEAT] Timer fired, sending keepalive'); - _sendScheduledHeartbeat(); - }); + // Schedule session deadline timer at exact expiry + _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -567,10 +655,22 @@ class ApiService { if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); + // Reset retry state on success + _heartbeatRetryCount = 0; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; // Next heartbeat will be scheduled when we get new expires_at } else if (result == null) { - // Network error — transient, trigger session expiring - debugWarn('[HEARTBEAT] Heartbeat failed: network error'); + // Network error — schedule retry with exponential backoff + if (_heartbeatRetryCount < _maxHeartbeatRetries) { + final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); + _heartbeatRetryCount++; + debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + } else { + debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + } _onSessionExpiring?.call(); } else { // Server returned an error — check if critical @@ -593,15 +693,51 @@ class ApiService { } } - /// Clear session data and cancel heartbeat timer + /// Schedule a hard deadline timer at the exact session expiry time. + /// If the server is unreachable and all heartbeat retries fail, this fires + /// and triggers the same disconnect flow as a server-returned session_expired. + void _scheduleSessionDeadline(int expiresAt) { + _sessionDeadlineTimer?.cancel(); + if (!_heartbeatEnabled) return; + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final secondsUntilExpiry = expiresAt - now; + + if (secondsUntilExpiry <= 0) { + _onSessionDeadlineReached(); + return; + } + + debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); + _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); + } + + /// Called when the session deadline timer fires — server was unreachable + void _onSessionDeadlineReached() { + debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); + _clearSession(); + onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); + } + + /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; _channels = []; + _scopes = []; + _enforceHybrid = false; + _enforceDiscDrop = false; + _minModeInterval = 15; + _apiHopBytes = 1; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -682,7 +818,7 @@ class ApiService { /// Returns a list of enabled repeaters for the given IATA zone code Future> fetchRepeaters(String iata) async { final stopwatch = Stopwatch()..start(); - const endpoint = '/repeaters.json'; + const endpoint = '/get_repeaters.php'; try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; @@ -734,10 +870,120 @@ class ApiService { } } + /// Submit wardrive data using an explicit session ID (for offline uploads) + /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state + Future?> submitWardriveDataWithSessionId( + List> entries, + String sessionId, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': entries, + }; + + final response = await _client.post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ).timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); + rethrow; + } + + final antennaSummary = entries.map((e) => + '${e['type']}:external_antenna=${e['external_antenna']}' + ).join(', '); + _logApiCall( + endpoint: '/wardrive-api.php/wardrive (offline)', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: {'data': '${entries.length} items', 'items': antennaSummary}, + response: data, + ); + + // Do NOT update shared _sessionExpiresAt or schedule heartbeat + return data; + } catch (e) { + stopwatch.stop(); + debugError('[API] POST /wardrive-api.php/wardrive (offline) failed: $e'); + return null; + } + } + + /// Upload batch using explicit session ID (for offline uploads) + /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode + Future uploadBatchWithSessionId( + List> pings, + String sessionId, + ) async { + if (pings.isEmpty) return UploadResult.success; + + try { + final result = await submitWardriveDataWithSessionId(pings, sessionId); + + if (result == null) { + debugError('[API] Offline upload batch failed: no response'); + return UploadResult.retryable; + } + + if (result['success'] == true) { + debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); + return UploadResult.success; + } + + final reason = result['reason'] as String?; + + // For offline uploads, session/auth errors are non-retryable but do NOT cascade + const criticalErrors = { + 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', + 'invalid_key', 'unauthorized', 'bad_key', + 'outside_zone', 'zone_full', 'zone_disabled', + }; + if (criticalErrors.contains(reason)) { + debugError('[API] Offline upload batch session error: $reason'); + return UploadResult.nonRetryable; + } + + const nonRetryableErrors = { + 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + }; + if (nonRetryableErrors.contains(reason)) { + debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + return UploadResult.nonRetryable; + } + + return UploadResult.retryable; + } catch (e) { + debugError('[API] Offline upload batch exception: $e'); + return UploadResult.retryable; + } + } + /// Dispose of resources void dispose() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 49a110c0..3ff0d174 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -249,6 +249,8 @@ class BackgroundServiceManager { String body; if (mode == 'Passive Mode') { body = 'RX: $rxCount | Queue: $queueSize'; + } else if (mode == 'Trace Mode') { + body = 'Trace: $txCount | RX: $rxCount | Queue: $queueSize'; } else { body = 'TX: $txCount | RX: $rxCount | Queue: $queueSize'; } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 6131f832..d279477d 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -46,6 +46,7 @@ class MobileBluetoothService implements BluetoothService { StreamSubscription? _connectionStateSubscription; StreamSubscription? _notificationSubscription; StreamSubscription? _scanSubscription; + StreamController? _scanController; // Store scanned device info for use in connect() // This preserves the device name from scan results @@ -183,10 +184,15 @@ class MobileBluetoothService implements BluetoothService { @override Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); + _scanController = controller; _updateStatus(ConnectionStatus.scanning); try { + // Ensure any previous scan stream is closed before starting a new one + await _scanSubscription?.cancel(); + _scanSubscription = null; + // Start scanning with filter for MeshCore service UUID await fbp.FlutterBluePlus.startScan( withServices: [fbp.Guid(BleUuids.serviceUuid)], @@ -208,14 +214,30 @@ class MobileBluetoothService implements BluetoothService { ); // Store device info for use in connect() - preserves name from scan _scannedDevices[device.id] = device; - controller.add(device); + if (!controller.isClosed) { + controller.add(device); + } } }); + // Complete stream when scan naturally stops (timeout or platform stop) + unawaited(() async { + await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + if (!controller.isClosed) { + await controller.close(); + } + }()); + yield* controller.stream; } finally { await _scanSubscription?.cancel(); _scanSubscription = null; + if (!controller.isClosed) { + await controller.close(); + } + if (identical(_scanController, controller)) { + _scanController = null; + } } } @@ -224,6 +246,11 @@ class MobileBluetoothService implements BluetoothService { await fbp.FlutterBluePlus.stopScan(); await _scanSubscription?.cancel(); _scanSubscription = null; + final scanController = _scanController; + if (scanController != null && !scanController.isClosed) { + await scanController.close(); + } + _scanController = null; // NOTE: Do NOT fire 'disconnected' here. Stopping a scan is not a disconnection. // The status will be updated by connect() when a connection starts. // Firing 'disconnected' here causes a race condition where the queued event diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 1535a629..753c67c2 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -28,6 +28,18 @@ class GpsService { /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; + /// Configured minimum ping distance (user-adjustable, clamped to minDistanceMeters floor) + double _configuredMinDistance = minDistanceMeters; + + /// Get the configured minimum ping distance + double get configuredMinDistance => _configuredMinDistance; + + /// Set the minimum ping distance (clamped to 25m floor) + void setMinPingDistance(double meters) { + _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + } + final _statusController = StreamController.broadcast(); final _positionController = StreamController.broadcast(); @@ -161,6 +173,14 @@ class GpsService { Future startWatching() async { debugLog('[GPS] startWatching() called, current status: $_status'); + // Ensure only one active position stream subscription exists. + // startWatching() can be called multiple times (e.g. after permission flow). + if (_positionSubscription != null) { + debugLog('[GPS] Existing position subscription found, restarting watcher'); + await _positionSubscription?.cancel(); + _positionSubscription = null; + } + // Check if location services are enabled first (system-level setting) final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); @@ -194,6 +214,12 @@ class GpsService { } debugLog('[GPS] Starting position stream listener...'); + + // Cancel any existing subscription to prevent orphaned listeners + // (e.g. restartGpsAfterPermission() racing with _initialize()) + await _positionSubscription?.cancel(); + _positionSubscription = null; + _updateStatus(GpsStatus.searching); // Configure location settings for position stream @@ -260,10 +286,10 @@ class GpsService { ); } - /// Check if current position is far enough from last ping (25m minimum) + /// Check if current position is far enough from last ping bool canPingAtPosition(Position position) { if (_lastPingPosition == null) return true; - return distanceFromLastPing(position) >= minDistanceMeters; + return distanceFromLastPing(position) >= _configuredMinDistance; } /// Mark current position as ping location diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 0292a289..07c08100 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,11 +17,15 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 + final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, required this.manufacturer, this.firmwareBuildDate, + this.firmwareVersionString, + this.pathHashMode, }); } @@ -66,6 +70,7 @@ class MeshCoreConnection { final _rawDataController = StreamController>.broadcast(); final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -120,6 +125,10 @@ class MeshCoreConnection { /// Stream of ControlData packets (for discovery responses) Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + /// Stream of TraceData packets (for trace path responses) + /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly + Stream get traceDataStream => _traceDataController.stream; + /// Stream of noise floor updates (dBm) Stream get noiseFloorStream => _noiseFloorController.stream; @@ -399,6 +408,9 @@ class MeshCoreConnection { case PushCodes.controlData: _onControlDataPush(reader); break; + case PushCodes.traceData: + _onTraceDataPush(reader); + break; case ResponseCodes.stats: _onStatsResponse(reader); break; @@ -438,17 +450,43 @@ class MeshCoreConnection { // Protocol v7+ format reader.readBytes(6); // skip reserved bytes final buildDate = reader.readCString(12); // e.g. "04-Jan-2026" - final manufacturerModel = reader.readString(); // remainder of frame - + + // Read manufacturer model as CString(40) — fixed-length null-terminated + final manufacturerModel = reader.readCString(40); + + // Parse additional fields from v9+ firmware + int? pathHashMode; + String? firmwareVersionString; + if (reader.remainingBytesCount > 0) { + // FIRMWARE_VERSION: 20-byte null-terminated C-string + if (reader.remainingBytesCount >= 20) { + firmwareVersionString = reader.readCString(20); + debugLog('[CONN] Firmware version string: $firmwareVersionString'); + } + + // client_repeat: 1 byte (v9+, skip) + if (reader.remainingBytesCount >= 1) { + reader.readByte(); // client_repeat + } + + // path_hash_mode: 1 byte (v10+) + if (reader.remainingBytesCount >= 1) { + pathHashMode = reader.readByte(); + debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + } + } + debugLog('[CONN] Build date: $buildDate'); debugLog('[CONN] Manufacturer model: $manufacturerModel'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturerModel, firmwareBuildDate: buildDate, + firmwareVersionString: firmwareVersionString, + pathHashMode: pathHashMode, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } else { @@ -584,6 +622,17 @@ class MeshCoreConnection { _controlDataController.add((raw: raw, snr: snr, rssi: rssi)); } + void _onTraceDataPush(BufferReader reader) { + // 0x89 TraceData has NO snr/rssi prefix (unlike 0x88 LogRxData). + // The entire remaining payload is the trace response: + // [reserved][path_len][flags][tag:4][auth:4][path_hashes][path_snrs] + final raw = reader.readRemainingBytes(); + + debugLog('[CONN] Received trace data: ${raw.length} bytes'); + + _traceDataController.add(raw); + } + void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // @@ -725,6 +774,14 @@ class MeshCoreConnection { await _sendToRadio(data); } + /// Set the companion advertised name + Future setAdvertName(String name) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setAdvertName); + data.writeString(name); + await _sendToRadio(data); + } + /// Set radio parameters Future setRadioParams(int freq, int bw, int sf, int cr) async { final data = BufferWriter(); @@ -771,6 +828,24 @@ class MeshCoreConnection { await _sendToRadio(data); } + /// Set flood scope for regional packet filtering + /// TransportKey is 16-byte SHA-256 derived key from scope name + Future setFloodScope(Uint8List transportKey) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setFloodScope); + data.writeByte(0); // reserved byte + data.writeBytes(transportKey); // 16-byte key + await _sendToRadio(data); + } + + /// Clear flood scope (return to unscoped global flood) + Future clearFloodScope() async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setFloodScope); + data.writeByte(0); // reserved byte — no key means clear + await _sendToRadio(data); + } + /// Delete channel by setting it to empty Future deleteChannel(int channelIdx) async { await setChannel(channelIdx, '', Uint8List(16)); @@ -904,6 +979,38 @@ class MeshCoreConnection { return tag; } + /// Send trace path to a specific repeater (targeted ping / zero-hop trace) + /// Returns the 4-byte tag used for matching the response + /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) + Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { + final random = Random.secure(); + final tag = Uint8List.fromList([ + random.nextInt(256), random.nextInt(256), + random.nextInt(256), random.nextInt(256), + ]); + + // Trace uses bitshift encoding: actual_bytes = 1 << path_sz + // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 + final int pathSz; + switch (hopBytes) { + case 4: pathSz = 2; break; + case 2: pathSz = 1; break; + default: pathSz = 0; break; + } + final int flags = pathSz & 0x03; + + debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + + final data = BufferWriter(); + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID + await _sendToRadio(data); + return tag; + } + /// Get battery voltage Future getBatteryVoltage() async { final data = BufferWriter(); @@ -1028,6 +1135,17 @@ class MeshCoreConnection { debugLog('[CONN] Stopped battery polling'); } + /// Set path hash mode on the radio + /// mode: 0=1-byte, 1=2-byte, 2=3-byte (persisted in radio prefs) + Future setPathHashMode(int mode) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setPathHashMode); // 61 (0x3D) + data.writeByte(0); // reserved + data.writeByte(mode); // 0=1-byte, 1=2-byte, 2=3-byte + await _sendToRadio(data); + debugLog('[CONN] Sent setPathHashMode: mode=$mode (${mode + 1}-byte hops)'); + } + /// Reboot device Future reboot() async { final data = BufferWriter(); @@ -1046,6 +1164,7 @@ class MeshCoreConnection { _rawDataController.close(); _logRxDataController.close(); _controlDataController.close(); + _traceDataController.close(); _noiseFloorController.close(); _batteryController.close(); } diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index cf736dd2..30da8862 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -55,6 +55,20 @@ class CryptoService { return channelKey; } + /// Derive a 16-byte TransportKey from a scope/region name + /// Same algorithm as channel key derivation: SHA-256(name)[0:16] + /// API returns names without '#' prefix (e.g., "ottawa") — we prepend it + /// to match MeshCore's implicit hashtag region convention + static Uint8List deriveScopeKey(String scopeName) { + final name = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final normalizedName = name.toLowerCase(); + final bytes = utf8.encode(normalizedName); + final digest = sha256.convert(bytes); + final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); + debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + return scopeKey; + } + /// Get channel key for any channel (handles both Public and hashtag channels) /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index a836f3a3..5796d6f1 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -20,6 +20,9 @@ class DiscTracker { /// Callback to check if a repeater should be ignored (carpeater filter) final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// When true, skip RSSI carpeater check (user setting) + final bool disableRssiFilter; + /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when a discovery response is dropped due to carpeater detection void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -28,7 +31,10 @@ class DiscTracker { /// Parameters: (node, isNew) - isNew is true for first time seeing this node void Function(DiscoveredNode node, bool isNew)? onNodeDiscovered; - DiscTracker({this.shouldIgnoreRepeater}); + /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. + final int hopBytes; + + DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -39,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 7), + Duration windowDuration = const Duration(seconds: 5), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); @@ -131,8 +137,8 @@ class DiscTracker { final pubkey = rawBytes.sublist(7, 39); final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - // Get repeater ID (first 2 hex chars = first byte) - final repeaterId = pubkeyHex.substring(0, 2); + // Get repeater ID (first N hex chars based on hopBytes setting) + final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { @@ -141,7 +147,9 @@ class DiscTracker { } // Check RSSI (carpeater failsafe) - if (PacketValidator.isCarpeater(localRssi)) { + if (disableRssiFilter) { + debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + } else if (PacketValidator.isCarpeater(localRssi)) { debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); @@ -204,7 +212,7 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index b3fc7430..c65a6ba1 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -8,31 +8,31 @@ import 'protocol_constants.dart'; class PacketMetadata { /// Original raw packet bytes final Uint8List raw; - + /// Packet header byte (contains route type, payload type, version) final int header; - + /// Route type (FLOOD=0x01, DIRECT=0x02) final int routeType; - - /// Number of hops in path - final int pathLength; - - /// Raw path bytes (repeater IDs) + + /// Raw pathLen byte (encodes both hash size and hop count) + final int pathLenRaw; + + /// Number of bytes per hop hash (1, 2, 3, or 4) + final int pathHashSize; + + /// Number of hops in path (0-63) + final int pathHashCount; + + /// Raw path bytes (repeater IDs, length = pathHashCount * pathHashSize) final Uint8List pathBytes; - - /// First hop (first repeater ID) - used for TX tracking - final int? firstHop; - - /// Last hop (last repeater ID) - used for RX logging - final int? lastHop; - + /// Signal-to-noise ratio (dB) final double snr; - + /// Received signal strength indicator (dBm) final int rssi; - + /// Encrypted payload (after header + path) final Uint8List encryptedPayload; @@ -40,17 +40,17 @@ class PacketMetadata { required this.raw, required this.header, required this.routeType, - required this.pathLength, + required this.pathLenRaw, + required this.pathHashSize, + required this.pathHashCount, required this.pathBytes, - required this.firstHop, - required this.lastHop, required this.snr, required this.rssi, required this.encryptedPayload, }); /// Parse packet metadata from BLE LogRxData event - /// + /// /// Event structure: /// ```dart /// { @@ -61,21 +61,21 @@ class PacketMetadata { /// ``` factory PacketMetadata.fromLogRxData(Map data) { debugLog('[RX PARSE] Starting metadata parsing'); - + final Uint8List raw = data['raw'] as Uint8List; final double snr = (data['lastSnr'] as num).toDouble(); final int rssi = data['lastRssi'] as int; - + // Dump raw packet for debugging final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); - + // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - + debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); - + // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 int pathLengthOffset = 1; @@ -83,42 +83,44 @@ class PacketMetadata { // TransportFlood or TransportDirect - has 4-byte transport codes pathLengthOffset = 5; } - - // Extract path length from calculated offset - final int pathLength = raw[pathLengthOffset]; - - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, Path length: $pathLength'); - + + // Extract raw pathLen byte and decode multi-byte path encoding + // pathHashSize = (pathLen >> 6) + 1 → 1, 2, 3, or 4 + // pathHashCount = pathLen & 63 → 0-63 hops + final int pathLenRaw = raw[pathLengthOffset]; + final int pathHashSize = (pathLenRaw >> 6) + 1; + final int pathHashCount = pathLenRaw & 63; + final int pathByteLen = pathHashCount * pathHashSize; + + debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); + // Path data starts after path length byte final int pathDataOffset = pathLengthOffset + 1; final Uint8List pathBytes = raw.sublist( pathDataOffset, - (pathDataOffset + pathLength).clamp(0, raw.length), + (pathDataOffset + pathByteLen).clamp(0, raw.length), ); - - // Derive first and last hops - final int? firstHop = pathBytes.isNotEmpty ? pathBytes.first : null; - final int? lastHop = pathBytes.isNotEmpty ? pathBytes.last : null; - + // Extract encrypted payload after path data - final int payloadOffset = pathDataOffset + pathLength; + final int payloadOffset = pathDataOffset + pathByteLen; final Uint8List encryptedPayload = raw.sublist(payloadOffset); - + debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathLength=$pathLength, ' - 'firstHop=${firstHop != null ? '0x${firstHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' - 'lastHop=${lastHop != null ? '0x${lastHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' + 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' + 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' + 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' 'SNR=$snr, RSSI=$rssi, ' 'payload=${encryptedPayload.length} bytes'); - + return PacketMetadata( raw: raw, header: header, routeType: routeType, - pathLength: pathLength, + pathLenRaw: pathLenRaw, + pathHashSize: pathHashSize, + pathHashCount: pathHashCount, pathBytes: pathBytes, - firstHop: firstHop, - lastHop: lastHop, snr: snr, rssi: rssi, encryptedPayload: encryptedPayload, @@ -160,20 +162,49 @@ class PacketMetadata { return payloadType == PayloadType.advert; } + /// Check if packet is TRACE (trace path response, header 0x26) + bool get isTrace { + final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + return payloadType == PayloadType.trace; + } + /// Get first hop as hex string (for TX tracking keys) + /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get firstHopHex { - return firstHop?.toRadixString(16).padLeft(2, '0'); + if (pathHashCount == 0) return null; + return _bytesToHex(pathBytes.sublist(0, pathHashSize)); } /// Get last hop as hex string (for RX logging keys) + /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get lastHopHex { - return lastHop?.toRadixString(16).padLeft(2, '0'); + if (pathHashCount == 0) return null; + return _bytesToHex(pathBytes.sublist(pathBytes.length - pathHashSize)); + } + + /// Get any hop by index as hex string + /// @param hopIndex 0-based hop index (0 = first hop) + String? getHopHex(int hopIndex) { + if (hopIndex < 0 || hopIndex >= pathHashCount) return null; + final offset = hopIndex * pathHashSize; + return _bytesToHex(pathBytes.sublist(offset, offset + pathHashSize)); + } + + /// Convert N bytes to uppercase hex string + String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + /// Static version for use in factory constructor + static String _bytesToHexStatic(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); } @override String toString() { return 'PacketMetadata(header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathLength=$pathLength, firstHop=$firstHopHex, lastHop=$lastHopHex, ' + 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' + 'firstHop=$firstHopHex, lastHop=$lastHopHex, ' 'SNR=$snr, RSSI=$rssi)'; } } diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index 7e9b7847..e9cec944 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -21,11 +21,15 @@ class PacketValidator { /// Allowed channels for validation final Map allowedChannels; - PacketValidator({required this.allowedChannels}); + /// When true, skip RSSI carpeater check (user setting) + final bool disableRssiFilter; + + PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason - Future validate(PacketMetadata metadata) async { + /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) + Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw @@ -34,15 +38,20 @@ class PacketValidator { debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' - 'PathLength: ${metadata.pathLength} | SNR: ${metadata.snr}'); + 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) - if (isCarpeater(metadata.rssi)) { + if (skipRssiCheck) { + debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); + } else if (disableRssiFilter) { + debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + } else if (isCarpeater(metadata.rssi)) { debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); + } else { + debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); // VALIDATION 2: Check packet type if (metadata.isGroupText) { @@ -163,6 +172,18 @@ class PacketValidator { return rssi >= maxRssiThreshold; } + /// Check if a hop hex string matches a stored CARpeater ID using prefix truncation. + /// The stored ID is always 3-byte (6 hex chars). Incoming hop IDs vary by the + /// packet's path hash size (1, 2, or 3 bytes). Compares the shorter prefix. + /// Also handles legacy shorter stored IDs from before the 3-byte requirement. + static bool isCarpeaterIdMatch(String hopHex, String storedId) { + final hop = hopHex.toUpperCase(); + final stored = storedId.toUpperCase(); + final compareLen = hop.length < stored.length ? hop.length : stored.length; + if (compareLen == 0) return false; + return hop.substring(0, compareLen) == stored.substring(0, compareLen); + } + /// Calculate ratio of printable ASCII characters (32-126) static double getPrintableRatio(String text) { if (text.isEmpty) return 0.0; diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 537d229b..3dee89f4 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -66,8 +66,10 @@ class CommandCodes { static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; + static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE static const int getStats = 56; // 0x38 static const int sendBinaryReq = 50; + static const int setPathHashMode = 61; // 0x3D - CMD_SET_PATH_HASH_MODE } /// Response codes received from device diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 64b3950e..0451fffc 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -34,12 +34,17 @@ class RxLogger { /// Called with repeater ID and reason when a packet is dropped due to carpeater detection final void Function(String repeaterId, String reason)? onCarpeaterDrop; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped + /// to report the underlying repeater with null SNR/RSSI + String? carpeaterPrefix; + RxLogger({ required this.onRxEntry, this.onObservation, required this.getGpsLocation, this.shouldIgnoreRepeater, this.onCarpeaterDrop, + this.carpeaterPrefix, }); /// Start passive RX wardriving @@ -68,16 +73,35 @@ class RxLogger { // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info - if (metadata.pathLength == 0) { + if (metadata.pathHashCount == 0) { debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } + bool carpeaterStripped = false; + String repeaterId; + double? reportedSnr = metadata.snr; + int? reportedRssi = metadata.rssi; + // Extract LAST hop from path (the repeater that directly delivered to us) - // Do this early so we have repeater ID for carpeater logging - // Mask to last byte only (0xFF) for consistent 2-character display - final lastHopId = metadata.lastHop! & 0xFF; - final repeaterId = lastHopId.toRadixString(16).padLeft(2, '0').toUpperCase(); + final lastHopHex = metadata.lastHopHex!; + + // CARpeater check: the carpeater is co-located with us, so it only + // appears as the last hop (the delivery repeater) on RX packets + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { + if (metadata.pathHashCount < 2) { + debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); + return false; + } + // Second-to-last hop = the real repeater that forwarded to our carpeater + repeaterId = metadata.getHopHex(metadata.pathHashCount - 2)!; + carpeaterStripped = true; + reportedSnr = null; + reportedRssi = null; + debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + } else { + repeaterId = lastHopHex; + } // Get current GPS location final gpsLocation = getGpsLocation(); @@ -89,13 +113,15 @@ class RxLogger { // Check if this repeater should be ignored (user carpeater filter) // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore - if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { + // Skip for CARpeater pass-through (CARpeater itself was already handled) + if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging - final validation = await validator.validate(metadata); + // Skip RSSI check for CARpeater pass-through + final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -110,17 +136,17 @@ class RxLogger { return false; } - debugLog('[RX LOG] Packet heard via last hop: $repeaterId, ' - 'SNR=${metadata.snr}, path_length=${metadata.pathLength}'); + debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, - snr: metadata.snr, - rssi: metadata.rssi, - pathLength: metadata.pathLength, + snr: reportedSnr, + rssi: reportedRssi, + pathLength: metadata.pathHashCount, header: metadata.header, lat: gpsLocation.lat, lon: gpsLocation.lon, @@ -132,9 +158,9 @@ class RxLogger { // Returns true if this observation updated the batch (new repeater or better SNR) final wasKept = await _handleRxBatching( repeaterId: repeaterId, - snr: metadata.snr, - rssi: metadata.rssi, - pathLength: metadata.pathLength, + snr: reportedSnr, + rssi: reportedRssi, + pathLength: metadata.pathHashCount, header: metadata.header, currentLocation: gpsLocation, metadata: metadata, @@ -149,10 +175,10 @@ class RxLogger { final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' - 'snr=${batchedObservation.snr}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); + 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' - 'snr=${metadata.snr}, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); + 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } return true; @@ -168,8 +194,8 @@ class RxLogger { /// Returns true if this observation was kept (new repeater or better SNR) Future _handleRxBatching({ required String repeaterId, - required double snr, - required int rssi, + required double? snr, + required int? rssi, required int pathLength, required int header, required ({double lat, double lon}) currentLocation, @@ -207,9 +233,14 @@ class RxLogger { debugLog('[RX BATCH] Started 30s timeout timer for repeater $repeaterId'); } else { // Already tracking this repeater - check if new SNR is better - if (snr > buffer.bestObservation.snr) { + // Null SNR never replaces non-null; non-null always replaces null + final existingSnr = buffer.bestObservation.snr; + final shouldUpdate = snr != null && existingSnr != null + ? snr > existingSnr + : snr != null && existingSnr == null; + if (shouldUpdate) { debugLog('[RX BATCH] Better SNR for repeater $repeaterId: ' - '${buffer.bestObservation.snr} -> $snr'); + '$existingSnr -> $snr'); // IMPORTANT: Keep the FIRST location where we heard this repeater, // only update the SNR/RSSI/metadata. This ensures the map pin stays // at the original location and doesn't follow the user. @@ -423,8 +454,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double snr; - final int rssi; + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -434,8 +465,8 @@ class RxObservation { RxObservation({ required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.lat, @@ -450,8 +481,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double snr; - final int rssi; + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; @@ -461,8 +492,8 @@ class RxApiEntry { required this.repeaterId, required this.lat, required this.lon, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.timestamp, diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart new file mode 100644 index 00000000..ad7b5297 --- /dev/null +++ b/lib/services/meshcore/trace_tracker.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Result of a trace path probe to a specific repeater +class TraceResult { + final String targetRepeaterId; + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final bool success; + + const TraceResult({ + required this.targetRepeaterId, + required this.localSnr, + required this.localRssi, + required this.remoteSnr, + required this.success, + }); +} + +/// Trace path tracker for targeted ping (zero-hop trace) +/// Sends CMD_SEND_TRACE_PATH (0x24) to a specific repeater and listens +/// for the trace response (PUSH_CODE_TRACE_DATA, 0x89). +/// +/// Follows the DiscTracker pattern but simpler: expects exactly 1 response. +class TraceTracker { + bool isListening = false; + Uint8List? _expectedTag; + String _targetRepeaterId = ''; + TraceResult? _result; + Timer? _windowTimer; + + /// BLE metadata from the 0x88 LogRxData event that arrives before the 0x89 TraceData. + /// Set by UnifiedRxHandler when it sees a trace packet in the LogRxData stream. + double pendingBleSnr = 0.0; + int pendingBleRssi = 0; + + /// Fired when a trace response is received during the window + void Function(TraceResult)? onTraceReceived; + + /// Fired when the listening window ends (result is null if no response) + void Function(TraceResult?)? onWindowComplete; + + TraceTracker(); + + /// Start tracking trace responses + void startTracking({ + required Uint8List tag, + required String targetRepeaterId, + Duration windowDuration = const Duration(seconds: 7), + }) { + debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); + debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + + isListening = true; + _expectedTag = tag; + _targetRepeaterId = targetRepeaterId; + _result = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + // Start window timer + _windowTimer?.cancel(); + _windowTimer = Timer(windowDuration, _endWindow); + + debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + } + + /// Handle incoming trace data packet (0x89) + /// Returns true if the packet was a valid trace response + /// + /// Trace response format (per meshcore_py reference): + /// Byte 0: reserved (skip) + /// Byte 1: path_len (raw byte count of path hashes, NOT LogRxData encoding) + /// Byte 2: flags → hashSize = 1 << (flags & 3) + /// Bytes 3-6: tag → match against _expectedTag + /// Bytes 7-10: auth_code (skip) + /// Bytes 11 to 11+path_len: path_hashes → extract repeater ID + /// Next hopCount+1 bytes: path_snrs → each is signedInt8 / 4.0 for dB + /// For zero-hop (hopCount=1): remoteSnr = snrs[0], localSnr = snrs[1] + bool handlePacket(Uint8List rawBytes, double bleSnr, int bleRssi) { + if (!isListening) return false; + + try { + // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes + if (rawBytes.length < 11) { + debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + return false; + } + + // Skip byte 0 (reserved) + final pathLen = rawBytes[1]; // Raw byte count of path hashes + final flags = rawBytes[2]; + + // Decode per 0x89 trace format (meshcore_py reference): + // hash size from flags, hop count from path_len / hash_size + final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop + final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; + + debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + + // Extract tag (bytes 3-6) + final tag = rawBytes.sublist(3, 7); + + // Match tag against expected + final expectedTag = _expectedTag; + if (expectedTag != null) { + bool tagMatch = true; + for (int i = 0; i < 4; i++) { + if (tag[i] != expectedTag[i]) { + tagMatch = false; + break; + } + } + if (!tagMatch) { + debugLog('[TRACE] Tag mismatch, ignoring packet'); + return false; + } + } + + // Skip auth_code (bytes 7-10) + + // Extract path hashes (bytes 11 to 11 + hopCount*hashSize) + const pathStart = 11; + final pathEnd = pathStart + (hopCount * hashSize); + + if (rawBytes.length < pathEnd) { + debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + return false; + } + + // Extract repeater ID from first hop in path + String repeaterId = ''; + if (hopCount > 0) { + final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); + repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + // Extract path SNRs (hopCount+1 bytes after path hashes) + final snrStart = pathEnd; + final snrEnd = snrStart + hopCount + 1; + + double remoteSnr = 0.0; + double localSnr = bleSnr; // Default to BLE metadata SNR + + if (rawBytes.length >= snrEnd && hopCount >= 1) { + // Each SNR byte is signed int8, divide by 4.0 for dB + remoteSnr = rawBytes[snrStart].toSigned(8) / 4.0; + if (hopCount + 1 >= 2) { + localSnr = rawBytes[snrStart + 1].toSigned(8) / 4.0; + } + } + + debugLog('[TRACE] Trace response from $repeaterId: ' + 'localSnr=${localSnr.toStringAsFixed(2)}, ' + 'remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' + 'bleRssi=$bleRssi'); + + _result = TraceResult( + targetRepeaterId: _targetRepeaterId, + localSnr: localSnr, + localRssi: bleRssi, + remoteSnr: remoteSnr, + success: true, + ); + + // Notify callback + onTraceReceived?.call(_result!); + + return true; + } catch (e, stackTrace) { + debugError('[TRACE] Error processing trace response: $e'); + debugError('[TRACE] Stack trace: $stackTrace'); + return false; + } + } + + /// Stop tracking and return result + TraceResult? stopTracking() { + debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + + final result = _result; + isListening = false; + _windowTimer?.cancel(); + _windowTimer = null; + _expectedTag = null; + + return result; + } + + /// Handle trace window completion + void _endWindow() { + debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + + final result = _result; + isListening = false; + _windowTimer = null; + _expectedTag = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + onWindowComplete?.call(result); + } + + /// Dispose of resources + void dispose() { + stopTracking(); + } +} diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 999a16dd..50b1d2ca 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,7 +7,7 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; -/// TX echo tracker for repeater detection during 7-second window +/// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { bool isListening = false; @@ -22,9 +22,14 @@ class TxTracker { Timer? _windowTimer; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped + /// to report the underlying repeater with null SNR/RSSI + String? carpeaterPrefix; + /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater - void Function(String repeaterId, double snr, int rssi, bool isNew)? onEchoReceived; + /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -34,6 +39,9 @@ class TxTracker { /// Returns true if the repeater should be filtered out bool Function(String repeaterId)? shouldIgnoreRepeater; + /// When true, skip RSSI carpeater check (user setting) + bool disableRssiFilter = false; + /// Start tracking echoes for a sent ping /// /// @param payload - The message text sent (for content verification) @@ -46,7 +54,7 @@ class TxTracker { required int channelIdx, required int channelHash, required Uint8List channelKey, - Duration windowDuration = const Duration(seconds: 7), + Duration windowDuration = const Duration(seconds: 5), }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); @@ -78,7 +86,7 @@ class TxTracker { // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr}, seen=${entry.value.seenCount}x'); + debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); } } } @@ -104,31 +112,55 @@ class TxTracker { // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops - if (metadata.pathLength == 0) { + if (metadata.pathHashCount == 0) { debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } // Extract first hop (first repeater) for use in validation and logging - final firstHopId = metadata.firstHop!; - final pathHex = firstHopId.toRadixString(16).padLeft(2, '0'); + var pathHex = metadata.firstHopHex!; + + // CARpeater pass-through: strip CARpeater hop and report underlying repeater + bool carpeaterStripped = false; + double? reportedSnr = metadata.snr; + int? reportedRssi = metadata.rssi; + + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { + if (metadata.pathHashCount < 2) { + debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); + return false; + } + // Multi-hop: strip CARpeater, report underlying repeater (second hop) + final underlyingHex = metadata.getHopHex(1)!; + debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + pathHex = underlyingHex; + carpeaterStripped = true; + reportedSnr = null; + reportedRssi = null; + } // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { + if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - if (PacketValidator.isCarpeater(metadata.rssi)) { + // Skip for CARpeater pass-through (user explicitly identified their CARpeater) + if (carpeaterStripped) { + debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); + } else if (disableRssiFilter) { + debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + } else if (PacketValidator.isCarpeater(metadata.rssi)) { debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) + } else { + debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { @@ -204,50 +236,53 @@ class TxTracker { // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=${metadata.snr}, ' - 'full_path_length=${metadata.pathLength}'); + debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; if (repeaters.containsKey(pathHex)) { final existing = repeaters[pathHex]!; debugLog('[PING] Deduplication: path $pathHex already seen ' - '(existing SNR=${existing.snr}, new SNR=${metadata.snr})'); + '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); - // Keep the best (highest) SNR - if (metadata.snr > existing.snr) { + // Keep the best (highest) SNR — null SNR never replaces non-null + final shouldUpdate = reportedSnr != null && existing.snr != null + ? reportedSnr > existing.snr! + : reportedSnr != null && existing.snr == null; + if (shouldUpdate) { debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' - '${existing.snr} -> ${metadata.snr}'); + '${existing.snr} -> $reportedSnr'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: metadata.snr, - rssi: metadata.rssi, + snr: reportedSnr, + rssi: reportedRssi, seenCount: existing.seenCount + 1, ); } else { debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' - '(existing ${existing.snr} >= new ${metadata.snr})'); + '(existing ${existing.snr} >= new $reportedSnr)'); // Still increment seen count existing.seenCount++; } } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: metadata.snr, - rssi: metadata.rssi, + snr: reportedSnr, + rssi: reportedRssi, seenCount: 1, ); } // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; - final rssi = repeaters[pathHex]!.rssi; + final bestRssi = repeaters[pathHex]!.rssi; debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, rssi, isNewRepeater); + onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); } @@ -269,14 +304,14 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { final String repeaterId; // Hex string - double snr; // Best SNR seen - int rssi; // RSSI value (dBm) + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, this.seenCount = 1, }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index f78c9629..1c95dd2c 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -4,6 +4,7 @@ import '../../utils/debug_logger_io.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; import 'rx_logger.dart'; +import 'trace_tracker.dart'; import 'tx_tracker.dart'; /// Unified RX handler - orchestrates TX echo tracking and passive RX logging @@ -18,6 +19,9 @@ class UnifiedRxHandler { /// Get current validator PacketValidator get validator => _validator; + /// Trace tracker for targeted ping mode (set by PingService when active) + TraceTracker? traceTracker; + /// Channel key for message decryption (injected for TX validation) Uint8List? channelKey; @@ -71,9 +75,22 @@ class UnifiedRxHandler { debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' - 'pathLength=${metadata.pathLength}'); - - // Route to TX tracking if active (during 7s echo window) + 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); + + // Store BLE metadata from 0x88 LogRxData for trace packets. + // The actual trace payload arrives separately via 0x89 TraceData stream. + // We only store RSSI/SNR here — the 0x89 handler combines them. + if (metadata.isTrace) { + final tt = traceTracker; + if (tt != null && tt.isListening) { + debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + tt.pendingBleSnr = metadata.snr; + tt.pendingBleRssi = metadata.rssi; + } + return; // Trace packets don't go to TX tracker or RX logger + } + + // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); final wasEcho = await txTracker.handlePacket(metadata); diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 282d911d..d37cd8db 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -12,6 +12,7 @@ class OfflineSession { final Map data; final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload final bool uploaded; // Track upload status OfflineSession({ @@ -21,6 +22,7 @@ class OfflineSession { required this.data, this.devicePublicKey, this.deviceName, + this.contactUri, this.uploaded = false, }); @@ -33,6 +35,7 @@ class OfflineSession { data: json['data'] as Map, devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, + contactUri: json['contactUri'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -46,6 +49,7 @@ class OfflineSession { 'data': data, 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, + 'contactUri': contactUri, 'uploaded': uploaded, }; } @@ -59,6 +63,7 @@ class OfflineSession { data: data, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, uploaded: uploaded ?? this.uploaded, ); } @@ -78,6 +83,10 @@ class OfflineSessionService { SharedPreferences? _prefs; List _sessions = []; + /// Tracks the filename of the session currently being accumulated via periodic auto-save. + /// When set, `updateCurrentSession()` updates this session in-place instead of creating a new one. + String? _currentSessionFilename; + /// Callback when sessions list changes void Function(List sessions)? onSessionsUpdated; @@ -141,6 +150,7 @@ class OfflineSessionService { List> pings, { String? devicePublicKey, String? deviceName, + String? contactUri, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -167,6 +177,7 @@ class OfflineSessionService { data: sessionData, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -175,6 +186,70 @@ class OfflineSessionService { debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } + /// Update the current in-progress session with the latest pings snapshot. + /// If no current session exists, creates a new one and tracks it. + /// This allows periodic saves to update the same file instead of creating duplicates. + Future updateCurrentSession( + List> pings, { + String? devicePublicKey, + String? deviceName, + String? contactUri, + }) async { + if (pings.isEmpty) { + debugLog('[OFFLINE] No pings to auto-save, skipping'); + return; + } + + // If we have a tracked session, update it in-place + if (_currentSessionFilename != null) { + final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + if (index != -1) { + final existing = _sessions[index]; + final updatedData = Map.from(existing.data); + updatedData['pings'] = pings; + updatedData['ping_count'] = pings.length; + + _sessions[index] = OfflineSession( + filename: existing.filename, + createdAt: existing.createdAt, + pingCount: pings.length, + data: updatedData, + devicePublicKey: devicePublicKey ?? existing.devicePublicKey, + deviceName: deviceName ?? existing.deviceName, + contactUri: contactUri ?? existing.contactUri, + ); + await _saveSessions(); + debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + return; + } + // Session was deleted externally — fall through to create new + debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + _currentSessionFilename = null; + } + + // No current session — create a new one and track it + await saveSession( + pings, + devicePublicKey: devicePublicKey, + deviceName: deviceName, + contactUri: contactUri, + ); + // saveSession inserts at index 0 (newest first) + if (_sessions.isNotEmpty) { + _currentSessionFilename = _sessions.first.filename; + debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + } + } + + /// Clear the current session tracker so the next save creates a fresh session file. + /// Called after final saves (mode switch, disconnect) to create a clean break. + void finalizeCurrentSession() { + if (_currentSessionFilename != null) { + debugLog('[OFFLINE] Finalized session: $_currentSessionFilename'); + _currentSessionFilename = null; + } + } + /// Mark a session as uploaded without deleting it Future markAsUploaded(String filename) async { final index = _sessions.indexWhere((s) => s.filename == filename); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 685cdbea..d7822824 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:geolocator/geolocator.dart'; @@ -12,7 +13,9 @@ import 'countdown_timer_service.dart'; import 'gps_service.dart'; import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; +import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; /// Ping service for TX/RX ping orchestration @@ -22,7 +25,7 @@ import 'wakelock_service.dart'; /// 1. Validate GPS lock, 25m min distance (zone validation handled server-side) /// 2. Start TxTracker to monitor for repeater echoes /// 3. Send @[MapperBot] LAT, LON [POWERw] to #wardriving channel -/// 4. Start 6-second RX listening window (matches JS RX_LOG_LISTEN_WINDOW_MS) +/// 4. Start 5-second RX listening window /// 5. Post to API queue with type "TX" /// /// RX Flow (via TxTracker): @@ -33,21 +36,24 @@ import 'wakelock_service.dart'; /// /// Discovery Flow (Passive Mode only): /// 1. In Passive Mode, send discovery request instead of TX ping -/// 2. Start 7-second listening window via DiscTracker +/// 2. Start 5-second listening window via DiscTracker /// 3. Collect discovery responses (0x8E packets) /// 4. After window ends, create log entry and queue DISC API payloads class PingService { - /// RX listening window duration (7 seconds - matches cooldown duration) - static const Duration _rxListeningWindow = Duration(seconds: 7); - /// Cooldown period between pings (7 seconds - matches JS COOLDOWN_MS = 7000) - static const Duration _autoPingCooldown = Duration(seconds: 7); - /// Discovery listening window duration (7 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// RX listening window duration (5 seconds - matches cooldown duration) + static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) + static const Duration _autoPingCooldown = Duration(seconds: 5); + /// Discovery listening window duration (5 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 5); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); + /// Current configured min ping distance (for validation messages) + static int currentMinDistance = 25; + final GpsService _gpsService; final MeshCoreConnection _connection; final ApiQueueService _apiQueue; @@ -61,6 +67,24 @@ class PingService { final AudioService? _audioService; final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. + int _hopBytes; + + /// Number of bytes for trace path IDs (1, 2, or 4). Uses bitshift encoding, separate from TX. + int _traceHopBytes; + + /// Update hop bytes at runtime (e.g. when user changes path mode while connected) + set hopBytes(int value) => _hopBytes = value; + + /// Update trace hop bytes at runtime (e.g. when user changes trace byte setting) + set traceHopBytes(int value) => _traceHopBytes = value; + + /// When true, skip RSSI carpeater check in DiscTracker (user setting) + bool disableRssiFilter; + + /// Unified RX handler reference for routing trace packets + UnifiedRxHandler? unifiedRxHandler; + PingStats _stats = const PingStats(); DateTime? _lastTxTime; Timer? _rxWindowTimer; @@ -77,9 +101,17 @@ class PingService { bool _autoPingEnabled = false; bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; + bool _targetedModeEnabled = false; bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; + // Targeted mode tracking + TraceTracker? _traceTracker; + StreamSubscription? _traceDataSubscription; + Timer? _targetedTimer; + String? _targetRepeaterId; + Position? _lastTargetedPosition; + // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; @@ -105,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to check if discovery drop is enabled (failed discoveries → API) + bool Function()? getDiscDropEnabled; + /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; @@ -132,6 +167,13 @@ class PingService { /// Parameters: (bool success) - true if any nodes discovered, false if none void Function(bool success)? onDiscoveryWindowComplete; + /// Callback when trace window ends (for noise floor graph + log) + /// Parameters: (TraceResult? result) - null if no response + void Function(TraceResult? result)? onTraceWindowComplete; + + /// Callback when trace ping is sent (for log entry creation) + void Function(TraceLogEntry)? onTracePing; + /// Callback when pingInProgress changes (for immediate UI refresh) void Function()? onPingProgressChanged; @@ -159,6 +201,9 @@ class PingService { TxTracker? txTracker, AudioService? audioService, this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + int hopBytes = 1, + int traceHopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -169,7 +214,9 @@ class PingService { _discoveryWindowCountdown = discoveryWindowTimer, _deviceId = deviceId, _txTracker = txTracker, - _audioService = audioService; + _audioService = audioService, + _hopBytes = hopBytes, + _traceHopBytes = traceHopBytes; /// Get current ping statistics PingStats get stats => _stats; @@ -186,6 +233,9 @@ class PingService { /// Check if Hybrid Mode is active (alternates discovery + TX) bool get isHybridMode => _hybridModeEnabled; + /// Check if Targeted Mode is active (zero-hop trace to specific repeater) + bool get isTargetedMode => _targetedModeEnabled; + /// Check if discovery tracker is currently listening (for Passive Mode UI) bool get isDiscoveryListening => _discTracker?.isListening ?? false; @@ -265,7 +315,7 @@ class PingService { return PingValidation.tooCloseToLastPing; } - // Check cooldown (7 seconds between pings) + // Check cooldown (5 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -374,7 +424,7 @@ class PingService { // NOTE: Skip distance check (tooCloseToLastPing) intentionally // Auto mode handles this by setting skipReason='too close' and scheduling next ping - // Check cooldown (7 seconds between pings) + // Check cooldown (5 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -447,7 +497,7 @@ class PingService { return false; } } else { - // Auto ping: 7-second cooldown, 25m distance check + // Auto ping: 5-second cooldown, 25m distance check // This fixes a race condition where disabling Active Mode during cooldown // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { @@ -582,7 +632,7 @@ class PingService { // Manual ping: 15-second cooldown, no distance check _manualPingCooldownTimer.start(_manualPingCooldown.inMilliseconds); } else { - // Auto ping: 7-second cooldown + // Auto ping: 5-second cooldown _cooldownTimer.start(_autoPingCooldown.inMilliseconds); } @@ -610,14 +660,13 @@ class PingService { } } - /// Start the 6-second RX listening window after TX + /// Start the 5-second RX listening window after TX /// Note: TxTracker handles the actual echo tracking, we just manage the countdown UI - /// Reference: RX_LOG_LISTEN_WINDOW_MS = 6000 in wardrive.js void _startRxListeningWindow(Position txPosition) { // Cancel previous timer _rxWindowTimer?.cancel(); - // Start RX window countdown display (6 seconds - matches JS RX_LOG_LISTEN_WINDOW_MS) + // Start RX window countdown display (5 seconds) _rxWindowCountdown.start(_rxListeningWindow.inMilliseconds); // Set timer for window end @@ -647,8 +696,10 @@ class PingService { for (final entry in txTracker.repeaters.entries) { final repeaterId = entry.key; final echo = entry.value; - // Format SNR with 2 decimal places - repeaterStrings.add('$repeaterId(${echo.snr.toStringAsFixed(2)})'); + // Format SNR with 2 decimal places, or "null" for CARpeater pass-through + repeaterStrings.add(echo.snr != null + ? '$repeaterId(${echo.snr!.toStringAsFixed(2)})' + : '$repeaterId(null)'); debugLog('[PING] Heard repeater: $repeaterId, SNR=${echo.snr}'); } heardRepeats = repeaterStrings.join(','); @@ -691,9 +742,11 @@ class PingService { debugLog('[PING] Executing pending disable after RX window'); _pendingDisable = false; final wasHybrid = _hybridModeEnabled; + final wasTargeted = _targetedModeEnabled; _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _autoTimer?.cancel(); _autoTimer = null; @@ -701,6 +754,10 @@ class PingService { if (wasHybrid) { _stopDiscoveryMode(); } + // Clean up targeted infrastructure if targeted was enabled + if (wasTargeted) { + _stopTargetedMode(); + } // Start cooldown immediately _cooldownTimer.start(_autoPingCooldown.inMilliseconds); debugLog('[PING] Pending disable complete, cooldown started'); @@ -793,20 +850,34 @@ class PingService { } } - /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), or Hybrid Mode + /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), + /// Hybrid Mode, or Targeted Mode (zero-hop trace) /// Reference: startAutoPing() in wardrive.js /// @param passiveMode - If true, only listens for RX (no TX pings) - this is Passive Mode /// @param hybridMode - If true, alternates discovery + TX pings each interval - Future enableAutoPing({bool passiveMode = false, bool hybridMode = false}) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode)'); + /// @param targetedMode - If true, sends trace path to specific repeater + /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) + Future enableAutoPing({ + bool passiveMode = false, + bool hybridMode = false, + bool targetedMode = false, + String? targetRepeaterId, + }) async { + debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); return false; } + // Targeted mode requires a repeater ID + if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + debugLog('[AUTO] Targeted mode requires a repeater ID'); + return false; + } + // Check if we're in cooldown (can't start during cooldown) - // Hybrid and Active modes are blocked by cooldown, Passive is not + // Hybrid, Active, and Targeted modes are blocked by cooldown, Passive is not // Reference: isInCooldown() check in startAutoPing() in wardrive.js if (!passiveMode && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); @@ -824,14 +895,23 @@ class PingService { _autoPingEnabled = true; _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; + _targetedModeEnabled = targetedMode; _nextPingIsDiscovery = true; // Always start hybrid with discovery + if (targetedMode) { + _targetRepeaterId = targetRepeaterId; + } + // Enable wake lock to keep screen on during auto mode // Reference: acquireWakeLock() in wardrive.js debugLog('[AUTO] Acquiring wake lock for auto mode'); await _wakelockService.enable(); - if (hybridMode) { + if (targetedMode) { + // Targeted Mode: send trace path to specific repeater + debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + await _startTargetedMode(); + } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); @@ -884,14 +964,20 @@ class PingService { // Clear skip reason _skipReason = null; - // Clean up discovery infrastructure if hybrid was enabled - if (_hybridModeEnabled) { + // Clean up discovery infrastructure if passive or hybrid was enabled + if (_passiveModeEnabled || _hybridModeEnabled) { _stopDiscoveryMode(); } + // Clean up targeted mode infrastructure + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; // Disable wake lock when auto mode stops @@ -912,8 +998,10 @@ class PingService { _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _stopDiscoveryMode(); + _stopTargetedMode(); await _wakelockService.disable(); } @@ -932,7 +1020,11 @@ class PingService { debugLog('[DISC] Starting discovery mode'); // Create and configure discovery tracker - final tracker = DiscTracker(shouldIgnoreRepeater: shouldIgnoreRepeater); + final tracker = DiscTracker( + shouldIgnoreRepeater: shouldIgnoreRepeater, + disableRssiFilter: disableRssiFilter, + hopBytes: _hopBytes, + ); _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { @@ -1012,8 +1104,8 @@ class PingService { position.latitude, position.longitude, ); - if (distance < GpsService.minDistanceMeters) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < 25m), skipping'); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1064,7 +1156,7 @@ class PingService { windowDuration: _discoveryListeningWindow, ); - // Start discovery window countdown display (7 seconds) + // Start discovery window countdown display (5 seconds) _discoveryWindowCountdown.start(_discoveryListeningWindow.inMilliseconds); // Clear pingInProgress now that discovery window is active @@ -1121,6 +1213,18 @@ class PingService { onStatsUpdated?.call(_stats); } else { debugLog('[DISC] No nodes discovered'); + + // Queue failed discovery to API if disc drop is enabled + if (getDiscDropEnabled?.call() == true) { + _apiQueue.enqueueDiscDrop( + latitude: position.latitude, + longitude: position.longitude, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + debugLog('[DISC] Discovery drop queued (no response)'); + } } // Entry already added to log via onDiscPing - no need to fire onDiscoveryComplete @@ -1171,8 +1275,8 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 7000 = 8000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 7000 + // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. + final listenMs = _rxListeningWindow.inMilliseconds; // 5000 final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1199,6 +1303,219 @@ class PingService { }); } + // ============================================ + // Targeted Mode (Zero-Hop Trace) + // ============================================ + + /// Start targeted mode - subscribes to trace data and sends first trace + Future _startTargetedMode() async { + debugLog('[TRACE] Starting targeted mode for repeater $_targetRepeaterId'); + + // Create trace tracker + final tracker = TraceTracker(); + _traceTracker = tracker; + tracker.onTraceReceived = (result) { + debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + }; + tracker.onWindowComplete = (result) { + debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + _handleTraceWindowComplete(result); + }; + + // Wire trace tracker into UnifiedRxHandler so 0x88 BLE metadata + // gets stored for the 0x89 handler + unifiedRxHandler?.traceTracker = tracker; + + // Subscribe to 0x89 TraceData stream for actual trace payloads + _traceDataSubscription = _connection.traceDataStream.listen((raw) { + final tt = _traceTracker; + if (tt != null && tt.isListening) { + tt.handlePacket(raw, tt.pendingBleSnr, tt.pendingBleRssi); + } + }); + + // Send first trace immediately + await _sendTargetedPing(); + } + + /// Stop targeted mode - cleans up tracker and subscription + void _stopTargetedMode() { + debugLog('[TRACE] Stopping targeted mode'); + _targetedTimer?.cancel(); + _targetedTimer = null; + _traceDataSubscription?.cancel(); + _traceDataSubscription = null; + unifiedRxHandler?.traceTracker = null; + _traceTracker?.dispose(); + _traceTracker = null; + _lastTargetedPosition = null; + } + + /// Send a targeted ping (trace path) and start listening window + Future _sendTargetedPing() async { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, skipping trace'); + return; + } + + final targetId = _targetRepeaterId; + if (targetId == null || targetId.isEmpty) { + debugLog('[TRACE] No target repeater ID, skipping trace'); + _scheduleNextTargetedPing(); + return; + } + + // Check GPS + final position = _gpsService.lastPosition; + if (position == null) { + debugLog('[TRACE] No GPS position, skipping trace'); + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + + // Check minimum distance from last trace (25m) + final lastPos = _lastTargetedPosition; + if (lastPos != null) { + final distance = Geolocator.distanceBetween( + lastPos.latitude, lastPos.longitude, + position.latitude, position.longitude, + ); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + _skipReason = 'too close'; + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + } + + // Clear skip reason since we're proceeding + _skipReason = null; + + // Signal "Sending..." to UI + _pingInProgress = true; + onPingProgressChanged?.call(); + + // Capture noise floor + final noiseFloor = _connection.lastNoiseFloor; + _pendingTxNoiseFloor = noiseFloor; + + // Create trace log entry immediately + final traceEntry = TraceLogEntry( + timestamp: DateTime.now(), + latitude: position.latitude, + longitude: position.longitude, + targetRepeaterId: targetId, + noiseFloor: noiseFloor, + success: false, // Will be updated after window completes + ); + onTracePing?.call(traceEntry); + + debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + + try { + // Play transmit sound + _audioService?.playTransmitSound(); + + // Convert hex repeater ID to bytes (trace uses separate byte size: 1, 2, or 4) + final traceBytes = _traceHopBytes; + final repeaterIdBytes = Uint8List(traceBytes); + for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { + repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + } + + // Send trace path and get tag + final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); + + // Start tracking with the tag + _traceTracker?.startTracking( + tag: tag, + targetRepeaterId: targetId, + windowDuration: _rxListeningWindow, + ); + + // Start listening window countdown display + _discoveryWindowCountdown.start(_rxListeningWindow.inMilliseconds); + + // Clear pingInProgress now that trace window is active + _pingInProgress = false; + + // Update last targeted position for 25m check + _lastTargetedPosition = position; + + } catch (e) { + _pingInProgress = false; + debugError('[TRACE] Failed to send trace: $e'); + _scheduleNextTargetedPing(); + } + } + + /// Handle trace window completion + void _handleTraceWindowComplete(TraceResult? result) { + _discoveryWindowCountdown.stop(); + final position = _lastTargetedPosition; + final targetId = _targetRepeaterId ?? ''; + + if (result != null && result.success && position != null) { + debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + + // Queue to API (only successful traces) + _apiQueue.enqueueTrace( + latitude: position.latitude, + longitude: position.longitude, + repeaterId: targetId, + localSnr: result.localSnr, + localRssi: result.localRssi, + remoteSnr: result.remoteSnr, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + + // Update stats + _stats = _stats.copyWith(traceCount: _stats.traceCount + 1); + onStatsUpdated?.call(_stats); + + // Play receive sound for successful trace + _audioService?.playReceiveSound(); + } else { + debugLog('[TRACE] Trace failed: no response from $targetId'); + // Failed traces are NOT posted to API (local visual only) + } + + // Notify for noise floor graph and log updates + onTraceWindowComplete?.call(result); + + _scheduleNextTargetedPing(); + } + + /// Schedule next targeted ping after interval + void _scheduleNextTargetedPing() { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, not scheduling next trace'); + return; + } + + _targetedTimer?.cancel(); + _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { + debugLog('[TRACE] Targeted ping timer fired'); + if (_autoPingEnabled && _targetedModeEnabled) { + if (_pingInProgress) { + debugLog('[TRACE] Ping already in progress, skipping'); + return; + } + _skipReason = null; + _sendTargetedPing(); + } + }); + + // Notify callback for countdown display + onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + + debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + } + /// Stop any active TX echo tracking window /// Called when disabling auto mode to prevent late timer callbacks from /// triggering pings during cooldown (race condition fix) @@ -1221,6 +1538,7 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; _stopDiscoveryMode(); + _stopTargetedMode(); _wakelockService.dispose(); } } @@ -1255,7 +1573,7 @@ enum PingValidation { /// Too close to last ping (< 25m) tooCloseToLastPing, - /// Cooldown period active (< 7s since last ping) + /// Cooldown period active (< 5s since last ping) cooldownActive, /// Manual ping cooldown period active (< 15s since last manual ping) @@ -1285,9 +1603,9 @@ extension PingValidationExtension on PingValidation { case PingValidation.outsideGeofence: return 'Outside service area'; case PingValidation.tooCloseToLastPing: - return 'Move 25m before next ping'; + return 'Move ${PingService.currentMinDistance}m before next ping'; case PingValidation.cooldownActive: - return 'Wait 7 seconds between pings'; + return 'Wait 5 seconds between pings'; case PingValidation.manualCooldownActive: return 'Wait 15 seconds between manual pings'; case PingValidation.txNotAllowed: diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index b7e96ba3..4dc5a98d 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -20,8 +20,8 @@ class DebugLogger { if (_initialized) return; _initialized = true; - // On mobile, enable debug logging in debug mode - _debugEnabled = kDebugMode; + // Enable debug logging by default on all builds + _debugEnabled = true; if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 82dab78d..7d2e2ef5 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -80,6 +80,18 @@ extension MapStyleExtension on MapStyle { return null; // ArcGIS doesn't use subdomains } } + + /// Whether this style supports retina tiles via {r} placeholder + bool get supportsRetina { + switch (this) { + case MapStyle.dark: + return true; // Carto supports @2x via {r} + case MapStyle.light: + return false; // OSM has no retina support + case MapStyle.satellite: + return false; // ArcGIS has no retina support + } + } } /// Custom tile provider that silently handles HTTP errors (404, 503, etc.) @@ -557,11 +569,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map _buildMap(appState, center), - // GPS Info overlay (top-left, respects dynamic island in landscape) + // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( top: topPadding, left: leftPadding, - child: _buildGpsInfoOverlay(appState), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], + ], + ), ), // Map controls - top-right in both orientations, collapsible @@ -616,7 +637,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { initialCenter: center, initialZoom: _defaultZoom, minZoom: 3, - maxZoom: 18, + maxZoom: 17, interactionOptions: InteractionOptions( flags: _rotationLocked ? InteractiveFlag.all & ~InteractiveFlag.rotate @@ -639,9 +660,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { urlTemplate: mapStyle.urlTemplate, subdomains: mapStyle.subdomains ?? const [], userAgentPackageName: 'com.meshmapper.app', - maxZoom: 19, - retinaMode: RetinaMode.isHighDensity(context), // Enable high-res tiles on retina displays - tileProvider: SilentCancellableNetworkTileProvider(), // Silently handles tile errors + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), ); }, ), @@ -649,9 +670,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // MeshMapper coverage overlay (only when zone code available and overlay enabled) if (appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}', + urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', - maxZoom: 19, + minZoom: 3, + maxZoom: 17, + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, // Keep old tile visible until new one loads + ), tileProvider: SilentCancellableNetworkTileProvider(), ), @@ -667,12 +692,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // DISC markers (purple circles for discovery observations) MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries), + markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), + ), + + // Trace markers (cyan/red circles for targeted ping results) + MarkerLayer( + markers: _buildTraceMarkers(appState.traceLogEntries), ), - // Repeater markers (magenta circles with ID) + // Repeater markers (magenta with ID, rotate with map) MarkerLayer( - markers: _buildRepeaterMarkers(appState.repeaters), + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), ), // Current position marker (car icon) @@ -695,7 +729,128 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Color for the overlay ping-type dot + static Color _overlayTypeColor(OverlayPingType type) { + return switch (type) { + OverlayPingType.tx => Colors.green, + OverlayPingType.disc => Colors.purple, + OverlayPingType.trace => Colors.cyan, + OverlayPingType.rx => Colors.blue, + }; + } + + /// Build a single overlay table row with colored dot, repeater ID, and SNR + TableRow _overlayRow(String repeaterId, double snr, Color dotColor) { + return TableRow( + children: [ + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + snr.toStringAsFixed(1), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(snr), + ), + ), + ), + ], + ); + } + /// GPS info overlay (top-left corner) + /// Top heard repeaters overlay (bottom-right of map) + Widget _buildTopRepeatersOverlay(AppStateProvider appState) { + final topRepeaters = appState.topRepeatersBySnr; + final rxSlot = appState.rxOverlaySlot; + final isEmpty = topRepeaters.isEmpty && rxSlot == null; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Top Heard', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Colors.white54, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + if (isEmpty) + const Text( + '---', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.white38, + ), + ), + if (!isEmpty) + Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: const { + 0: IntrinsicColumnWidth(), // dot + 1: IntrinsicColumnWidth(), // ID + 2: FixedColumnWidth(8), // spacer + 3: IntrinsicColumnWidth(), // SNR + }, + children: [ + for (final r in topRepeaters) + _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), + if (rxSlot != null) + _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + ], + ), + ], + ), + ); + } + + /// SNR color: green > 5, orange -1..5, red <= -1 + static Color _snrColor(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; final hasGps = position != null; @@ -1100,12 +1255,26 @@ class _MapWidgetState extends State with TickerProviderStateMixin { description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: Colors.cyan, + label: 'TRC', + description: 'Location where a trace reached the repeater', + ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, color: Colors.grey, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: Colors.grey, + label: 'TRC', + description: 'Location where a trace got no response', + ), ], ), ), @@ -1550,7 +1719,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } - List _buildDiscMarkers(List entries) { + List _buildDiscMarkers(List entries, bool discDropEnabled) { return entries.map((entry) { return Marker( point: LatLng(entry.latitude, entry.longitude), @@ -1560,7 +1729,36 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showDiscPingDetails(entry), child: Container( decoration: BoxDecoration( - color: entry.nodeCount == 0 ? Colors.grey : _discMarkerColor, + color: entry.nodeCount == 0 + ? (discDropEnabled ? Colors.red : Colors.grey) + : _discMarkerColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + ), + ); + }).toList(); + } + + List _buildTraceMarkers(List entries) { + return entries.map((entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: Container( + decoration: BoxDecoration( + color: entry.success ? Colors.cyan : Colors.grey, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ @@ -1577,6 +1775,257 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } + void _showTraceDetails(TraceLogEntry entry) { + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.cyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Target repeater section header + Text( + entry.success + ? 'Target Repeater' + : 'No response from target repeater', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (entry.success) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + Builder(builder: (context) { + final localSnr = entry.localSnr ?? 0; + final localRssi = entry.localRssi ?? 0; + final remoteSnr = entry.remoteSnr ?? 0; + + Color rxSnrColor; + if (localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (localRssi >= -70) { + rssiColor = Colors.green; + } else if (localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RX RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '$localRssi', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + /// DISC marker color (#7B68EE - medium slate blue/purple) static const Color _discMarkerColor = Color(0xFF7B68EE); @@ -1616,23 +2065,39 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers(List repeaters) { + List _buildRepeaterMarkers(List repeaters, int? regionHopBytesOverride) { final duplicateIds = _getDuplicateRepeaterIds(repeaters); return repeaters.map((repeater) { final isDuplicate = duplicateIds.contains(repeater.id); final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + // Display hex ID based on per-repeater hop_bytes (or regional admin override) + final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; + final isLongId = displayId.length > 2; + final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; + + // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded + final borderRadius = effectiveBytes >= 3 + ? BorderRadius.circular(8) + : effectiveBytes == 2 + ? BorderRadius.circular(6) + : BorderRadius.circular(4); + return Marker( point: LatLng(repeater.lat, repeater.lon), - width: 28, + width: markerWidth, height: 28, child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate), + onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), child: Container( + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 4) + : EdgeInsets.zero, decoration: BoxDecoration( color: markerColor, - shape: BoxShape.circle, + borderRadius: borderRadius, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ BoxShadow( @@ -1644,11 +2109,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), alignment: Alignment.center, child: Text( - repeater.id, - style: const TextStyle( - fontSize: 10, + displayId, + style: TextStyle( + fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, fontWeight: FontWeight.bold, color: Colors.white, + fontFamily: 'monospace', ), ), ), @@ -1672,6 +2138,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Compute node column width based on hop byte count. + /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). + double _nodeColumnWidth({double extraPadding = 0}) { + final appState = context.read(); + final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + switch (hopBytes) { + case 2: + return 70 + extraPadding; + case 3: + return 80 + extraPadding; + default: + return 60 + extraPadding; + } + } + /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing @@ -1680,209 +2161,221 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Icon badge + // Header with icon badge + Row( + children: [ + // Icon badge + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TX Ping', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(ping.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(ping.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + const SizedBox(height: 16), + + // Repeaters section header + Text( + heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, ), - ], - ), - ), - const SizedBox(height: 16), - - // Repeaters section header - Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // Repeaters table - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + + if (heardRepeaters.isNotEmpty) ...[ + const SizedBox(height: 12), + // Repeaters table + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: 60), - // SNR + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), Expanded( - child: Center( - child: _buildStatChip( - value: repeater.snr.toStringAsFixed(1), - color: snrColor, + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${repeater.rssi}', - color: rssiColor, + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), - ); - }), - ], - ), - ), - ], - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...heardRepeaters.map((repeater) { + // Calculate SNR chip color + Color snrColor; + if (repeater.snr == null) { + snrColor = Colors.grey; + } else if (repeater.snr! <= -1) { + snrColor = Colors.red; + } else if (repeater.snr! <= 5) { + snrColor = Colors.orange; + } else { + snrColor = Colors.green; + } + + // Calculate RSSI chip color + Color rssiColor; + if (repeater.rssi == null) { + rssiColor = Colors.grey; + } else if (repeater.rssi! >= -70) { + rssiColor = Colors.green; + } else if (repeater.rssi! >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + // SNR + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null ? '${repeater.rssi}' : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), ), ), ); @@ -2021,7 +2514,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: 60, + width: _nodeColumnWidth(), child: Text( 'Node', style: TextStyle( @@ -2065,7 +2558,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: 60), + RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -2102,254 +2595,262 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Icon badge + // Header with icon badge + Row( + children: [ + // Icon badge + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _discMarkerColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Disc Request', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: _discMarkerColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(entry.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + const SizedBox(height: 16), + + // Discovered nodes section header + Text( + entry.discoveredNodes.isEmpty + ? 'No nodes discovered' + : 'Discovered Nodes (${entry.discoveredNodes.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, ), - ], - ), - ), - const SizedBox(height: 16), - - // Discovered nodes section header - Text( - entry.discoveredNodes.isEmpty - ? 'No nodes discovered' - : 'Discovered Nodes (${entry.discoveredNodes.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (entry.discoveredNodes.isNotEmpty) ...[ - const SizedBox(height: 12), - // Table with headers - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + + if (entry.discoveredNodes.isNotEmpty) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Node ID with type SizedBox( - width: 60, - child: Row( - children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), - Text( - node.nodeTypeLabel, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _discMarkerColor, - ), - ), - ], + width: _nodeColumnWidth(extraPadding: 20), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), - // RX SNR Expanded( - child: Center( - child: _buildStatChip( - value: node.localSnr.toStringAsFixed(1), - color: rxSnrColor, + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${node.localRssi}', - color: rssiColor, + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // TX SNR Expanded( - child: Center( - child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), - color: txSnrColor, + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), - ); - }), - ], - ), - ), - ], - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.discoveredNodes.map((node) { + // Calculate colors + Color rxSnrColor; + if (node.localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (node.localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (node.localRssi >= -70) { + rssiColor = Colors.green; + } else if (node.localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (node.remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (node.remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Node ID with type + SizedBox( + width: _nodeColumnWidth(extraPadding: 20), + child: Row( + children: [ + RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + Text( + node.nodeTypeLabel, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _discMarkerColor, + ), + ), + ], + ), + ), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: node.localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${node.localRssi}', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: node.remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), ), ), ); @@ -2376,7 +2877,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false}) { + void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -2410,25 +2911,33 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Header with icon badge (containing ID) and name Row( children: [ - // Icon badge with ID (mirrors map marker) - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text( - repeater.id, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, + // Icon badge with hex ID (mirrors map marker) + Builder(builder: (context) { + final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final isLongId = displayId.length > 2; + return Container( + constraints: const BoxConstraints(minWidth: 44), + height: 44, + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: iconColor, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white, width: 2), ), - ), - ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: isLongId ? 13 : 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + }), const SizedBox(width: 12), Expanded( child: Text( diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 31ba9332..aa760b46 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -221,6 +221,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Failed', }; final eventDescription = switch (marker.type) { @@ -229,6 +231,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', + PingEventType.traceSuccess => 'Trace got response from target', + PingEventType.traceFail => 'Trace got no response from target', }; final hasLocation = marker.latitude != null && marker.longitude != null; @@ -848,7 +852,8 @@ class InteractiveNoiseFloorChartState extends State _legendItem(context, Colors.red, 'TX Fail'), _legendItem(context, Colors.blue, 'RX'), _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.grey, 'DISC Fail'), + _legendItem(context, Colors.cyan, 'Trace Success'), + _legendItem(context, Colors.grey, 'No Response'), ], ); } diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart new file mode 100644 index 00000000..b54aec58 --- /dev/null +++ b/lib/widgets/offline_mode_toggle.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; + +/// Shared offline mode toggle widget +/// Compact button style — icon + label, tappable to toggle with confirmation +class OfflineModeToggle extends StatelessWidget { + const OfflineModeToggle({super.key}); + + /// Handle offline mode toggle with progress dialog when connected + static Future handleOfflineModeToggle( + BuildContext context, + AppStateProvider appState, + bool currentOfflineMode, + bool isConnected, + ) async { + final newMode = !currentOfflineMode; + + // Always show confirmation dialog + final confirmed = await _showConfirmDialog(context, newMode); + if (confirmed != true || !context.mounted) return; + + // If connected, show progress dialog during mode switch + if (isConnected) { + final statusText = newMode + ? 'Switching to offline mode...' + : 'Switching to online mode...'; + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + + // Perform the mode switch + final result = await appState.setOfflineMode(newMode); + + // Close the progress dialog (check if context is still valid) + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show error dialog if switch failed + if (!result.success && context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Mode Switch Failed'), + content: Text( + result.error ?? 'An unknown error occurred', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } else { + // Not connected - simple toggle without dialog + await appState.setOfflineMode(newMode); + } + } + + /// Show confirmation dialog explaining what the mode does + static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { + final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; + final iconColor = switchingToOffline ? Colors.orange : Colors.green; + final description = switchingToOffline + ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' + : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' + 'This requires an active internet connection.'; + final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 8), + Flexible(child: Text(title)), + ], + ), + content: Text( + description, + style: const TextStyle(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: switchingToOffline + ? FilledButton.styleFrom(backgroundColor: Colors.orange) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final appState = context.watch(); + final offlineMode = appState.offlineMode; + final isConnected = appState.isConnected; + + final color = offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : (isDark ? Colors.green.shade400 : Colors.green.shade700); + final bgColor = offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15); + final borderColor = offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : Colors.green.withValues(alpha: 0.4); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: color, + ), + const SizedBox(width: 8), + Text( + offlineMode ? 'Go Online' : 'Go Offline', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index ce7c6709..805417de 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; +import 'repeater_picker_sheet.dart'; /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -22,6 +23,7 @@ class PingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode @@ -34,6 +36,7 @@ class PingControls extends StatelessWidget { final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -108,7 +111,7 @@ class PingControls extends StatelessWidget { ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow onPressed: () => _sendPing(context, appState), @@ -145,7 +148,7 @@ class PingControls extends StatelessWidget { : rxWindowActive ? 'Listening ${rxWindowRemaining}s' // TX RX window : autoPingWaiting - ? 'Next ping ${autoPingRemaining}s' + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') : rxWindowActive ? 'Cooldown ${rxWindowRemaining}s' @@ -157,7 +160,7 @@ class PingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), showCooldown: false, @@ -179,7 +182,7 @@ class PingControls extends StatelessWidget { ? (discoveryWindowActive ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? 'Next Disc ${autoPingRemaining}s' // Waiting for next discovery + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping @@ -191,7 +194,7 @@ class PingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases @@ -224,15 +227,11 @@ class PingControls extends StatelessWidget { else const SizedBox(height: 8), - // Offline Mode and Sound toggles row - const Row( - children: [ - // Offline Mode toggle (expanded) - Expanded(child: _OfflineModeToggle()), - SizedBox(width: 8), - // Sound toggle (compact, right side) - _SoundToggle(), - ], + // Targeted Ping controls + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, ), ], ); @@ -445,370 +444,245 @@ class _ActionButtonState extends State<_ActionButton> } } -/// Compact sound notification toggle - matches height of _OfflineModeToggle -class _SoundToggle extends StatelessWidget { - const _SoundToggle(); +/// Targeted Ping controls - hex text field + start/stop button +class _TargetedPingSection extends StatefulWidget { + final bool isAnyModeRunning; + final bool cooldownActive; + final int cooldownRemaining; + final bool compact; + + const _TargetedPingSection({ + required this.isAnyModeRunning, + required this.cooldownActive, + required this.cooldownRemaining, + this.compact = false, + }); @override - Widget build(BuildContext context) { - final appState = context.watch(); - final soundEnabled = appState.isSoundEnabled; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => appState.toggleSoundEnabled(), - borderRadius: BorderRadius.circular(10), - child: Container( - // Match _OfflineModeToggle: padding 10v + icon container (6+18+6) + text adds more height - // _OfflineModeToggle content: icon 30px, text column ~34px, toggle 26px - // Use same padding and let IntrinsicHeight from Row handle matching - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - soundEnabled ? Icons.volume_up : Icons.volume_off, - size: 18, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - // Add text column to match offline mode toggle height - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sound', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: soundEnabled - ? (isDark ? Colors.blue.shade300 : Colors.blue.shade800) - : colorScheme.onSurface, - ), - ), - Text( - soundEnabled ? 'On' : 'Off', - style: TextStyle( - fontSize: 11, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade600) - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } + State<_TargetedPingSection> createState() => _TargetedPingSectionState(); } -/// Compact offline mode toggle matching app design language -class _OfflineModeToggle extends StatelessWidget { - const _OfflineModeToggle(); - - // Offline mode is now enabled - static const bool _isEnabled = true; - - /// Handle offline mode toggle with progress dialog when connected - static Future _handleOfflineModeToggle( - BuildContext context, - AppStateProvider appState, - bool currentOfflineMode, - bool isConnected, - ) async { - final newMode = !currentOfflineMode; - - // If connected, show progress dialog during mode switch - if (isConnected) { - final statusText = newMode - ? 'Switching to offline mode...' - : 'Switching to online mode...'; - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ); - - // Perform the mode switch - final result = await appState.setOfflineMode(newMode); +class _TargetedPingSectionState extends State<_TargetedPingSection> { + final _controller = TextEditingController(); + bool _isStarting = false; - // Close the progress dialog (check if context is still valid) - if (context.mounted) { - Navigator.of(context).pop(); + @override + void initState() { + super.initState(); + // Restore any previously set target ID + WidgetsBinding.instance.addPostFrameCallback((_) { + final appState = context.read(); + final existing = appState.targetRepeaterId; + if (existing != null && existing.isNotEmpty && _controller.text != existing) { + _controller.text = existing; } + }); + } - // Show error dialog if switch failed - if (!result.success && context.mounted) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Mode Switch Failed'), - content: Text( - result.error ?? 'An unknown error occurred', - style: const TextStyle(fontSize: 14), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } else { - // Not connected - simple toggle without dialog - await appState.setOfflineMode(newMode); - } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showRepeaterPicker() async { + final appState = context.read(); + final repeater = await showRepeaterPicker(context); + if (repeater == null || !mounted) return; + + final maxLen = appState.traceHopBytes * 2; + final trimmed = repeater.hexId.length >= maxLen + ? repeater.hexId.substring(0, maxLen).toUpperCase() + : repeater.hexId.toUpperCase(); + _controller.text = trimmed; + appState.setTargetRepeaterId(trimmed); + setState(() {}); } @override Widget build(BuildContext context) { + final appState = context.watch(); + final isTargetedRunning = appState.isTargetedModeRunning; + final maxLen = appState.traceHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - // When disabled, always show as "off" state - if (!_isEnabled) { - return Opacity( - opacity: 0.5, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.cloud_queue, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and "Coming soon" - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - 'Coming soon', - style: TextStyle( - fontSize: 11, - fontStyle: FontStyle.italic, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // Toggle indicator (always off) - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1), // slate-600/300 - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); + + // Sync controller when provider clears target (e.g. trace bytes changed) + if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { + _controller.clear(); } - // Original implementation when enabled - final appState = context.watch(); - final offlineMode = appState.offlineMode; - final offlinePingCount = appState.offlinePingCount; - final isConnected = appState.isConnected; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _handleOfflineModeToggle(context, appState, offlineMode, isConnected), + // Determine if the start button should be enabled + final hexText = _controller.text.trim(); + final isValidHex = hexText.isNotEmpty && + hexText.length == maxLen && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); + final canStart = isValidHex && + !widget.isAnyModeRunning && + !isTargetedRunning && + !widget.cooldownActive && + appState.isConnected; + + // Status text for when targeted mode is running + String? statusText; + if (isTargetedRunning) { + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + + if (discoveryWindowActive) { + statusText = 'Listening ${discoveryRemaining}s'; + } else if (autoPingWaiting) { + statusText = appState.autoPingTimer.skipReason != null + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s'; + } + } + + final isEnabled = (canStart || isTargetedRunning) && !_isStarting; + final buttonColor = (isTargetedRunning || _isStarting) + ? const Color(0xFF22C55E) // green-500 when running/starting + : Colors.cyan; + final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + + return Container( + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: offlineMode - ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) - : colorScheme.onSurface, - ), - ), - if (offlineMode && offlinePingCount > 0) - Text( - '$offlinePingCount pings saved locally', - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, - ), - ) - else - Text( - offlineMode - ? 'Data saved locally' - : 'Uploads immediately', + border: Border.all( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + width: isTargetedRunning ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Targeted button + Expanded( + child: GestureDetector( + onTap: isEnabled + ? () async { + HapticFeedback.lightImpact(); + if (!isTargetedRunning) { + setState(() => _isStarting = true); + appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + } + await appState.toggleAutoPing(AutoMode.targeted); + if (mounted) setState(() => _isStarting = false); + } + : null, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Icon( + Icons.route, + size: 18, + color: effectiveColor, + ), + if (!widget.compact) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, + fontSize: 13, + fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, + color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), + overflow: TextOverflow.ellipsis, ), + ), ], - ), + ], ), - // Toggle indicator - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: offlineMode - ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) - : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), // slate-600/300 + ), + ), + const SizedBox(width: 8), + // Hex text field + SizedBox( + width: 80, + child: TextField( + controller: _controller, + enabled: !isTargetedRunning, + maxLength: maxLen, + textCapitalization: TextCapitalization.characters, + style: TextStyle( + fontSize: 14, + fontFamily: 'monospace', + color: isTargetedRunning + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintStyle: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: offlineMode - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), + counterText: '', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), ), - ], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), + _UpperCaseTextFormatter(), + ], + onChanged: (value) { + appState.setTargetRepeaterId(value.trim().toUpperCase()); + setState(() {}); + }, + ), ), - ), + const SizedBox(width: 6), + // Choose repeater button + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: Icon( + Icons.list, + size: 18, + color: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + onPressed: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? _showRepeaterPicker + : null, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + tooltip: 'Choose repeater', + ), + ), + ], ), ); } } +/// Text formatter that converts input to uppercase +class _UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return TextEditingValue( + text: newValue.text.toUpperCase(), + selection: newValue.selection, + ); + } +} + /// Compact ping controls for minimized panel view /// Shows 3 small horizontal pill buttons in a row /// Active button expands to show context (e.g., "Listening 5s") @@ -820,7 +694,7 @@ class CompactPingControls extends StatefulWidget { } /// Tracks which button should stay expanded during cooldown -enum _LastActiveButton { none, sendPing, activeMode, passiveMode } +enum _LastActiveButton { none, sendPing, activeMode, passiveMode, targeted } class _CompactPingControlsState extends State { // Static so it persists across widget rebuilds (e.g., expand/minimize panel) @@ -837,6 +711,7 @@ class _CompactPingControlsState extends State { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -849,6 +724,7 @@ class _CompactPingControlsState extends State { final isPingInProgress = appState.isPingInProgress; final autoPingWaiting = appState.autoPingTimer.isRunning; final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -873,9 +749,11 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.activeMode; } else if (passiveModeCurrentlyActive) { _lastActiveButton = _LastActiveButton.passiveMode; + } else if (isTargetedRunning) { + _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive) { + if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } @@ -890,25 +768,36 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet); final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; + // Trace Mode (only relevant when a repeater ID has been entered) + final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final targetedCurrentlyActive = isTargetedRunning; + final traceModeExpanded = targetedCurrentlyActive || + (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); + final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && + !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && + !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeActive = isTargetedRunning; + final traceModeShowColor = traceModeEnabled || traceModeActive; + // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded; + final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor; + final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -953,6 +842,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: activeModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, discoveryWindowActive: discoveryWindowActive, discoveryWindowRemaining: discoveryWindowRemaining, ), @@ -986,6 +876,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, ), color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 @@ -1003,6 +894,40 @@ class _CompactPingControlsState extends State { onPressed: () => _toggleRxAuto(context, appState), ); + // Build trace mode button (only used when hasTargetRepeaterId) + final traceModeButton = _CompactActionButton( + icon: Icons.route, + label: _getTraceModeLabel( + isTargetedRunning: isTargetedRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: traceModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isTargetedRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF06B6D4), // cyan-500 + enabled: traceModeEnabled || isTargetedRunning, + isActive: traceModeActive, + isExpanded: traceModeExpanded, + progress: discoveryWindowActive && isTargetedRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isTargetedRunning + ? appState.autoPingTimer.progress + : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + ? appState.cooldownTimer.progress + : null, + onPressed: () { + HapticFeedback.lightImpact(); + appState.toggleAutoPing(AutoMode.targeted); + }, + ); + // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally @@ -1034,6 +959,17 @@ class _CompactPingControlsState extends State { Expanded(child: passiveModeButton) else passiveModeButton, + + // Trace Mode (only shown when a repeater ID has been entered) + if (hasTargetRepeaterId) ...[ + const SizedBox(width: 6), + if (traceModeExpanded) + Expanded(child: traceModeButton) + else if (!anyExpanded && (traceModeShowColor || allDisabled)) + Expanded(child: traceModeButton) + else + traceModeButton, + ], ], ); } @@ -1074,6 +1010,7 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, bool discoveryWindowActive = false, int discoveryWindowRemaining = 0, }) { @@ -1086,7 +1023,7 @@ class _CompactPingControlsState extends State { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1107,10 +1044,37 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, }) { if (isPassiveModeRunning) { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } + // Show cooldown if this button caused it + if (cooldownActive && isExpandedDuringCooldown) { + return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + } + return null; + } + + /// Get label for Trace Mode button + /// When showFullText is true: "Listening 5s", when false: "5s" + String? _getTraceModeLabel({ + required bool isTargetedRunning, + required bool discoveryWindowActive, + required int discoveryWindowRemaining, + required bool autoPingWaiting, + required int autoPingRemaining, + required bool showFullText, + required bool cooldownActive, + required int cooldownRemaining, + required bool isExpandedDuringCooldown, + required bool isSkipped, + }) { + if (isTargetedRunning) { + if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1163,6 +1127,7 @@ class LandscapePingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -1186,11 +1151,6 @@ class LandscapePingControls extends StatelessWidget { final prefs = appState.preferences; final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Antenna selector - final soundEnabled = appState.isSoundEnabled; - final offlineMode = appState.offlineMode; - final isConnected = appState.isConnected; - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1213,7 +1173,7 @@ class LandscapePingControls extends StatelessWidget { icon: Icons.cell_tower, tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, countdown: isPingSending @@ -1242,7 +1202,7 @@ class LandscapePingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, countdown: isTxModeRunning ? (discoveryWindowActive @@ -1268,7 +1228,7 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), @@ -1286,34 +1246,12 @@ class LandscapePingControls extends StatelessWidget { ), const SizedBox(height: 10), - // Compact row for toggles - Row( - children: [ - Expanded( - child: _LandscapeToggle( - icon: offlineMode ? Icons.cloud_off : Icons.cloud_queue, - label: 'Offline', - isOn: offlineMode, - color: Colors.orange, - onTap: () => _OfflineModeToggle._handleOfflineModeToggle( - context, - appState, - offlineMode, - isConnected, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _LandscapeToggle( - icon: soundEnabled ? Icons.volume_up : Icons.volume_off, - label: 'Sound', - isOn: soundEnabled, - color: Colors.blue, - onTap: () => appState.toggleSoundEnabled(), - ), - ), - ], + // Targeted Ping controls (Trace Mode) + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + compact: true, ), ], ); @@ -1612,70 +1550,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> } /// Compact toggle button for landscape panel -class _LandscapeToggle extends StatelessWidget { - final IconData icon; - final String label; - final bool isOn; - final Color color; - final VoidCallback onTap; - - const _LandscapeToggle({ - required this.icon, - required this.label, - required this.isOn, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final activeColor = isOn ? color : colorScheme.onSurfaceVariant; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isOn - ? activeColor.withValues(alpha: 0.12) - : colorScheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isOn - ? activeColor.withValues(alpha: 0.35) - : colorScheme.outline.withValues(alpha: 0.15), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 16, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isOn ? FontWeight.w600 : FontWeight.w500, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} - /// Compact action button for minimized panel - horizontal pill layout /// Supports expanding to show label when active class _CompactActionButton extends StatefulWidget { diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 4c504837..10a909e1 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -6,18 +6,26 @@ class RegionalConfigCard extends StatelessWidget { final String? zoneName; final String? zoneCode; final List channels; + final String? scope; final bool isOfflineMode; + final bool compact; const RegionalConfigCard({ super.key, this.zoneName, this.zoneCode, this.channels = const [], + this.scope, this.isOfflineMode = false, + this.compact = false, }); @override Widget build(BuildContext context) { + if (compact) { + return _buildCompact(context); + } + // When offline mode is enabled, show "-" for zone fields final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; @@ -72,6 +80,16 @@ class RegionalConfigCard extends StatelessWidget { isOffline: isOfflineMode), const SizedBox(height: 12), + // Scope + _buildInfoRow( + context, + Icons.filter_alt, + 'Scope', + isOfflineMode ? '-' : (scope ?? 'Global'), + isOffline: isOfflineMode, + ), + const SizedBox(height: 12), + // Channels header _buildInfoRow(context, Icons.tag, 'RX Channels', null), const SizedBox(height: 8), @@ -96,6 +114,93 @@ class RegionalConfigCard extends StatelessWidget { ); } + /// Compact mode: "Regional Settings" header, scope row, channel chips + Widget _buildCompact(BuildContext context) { + final displayZone = isOfflineMode ? null : zoneName; + final displayScope = isOfflineMode ? '-' : (scope ?? 'Global'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.public, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Regional Settings', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (displayZone != null) + Text( + displayZone, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Scope row + _buildCompactRow(context, 'Scope', [ + _buildChannelChip(context, displayScope, isDefault: true), + ]), + const SizedBox(height: 8), + + // Channels row + _buildCompactRow(context, 'Channels', [ + _buildChannelChip(context, 'Public', isDefault: true), + _buildChannelChip(context, '#wardriving', isDefault: true), + if (!isOfflineMode) + ...channels.map((c) => _buildChannelChip(context, c)), + ]), + ], + ), + ), + ); + } + + /// Compact labeled row: small label on left, chips on right + Widget _buildCompactRow(BuildContext context, String label, List chips) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 70, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: chips, + ), + ), + ], + ); + } + Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( @@ -118,8 +223,12 @@ class RegionalConfigCard extends StatelessWidget { } Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { - // Public channel doesn't use # prefix + // Public channel doesn't use # prefix; scope/plain values pass through as-is final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + // If it doesn't look like a channel name, show raw value (e.g. scope "Global") + final isChannel = name.startsWith('#') || name == 'Public'; + final label = isChannel ? displayName : name; + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -134,11 +243,10 @@ class RegionalConfigCard extends StatelessWidget { ), ), child: Text( - displayName, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - // Use onPrimaryContainer for proper contrast on primaryContainer background color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, ), ), diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index b6c61bae..0088da57 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -10,11 +10,11 @@ import '../utils/distance_formatter.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// -/// Displays the 2-char hex repeater ID in monospace style. Use together with -/// [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so the -/// entire row is the tap target. +/// Displays the hex repeater ID (2/4/6 chars) in monospace style. Use together +/// with [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so +/// the entire row is the tap target. class RepeaterIdChip extends StatelessWidget { - /// The 2-char hex repeater ID (e.g., "4e") + /// The hex repeater ID (e.g., "4E", "4F5D", "4F5D82") final String repeaterId; /// Font size for the ID text (11 for log screens, 13 for map popups) @@ -32,13 +32,22 @@ class RepeaterIdChip extends StatelessWidget { @override Widget build(BuildContext context) { + // Scale font size down for longer IDs + final effectiveFontSize = repeaterId.length > 4 + ? fontSize - 2.0 // 6-char IDs (3-byte) + : repeaterId.length > 2 + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) + final child = Row( mainAxisSize: MainAxisSize.min, children: [ Text( repeaterId, + softWrap: false, + overflow: TextOverflow.clip, style: TextStyle( - fontSize: fontSize, + fontSize: effectiveFontSize, fontWeight: FontWeight.w600, fontFamily: 'monospace', color: Theme.of(context).colorScheme.onSurface, @@ -124,10 +133,11 @@ class RepeaterIdChip extends StatelessWidget { }); } + final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, position: position)) + .map((r) => _buildRepeaterRow(context, r, position: position, regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -188,6 +198,7 @@ class RepeaterIdChip extends StatelessWidget { BuildContext context, Repeater repeater, { Position? position, + int? regionHopBytesOverride, }) { final isActive = repeater.isActive; final badgeColor = isActive ? Colors.green : Colors.grey; @@ -218,27 +229,8 @@ class RepeaterIdChip extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - // Colored circle badge - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: badgeColor, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - repeater.hexId.length >= 2 - ? repeater.hexId.substring(0, 2).toUpperCase() - : repeater.hexId.toUpperCase(), - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), - ), + // Colored badge — circle for short IDs, pill for longer + _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -302,4 +294,31 @@ class RepeaterIdChip extends StatelessWidget { ), ); } + + /// Build a hex ID badge — circle for 2-char, pill for longer IDs + static Widget _buildHexBadge(String displayId, Color color) { + final isLong = displayId.length > 2; + + return Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: isLong + ? const EdgeInsets.symmetric(horizontal: 5) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: displayId.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + } } diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart new file mode 100644 index 00000000..a10fa2da --- /dev/null +++ b/lib/widgets/repeater_picker_sheet.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; + +import '../models/repeater.dart'; +import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; +import '../utils/distance_formatter.dart'; + +/// Show a bottom sheet repeater picker and return the selected repeater. +Future showRepeaterPicker(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.95, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _RepeaterPickerBody( + scrollController: scrollController, + ), + ), + ); +} + +class _RepeaterPickerBody extends StatefulWidget { + final ScrollController scrollController; + + const _RepeaterPickerBody({required this.scrollController}); + + @override + State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); +} + +class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterAndSort(List repeaters, Position? position) { + List filtered; + if (_query.isNotEmpty) { + final q = _query.toLowerCase(); + filtered = repeaters + .where((r) => + r.name.toLowerCase().contains(q) || + r.hexId.toLowerCase().startsWith(q)) + .toList(); + } else { + filtered = List.of(repeaters); + } + + // Sort: active first, then by distance (if GPS), then alphabetically + filtered.sort((a, b) { + // Active first + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + // By distance if GPS available + if (position != null) { + final distA = GpsService.distanceBetween( + position.latitude, position.longitude, a.lat, a.lon, + ); + final distB = GpsService.distanceBetween( + position.latitude, position.longitude, b.lat, b.lon, + ); + return distA.compareTo(distB); + } + // Alphabetically + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + return filtered; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final repeaters = appState.repeaters; + final position = appState.currentPosition; + final isImperial = appState.preferences.isImperial; + final colorScheme = Theme.of(context).colorScheme; + + final filtered = _filterAndSort(repeaters, position); + + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.cell_tower, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Select Repeater', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or hex ID...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onChanged: (value) => setState(() => _query = value), + ), + ), + // Count label + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Showing ${filtered.length} of ${repeaters.length} repeaters', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + // List + Expanded( + child: filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + repeaters.isEmpty + ? 'No repeaters available' + : 'No repeaters match your search', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.builder( + controller: widget.scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final r = filtered[index]; + return _RepeaterTile( + repeater: r, + position: position, + isImperial: isImperial, + onTap: () => Navigator.pop(context, r), + ); + }, + ), + ), + ], + ); + } +} + +class _RepeaterTile extends StatelessWidget { + final Repeater repeater; + final Position? position; + final bool isImperial; + final VoidCallback onTap; + + const _RepeaterTile({ + required this.repeater, + required this.position, + required this.isImperial, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isActive = repeater.isActive; + final badgeColor = isActive ? Colors.green : Colors.grey; + + // Distance text + String? distanceText; + if (position != null) { + final meters = GpsService.distanceBetween( + position!.latitude, position!.longitude, repeater.lat, repeater.lon, + ); + if (meters < 1000) { + distanceText = formatMeters(meters, isImperial: isImperial); + } else { + distanceText = + formatKilometers(meters / 1000, isImperial: isImperial); + } + } + + // Always show 4-byte (8-char) hex ID for identification + final displayHex = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Hex badge + Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayHex, + style: TextStyle( + fontSize: displayHex.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 12), + // Name + distance + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + if (distanceText != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.near_me, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Text( + distanceText, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Active/Stale chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + child: Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index c0904ff6..563d28f6 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -57,7 +57,7 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -158,6 +158,9 @@ class _StatusBarState extends State { case 'disc': return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -262,6 +265,16 @@ class _StatusBarState extends State { const SizedBox(width: 8), + // Trace count chip (animated) + _AnimatedStatChip( + icon: Icons.route, + value: appState.pingStats.traceCount, + color: Colors.cyan, + onTap: () => _showInfoPopup(context, 'trace'), + ), + + const SizedBox(width: 8), + // Uploaded count chip (animated) _AnimatedStatChip( icon: Icons.cloud_done, diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart new file mode 100644 index 00000000..4ba980ea --- /dev/null +++ b/lib/widgets/upload_logs_dialog.dart @@ -0,0 +1,825 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/debug_file_logger.dart'; +import '../services/debug_submit_service.dart'; +import '../utils/constants.dart'; +import '../utils/debug_logger_io.dart'; + +/// Result of a log upload operation +class UploadLogsResult { + final bool success; + final int uploadedCount; + final int failedCount; + final String? errorMessage; + + const UploadLogsResult({ + required this.success, + this.uploadedCount = 0, + this.failedCount = 0, + this.errorMessage, + }); +} + +/// Bottom sheet for uploading debug logs with a mandatory description +class UploadLogsSheet extends StatefulWidget { + final AppStateProvider appState; + final ScrollController scrollController; + + const UploadLogsSheet({ + super.key, + required this.appState, + required this.scrollController, + }); + + @override + State createState() => _UploadLogsSheetState(); +} + +class _UploadLogsSheetState extends State { + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + + final Set _selectedLogFiles = {}; + bool _isSubmitting = false; + String? _errorMessage; + + // Progress tracking + double _progress = 0.0; + String _progressStatus = ''; + int? _currentFile; + int? _totalFiles; + + // Uploadable log files + List _availableLogFiles = []; + bool _isLoadingFiles = true; + + @override + void initState() { + super.initState(); + _loadUploadableFiles(); + } + + Future _loadUploadableFiles() async { + try { + final files = await widget.appState.prepareDebugLogsForUpload(); + if (mounted) { + setState(() { + _availableLogFiles = files; + _isLoadingFiles = false; + // Select all by default + _selectedLogFiles.addAll(files.map((f) => f.path)); + }); + } + } catch (e) { + debugError('[DEBUG] Failed to load uploadable files: $e'); + if (mounted) { + setState(() { + _availableLogFiles = []; + _isLoadingFiles = false; + }); + } + } + } + + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + + void _toggleFile(String path) { + setState(() { + if (_selectedLogFiles.contains(path)) { + _selectedLogFiles.remove(path); + } else { + _selectedLogFiles.add(path); + } + }); + } + + void _onProgressUpdate(BugReportProgress progress) { + if (mounted) { + setState(() { + _progress = progress.progress; + _progressStatus = progress.status; + _currentFile = progress.currentFile; + _totalFiles = progress.totalFiles; + }); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + if (_selectedLogFiles.isEmpty) { + setState(() { + _errorMessage = 'Please select at least one log file to upload'; + }); + return; + } + + // Warn user about GPS data in debug logs + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Location Data Warning'), + content: const Text( + 'Debug logs may contain your approximate GPS coordinates ' + 'from your wardriving session. This location history will ' + 'be included in the uploaded files.\n\n' + 'Do you want to continue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Continue'), + ), + ], + ), + ); + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + _progress = 0.0; + _progressStatus = 'Preparing logs...'; + _currentFile = null; + _totalFiles = null; + }); + + try { + // Rotate the current log file now that the user has committed to uploading + final freshFiles = await widget.appState.prepareDebugLogsForUpload(); + + // Build the upload list using the user's selection applied to the freshly rotated files. + // Selected paths from before rotation still match, plus any newly rotated file is included. + final selectedPaths = Set.from(_selectedLogFiles); + final filesToUpload = freshFiles + .where((f) => selectedPaths.contains(f.path)) + .toList(); + + // If the rotation produced a new file that wasn't in the original selection + // (i.e. the previously-active log that just got rotated), include it too + // since the user selected "all" initially and this file has new content. + final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + filesToUpload.addAll(newFiles); + } + + if (filesToUpload.isEmpty) { + if (mounted) { + setState(() { + _errorMessage = 'No log files to upload after preparation'; + _isSubmitting = false; + _progress = 0.0; + _progressStatus = ''; + }); + } + return; + } + + final service = DebugSubmitService(); + + final publicKey = widget.appState.devicePublicKey ?? + widget.appState.lastConnectedPublicKey ?? + 'not-connected'; + final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final userNotes = _descriptionController.text.trim(); + + int uploadedCount = 0; + int failedCount = 0; + final totalFiles = filesToUpload.length; + + for (int i = 0; i < totalFiles; i++) { + final file = filesToUpload[i]; + final progressBase = i / totalFiles; + final progressPerFile = 1.0 / totalFiles; + + _onProgressUpdate(BugReportProgress( + status: 'Uploading file ${i + 1} of $totalFiles...', + progress: progressBase, + currentFile: i + 1, + totalFiles: totalFiles, + )); + + final success = await service.uploadDebugFileOnly( + file: file, + deviceId: deviceName, + publicKey: publicKey, + appVersion: AppConstants.appVersion, + devicePlatform: DebugSubmitService.getDevicePlatform(), + userNotes: userNotes, + onProgress: (p) { + _onProgressUpdate(BugReportProgress( + status: p.status, + progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + currentFile: i + 1, + totalFiles: totalFiles, + )); + }, + ); + + if (success) { + uploadedCount++; + } else { + failedCount++; + } + } + + service.dispose(); + + if (!mounted) return; + + final result = UploadLogsResult( + success: uploadedCount > 0, + uploadedCount: uploadedCount, + failedCount: failedCount, + errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + ); + + Navigator.of(context).pop(result); + } catch (e) { + debugError('[DEBUG] Upload logs error: $e'); + if (mounted) { + setState(() { + _errorMessage = 'Error: $e'; + _isSubmitting = false; + _progress = 0.0; + _progressStatus = ''; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isSubmitting) { + return _buildProgressView(theme); + } + + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Text('Upload Logs', style: theme.textTheme.titleLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + tooltip: 'Close', + ), + ], + ), + ), + + const Divider(height: 1), + + // Scrollable content + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.opaque, + child: Form( + key: _formKey, + child: ListView( + controller: widget.scrollController, + padding: const EdgeInsets.all(20), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Explanation text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Upload your debug logs directly to the MeshMapper developers. ' + 'This helps us diagnose issues and improve the app.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Description field (mandatory) + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Briefly describe why you\'re uploading these logs...', + alignLabelWithHint: true, + ), + maxLines: 3, + maxLength: 500, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'A description is required'; + } + if (value.trim().length < 10) { + return 'Please provide more detail (at least 10 characters)'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Log files section + _buildSectionLabel(theme, Icons.folder_open, 'Log Files'), + const SizedBox(height: 8), + + if (_isLoadingFiles) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else if (_availableLogFiles.isEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + 'No log files available to upload', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + // Select all / deselect all header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + '${_selectedLogFiles.length} of ${_availableLogFiles.length} selected', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setState(() { + if (_selectedLogFiles.length == _availableLogFiles.length) { + _selectedLogFiles.clear(); + } else { + _selectedLogFiles.clear(); + _selectedLogFiles.addAll( + _availableLogFiles.map((f) => f.path), + ); + } + }); + }, + child: Text( + _selectedLogFiles.length == _availableLogFiles.length + ? 'Deselect All' + : 'Select All', + ), + ), + ], + ), + ), + Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + // File list + ...List.generate(_availableLogFiles.length, (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = _selectedLogFiles.contains(file.path); + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), + ), + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + onTap: _isSubmitting ? null : () => _toggleFile(file.path), + ); + }), + ], + ), + ), + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ), + ], + + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), + ), + ), + ), + + // Sticky bottom action bar + Container( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + MediaQuery.of(context).padding.bottom + 12, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _isSubmitting || _availableLogFiles.isEmpty + ? null + : _submit, + icon: const Icon(Icons.cloud_upload, size: 18), + label: Text( + _selectedLogFiles.isEmpty + ? 'Upload' + : 'Upload ${_selectedLogFiles.length} Log${_selectedLogFiles.length == 1 ? '' : 's'}', + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildProgressView(ThemeData theme) { + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Text('Uploading...', style: theme.textTheme.titleLarge), + ], + ), + ), + + const Divider(height: 1), + + // Progress content + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + color: theme.colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 32), + + Text( + _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + if (_totalFiles != null && _currentFile != null) + Text( + 'File $_currentFile of $_totalFiles', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: 250, + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + color: theme.colorScheme.primary, + minHeight: 8, + ), + ), + const SizedBox(height: 8), + Text( + '${(_progress * 100).toInt()}%', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + Padding( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + MediaQuery.of(context).padding.bottom + 12, + ), + child: Text( + 'Please don\'t close this screen', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } + + Widget _buildSectionLabel(ThemeData theme, IconData icon, String label) { + return Row( + children: [ + Icon(icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + InputDecoration _buildInputDecoration( + ThemeData theme, { + String? hintText, + bool alignLabelWithHint = false, + }) { + return InputDecoration( + hintText: hintText, + alignLabelWithHint: alignLabelWithHint, + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.error, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ); + } +} + +/// Show the upload logs dialog and return the result +Future showUploadLogsDialog( + BuildContext context, + AppStateProvider appState, +) async { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => UploadLogsSheet( + appState: appState, + scrollController: scrollController, + ), + ), + ); +} From b7ec8ddf8a1b2f4cbfe556cf4b8a77de783f2b6c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 20 Mar 2026 12:10:16 -0400 Subject: [PATCH 038/360] ### New Features - Persistent radio power overrides: custom TX power settings are now saved per device, similar to antenna selection. On connect, your previously set power override is automatically applied with a visual indicator showing it's been overridden. A "Reset to Auto" button in the power selector restores the auto-detected power for your device model. ### Improvements - Redesigned the Top Heard overlay. Now shows the repeaters that heard your most recent ping sorted by SNR, with color-coded indicators per ping type (green for TX, purple for Discovery, cyan for Trace, blue for RX). Top 3 slots show the best results from your latest ping and fully replace on each new ping. RX slot now shows the strongest passively overheard repeater within a rolling window that matches your auto-ping interval (15s, 30s, or 60s) instead of a hardcoded 5 seconds. Overlay clears on auto-ping stop, mode switch, disconnect, or log clear. --- .build_version | 2 +- lib/providers/app_state_provider.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.build_version b/.build_version index 26aaba0e..6085e946 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 745b96f0..e090f55d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -379,7 +379,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _topRepeatersOverlay = fresh.take(3).toList(); } - /// Update the RX overlay slot with a 5-second rolling window (best SNR wins). + /// Update the RX overlay slot — window matches auto-ping interval (best SNR wins). void _updateRxOverlaySlot(String repeaterId, double snr) { final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); if (_rxOverlayWindowTimer?.isActive ?? false) { @@ -388,7 +388,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(const Duration(seconds: 5), () { + _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared }); } From dbc54e12f7633638272f28299e916bac461e9c25 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:07:37 -0400 Subject: [PATCH 039/360] Release of v1.2.1 (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. * - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. - Map coverage dots now draw in chronological order instead of by category. When your path crosses over itself, the most recent ping always renders on top regardless of type. - Reduced the thick white outline on map coverage dots to a subtle semi-transparent border, improving readability when markers cluster together. Repeater ID markers retain their original styling. * - Fixed inconsistent audio playback on Android where notification sounds (TX/RX pings) would work reliably in some sessions but not at all in others. * - When a zone reports TX capacity full or TX not allowed, Send Ping and Active/Hybrid mode buttons are now hidden instead of showing disabled "Zone Full" states. The Passive mode button expands to fill the full control panel width. Applies to portrait, compact, and landscape layouts. * - Fixed an error when switching from Offline Mode back to Online Mode while connected. The app would display "Missing contact_uri or iata for registration" instead of resuming online wardriving. The mode switch now correctly re-establishes your zone before authenticating with the server. If zone detection fails (e.g., poor GPS or no internet), a clear error message is shown instead. * - Map Marker Style: choose between four coverage marker shapes under Settings > General. Dot (default, subtle border), Outlined Dot (prominent white border), Pin (teardrop), and Diamond. - GPS Marker: choose your position icon on the map. Arrow (default), Car, Bike, Boat, or Walk. Arrow and Walk rotate with heading; vehicle icons stay upright. - Sound Notifications: now under Settings > General with independent toggles for "Ping Sent" (TX/Discovery) and "Response Received" (repeater echo/RX). * Fixed new pin marker * - Fixed iOS reconnect failures when the device drops BLE bond keys mid-session (every 13-23 min on affected devices). iOS would cache stale keys and all reconnect attempts would fail, requiring a manual forget/re-pair in Settings. The app now detects the pairing error and clears the stale bond automatically before retrying * ⏺ TX Power Reporting: Removed radio power from the BLE mesh message ([0.3w]) and moved it to a per-ping power field in every API wardrive data post (TX, RX, DISC, TRACE). Previously, the power value embedded in the mesh broadcast fell back to 0.3W for devices not in the model database, causing incorrect wattage in the admin panel even when the user had configured the correct power. Power is now read from user preferences and sent directly to the API with each ping, ensuring accurate reporting regardless of device model recognition. * Fix hybrid mode fallback, discovery window timing, connection lifecycle races, and auto-ping guards - Hybrid mode: route blocked TX pings back to hybrid scheduler instead of active-only - Discovery window: increase from 5s to 7s, fix hybrid interval math - Add apple-code 15 to bond error detection - Replace session deadline timer with 15-min idle disconnect timer - Enable heartbeat immediately on connect, keep alive while connected - Add idempotency guard to _fullDisconnectCleanup() for double-call race - Add _disposed flag and connect() guard for controller-closed race - Add reconnect/connection guards to sendPing, sendTxPing, _sendDiscoveryRequest, and all auto-ping timer callbacks - Throttle geofence logs to 1 per 30s with suppressed count * Colorblind Accessibility Setting — COMPLETED Status: Implementation complete, pending commit Approach Convert PingColors from static const fields to static get properties backed by a swappable palette. Add a colorVisionType user preference. All 7+ files that reference PingColors.txSuccess etc. continue to work without call-site changes -- the getters return the active palette's colors. CVD Types Supported ┌───────────────┬────────────────────────────────────┐ │ Setting │ Description │ ├───────────────┼────────────────────────────────────┤ │ none │ Default (current colors) │ ├───────────────┼────────────────────────────────────┤ │ protanopia │ Red-blind -- confuses red/green │ ├───────────────┼────────────────────────────────────┤ │ deuteranopia │ Green-blind -- confuses red/green │ ├───────────────┼────────────────────────────────────┤ │ tritanopia │ Blue-blind -- confuses blue/yellow │ ├───────────────┼────────────────────────────────────┤ │ achromatopsia │ Total color blindness (monochrome) │ └───────────────┴────────────────────────────────────┘ Palettes (Wong 2011 colorblind-safe colors) Normal -- current app colors unchanged. Protanopia / Deuteranopia (both use same palette since both are red-green CVD): - txSuccess: #0072B2 (blue) -- replaces green - txFail: #D55E00 (vermillion) -- replaces red - txSuccessLegend: #56B4E9 (sky blue) - rx: #CC79A7 (reddish purple) - discSuccess: #56B4E9 (sky blue) - traceSuccess: #009E73 (bluish green) - signalGood/Medium/Bad: #0072B2 / #F0E442 / #D55E00 - repeaterActive/New/Dead/Duplicate: #CC79A7 / #F0E442 / #9E9E9E / #D55E00 Tritanopia: - txSuccess: #009E73 (bluish green) -- green visible - txFail: #D55E00 (vermillion) - txSuccessLegend: #22C55E - rx: #CC79A7 (reddish purple) - discSuccess: #E69F00 (orange) -- replaces cyan - traceSuccess: #D55E00 (vermillion) -- replaces cyan - signalGood/Medium/Bad: #009E73 / #E69F00 / #D55E00 - repeaterActive/New/Dead/Duplicate: #CC79A7 / #E69F00 / #9E9E9E / #D55E00 Achromatopsia: - txSuccess: #E0E0E0 (light) / txFail: #616161 (dark) - txSuccessLegend: #E0E0E0 - rx: #9E9E9E (medium) - discSuccess: #BDBDBD (medium-light) / traceSuccess: #757575 (medium-dark) - signalGood/Medium/Bad: #E0E0E0 / #9E9E9E / #424242 - repeaterActive/New/Dead/Duplicate: #E0E0E0 / #BDBDBD / #616161 / #424242 * - Disconnect alert: triple-beep sound when BLE drops during wardriving, so you know pings have stopped. Has its own toggle, independent from the master sound setting, so you can wardrive silently and still get alerted on disconnect * Updating the four repeater colors in the default palette to match the web * - Fixed auto-ping using stale GPS positions. The position stream and ping timers run independently, so the cached position could be well behind your actual location. Auto-pings now force a fresh GPS read before each ping --- lib/models/api_queue_item.dart | 19 + lib/models/noise_floor_session.dart | 15 +- lib/models/user_preferences.dart | 52 +- lib/providers/app_state_provider.dart | 198 ++++- lib/screens/connection_screen.dart | 2 +- lib/screens/home_screen.dart | 21 +- lib/screens/log_screen.dart | 48 +- lib/screens/settings_screen.dart | 246 +++++- lib/services/api_queue_service.dart | 10 + lib/services/api_service.dart | 37 +- lib/services/audio_service.dart | 183 ++-- lib/services/bluetooth/bluetooth_service.dart | 6 + lib/services/bluetooth/mobile_bluetooth.dart | 36 +- lib/services/bluetooth/web_bluetooth.dart | 5 + lib/services/gps_service.dart | 24 + lib/services/meshcore/connection.dart | 62 +- lib/services/meshcore/disc_tracker.dart | 2 +- lib/services/ping_service.dart | 71 +- lib/utils/ping_colors.dart | 250 ++++++ lib/widgets/map_widget.dart | 785 ++++++++++++------ lib/widgets/noise_floor_chart.dart | 56 +- lib/widgets/ping_controls.dart | 6 + lib/widgets/repeater_id_chip.dart | 27 +- lib/widgets/repeater_picker_sheet.dart | 27 +- lib/widgets/status_bar.dart | 17 +- 25 files changed, 1684 insertions(+), 521 deletions(-) create mode 100644 lib/utils/ping_colors.dart diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 337cab1e..0f58d317 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -52,6 +52,10 @@ class ApiQueueItem extends HiveObject { @HiveField(14) final bool externalAntenna; + /// Radio power in watts (e.g., 0.3, 1.0, 2.0) — included in every API post + @HiveField(15) + final double? power; + ApiQueueItem({ required this.type, required this.latitude, @@ -63,6 +67,7 @@ class ApiQueueItem extends HiveObject { this.retryCount = 0, this.lastRetryAt, this.noiseFloor, + this.power, }); /// Create from TX ping @@ -74,6 +79,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'TX', @@ -84,6 +90,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -96,6 +103,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'RX', @@ -106,6 +114,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -123,6 +132,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; @@ -135,6 +145,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -150,6 +161,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( @@ -161,6 +173,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -171,6 +184,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'DISC', @@ -181,6 +195,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -201,6 +216,7 @@ class ApiQueueItem extends HiveObject { 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -216,6 +232,7 @@ class ApiQueueItem extends HiveObject { 'repeater_id': 'None', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -234,6 +251,7 @@ class ApiQueueItem extends HiveObject { 'public_key': parts.length > 5 ? parts[5] : '', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -245,6 +263,7 @@ class ApiQueueItem extends HiveObject { 'heard_repeats': heardRepeats, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 9c6b574d..6bd9fbf3 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import '../utils/ping_colors.dart'; part 'noise_floor_session.g.dart'; @@ -100,13 +101,13 @@ class PingEventMarker extends HiveObject { /// Get the color for this event type Color get color => switch (type) { - PingEventType.txSuccess => Colors.green, - PingEventType.txFail => Colors.red, - PingEventType.rx => Colors.blue, - PingEventType.discSuccess => Colors.purple, - PingEventType.discFail => Colors.grey, - PingEventType.traceSuccess => Colors.cyan, - PingEventType.traceFail => Colors.grey, + PingEventType.txSuccess => PingColors.txSuccess, + PingEventType.txFail => PingColors.txFail, + PingEventType.rx => PingColors.rx, + PingEventType.discSuccess => PingColors.discSuccess, + PingEventType.discFail => PingColors.discFail, + PingEventType.traceSuccess => PingColors.traceSuccess, + PingEventType.traceFail => PingColors.noResponse, }; /// Get a display label for this event type diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 4063fc8b..3275a1fd 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -85,6 +85,21 @@ class UserPreferences { /// Show top 3 repeaters by SNR on the map during wardriving final bool showTopRepeaters; + /// Coverage marker style on the map (dot, pin, diamond) + final String markerStyle; + + /// GPS position marker style (arrow, car, bike, boat, walk) + final String gpsMarkerStyle; + + /// Color vision type for accessibility (none, protanopia, deuteranopia, tritanopia, achromatopsia) + final String colorVisionType; + + /// Download map tiles (base map + coverage overlay). When false, no tile network requests are made to save mobile data. + final bool mapTilesEnabled; + + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) + final bool disconnectAlertEnabled; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -114,6 +129,11 @@ class UserPreferences { this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, this.showTopRepeaters = false, + this.markerStyle = 'dot', + this.gpsMarkerStyle = 'arrow', + this.colorVisionType = 'none', + this.mapTilesEnabled = true, + this.disconnectAlertEnabled = false, }); /// Create from JSON (for persistence) @@ -147,6 +167,11 @@ class UserPreferences { minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, + markerStyle: (json['markerStyle'] as String?) ?? 'dot', + gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + colorVisionType: (json['colorVisionType'] as String?) ?? 'none', + mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, + disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, ); } @@ -181,6 +206,11 @@ class UserPreferences { 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, 'showTopRepeaters': showTopRepeaters, + 'markerStyle': markerStyle, + 'gpsMarkerStyle': gpsMarkerStyle, + 'colorVisionType': colorVisionType, + 'mapTilesEnabled': mapTilesEnabled, + 'disconnectAlertEnabled': disconnectAlertEnabled, }; } @@ -214,6 +244,11 @@ class UserPreferences { int? minPingDistanceMeters, bool? autoStopAfterIdle, bool? showTopRepeaters, + String? markerStyle, + String? gpsMarkerStyle, + String? colorVisionType, + bool? mapTilesEnabled, + bool? disconnectAlertEnabled, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -244,6 +279,11 @@ class UserPreferences { minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, + markerStyle: markerStyle ?? this.markerStyle, + gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, + colorVisionType: colorVisionType ?? this.colorVisionType, + mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, + disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, ); } @@ -302,7 +342,12 @@ class UserPreferences { other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && - other.showTopRepeaters == showTopRepeaters; + other.showTopRepeaters == showTopRepeaters && + other.markerStyle == markerStyle && + other.gpsMarkerStyle == gpsMarkerStyle && + other.colorVisionType == colorVisionType && + other.mapTilesEnabled == mapTilesEnabled && + other.disconnectAlertEnabled == disconnectAlertEnabled; } @override @@ -335,6 +380,11 @@ class UserPreferences { minPingDistanceMeters, autoStopAfterIdle, showTopRepeaters, + markerStyle, + gpsMarkerStyle, + colorVisionType, + mapTilesEnabled, + disconnectAlertEnabled, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e090f55d..712f8cf7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -37,6 +37,7 @@ import '../services/meshcore/unified_rx_handler.dart'; import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../utils/constants.dart'; +import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -260,6 +261,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); + static const Duration _reconnectDelayAfterBondError = Duration(seconds: 5); + bool _lastReconnectWasBondError = false; + + // Idle disconnect timer — disconnects after 15 min without manual ping or auto-ping + Timer? _idleDisconnectTimer; + static const Duration _idleDisconnectTimeout = Duration(minutes: 15); + + // Geofence zone check log throttle (while disconnected) + DateTime? _lastZoneCheckLogTime; + int _zoneCheckSuppressedCount = 0; // Map navigation trigger (for navigating to log entry coordinates) ({double lat, double lon})? _mapNavigationTarget; @@ -487,6 +498,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; + bool get isTxSoundEnabled => _audioService.isTxEnabled; + bool get isRxSoundEnabled => _audioService.isRxEnabled; + bool get isDisconnectAlertEnabled => _preferences.disconnectAlertEnabled; AudioService get audioService => _audioService; bool get isConnected => _connectionStep == ConnectionStep.connected; @@ -740,7 +754,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + // Throttle log to once per 30s to avoid spam while driving + final now = DateTime.now(); + if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_zoneCheckSuppressedCount > 0) { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + } else { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + } + _lastZoneCheckLogTime = now; + _zoneCheckSuppressedCount = 0; + } else { + _zoneCheckSuppressedCount++; + } await checkZoneStatus(); } @@ -1310,6 +1336,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Get external antenna value for API payloads _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + // Get power level from preferences (includes per-device overrides and manual selection) + _pingService!.getPowerLevel = () => _preferences.powerLevel; + // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; @@ -1707,6 +1736,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!_preferences.offlineMode) { _startZoneRefreshTimer(); } + + // Enable heartbeat immediately on connection to keep server session alive + // Previously only enabled on auto-ping start, causing silent session expiry + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); + } + + // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) + _startIdleDisconnectTimer(); } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1955,6 +2000,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: entry.repeaterId, externalAntenna: _preferences.externalAntenna, noiseFloor: _meshCoreConnection?.lastNoiseFloor, + power: _preferences.powerLevel, ); // Update UI @@ -2167,6 +2213,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } Future _fullDisconnectCleanup() async { + // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) + if (_connectionStep == ConnectionStep.disconnected) { + debugLog('[CONN] Already disconnected, skipping duplicate cleanup'); + return; + } _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; @@ -2182,6 +2233,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { + if (!_userRequestedDisconnect) { + _playDisconnectAlert(); + } _autoPingEnabled = false; _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); @@ -2237,8 +2291,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { _cancelPendingAutoPingRestore(); + _cancelIdleDisconnectTimer(); _isAutoReconnecting = true; _reconnectAttempt = 0; + _lastReconnectWasBondError = false; _connectionStep = ConnectionStep.reconnecting; // Remember auto-ping state before cleanup @@ -2307,8 +2363,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); + // Use longer delay after bond errors to give iOS time to clear stale keys + final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + // Delay before attempting reconnection - _reconnectTimer = Timer(_reconnectDelay, () async { + _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { @@ -2318,6 +2377,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again @@ -2329,6 +2389,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { + // Check for iOS apple-code 14 (Peer removed pairing information) + // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying + await _handleBondErrorIfNeeded(e); + // Reset step back to reconnecting for UI _connectionStep = ConnectionStep.reconnecting; _connectionError = null; @@ -2339,6 +2403,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); } + /// Start 15-minute idle disconnect timer. + /// Fires if user does not send a manual ping or start auto-ping within 15 minutes. + void _startIdleDisconnectTimer() { + _idleDisconnectTimer?.cancel(); + _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { + if (!isConnected || _autoPingEnabled) return; + debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); + logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + disconnect(); + }); + debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + } + + /// Cancel the idle disconnect timer + void _cancelIdleDisconnectTimer() { + if (_idleDisconnectTimer != null) { + _idleDisconnectTimer!.cancel(); + _idleDisconnectTimer = null; + debugLog('[IDLE] Idle disconnect timer cancelled'); + } + } + + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry + Future _handleBondErrorIfNeeded(Object error) async { + final errorStr = error.toString(); + if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + _lastReconnectWasBondError = true; + final deviceId = _rememberedDevice?.id; + if (deviceId != null) { + debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + await _bluetoothService.removeBond(deviceId); + } + } + } + /// Called when auto-reconnect succeeds void _onReconnectSuccess() { // Cancel timers @@ -2378,6 +2477,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); + } else { + // No auto-ping to restore — start idle timer + _startIdleDisconnectTimer(); } notifyListeners(); @@ -2398,6 +2500,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimeoutTimer = null; _cancelPendingAutoPingRestore(); + // Alert if auto-ping was running before disconnect + if (_autoPingWasEnabled) { + _playDisconnectAlert(); + } + // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; @@ -2423,6 +2530,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + // Cancel any active auto-reconnect _reconnectTimer?.cancel(); _reconnectTimer = null; @@ -2601,6 +2711,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Send a manual TX ping Future sendPing() async { if (_pingService == null) return false; + if (_isAutoReconnecting) { + debugLog('[PING] Ignoring ping during auto-reconnect'); + return false; + } // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { @@ -2608,6 +2722,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!sessionCheck) return false; } + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + // Set sending state immediately for instant UI feedback _isPingSending = true; notifyListeners(); @@ -2648,6 +2765,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Auto-stop auto-ping after prolonged idle (no movement) void _triggerIdleAutoStop() { if (!_autoPingEnabled) return; + _playDisconnectAlert(); final elapsed = _idleAutoStopReference != null ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; @@ -2706,8 +2824,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // End noise floor session when mode is disabled await _endNoiseFloorSession(); - // Disable heartbeat when stopping auto mode - _apiService.disableHeartbeat(); + // Keep heartbeat enabled (stays on while connected to prevent session expiry) + // Re-start idle disconnect timer now that user is idle again + _startIdleDisconnectTimer(); _autoPingEnabled = false; _idleAutoStopReference = null; @@ -2724,6 +2843,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); + // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { final sessionCheck = await _checkSessionBeforeAction(); @@ -3062,6 +3184,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } + // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + debugLog('[APP] Re-checking zone status before auth...'); + await checkZoneStatus(); + + if (zoneCode == null) { + debugError('[APP] Cannot switch to online mode: not in a zone'); + _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + return (success: false, error: _modeSwitchError); + } + // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ @@ -3189,12 +3321,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } - // 7. Re-check zone status - if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after online mode enabled'); - await checkZoneStatus(); - } - debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { @@ -3702,6 +3828,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Set color vision type for accessibility and persist + void setColorVisionType(String type) { + _preferences = _preferences.copyWith(colorVisionType: type); + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ); + debugLog('[A11Y] Color vision type set to $type'); + notifyListeners(); + _savePreferences(); + } + /// Set unit system preference (metric or imperial) void setUnitSystem(String system) { _preferences = _preferences.copyWith(unitSystem: system); @@ -3754,6 +3891,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Set TX sound enabled state (ping sent / discovery sent) + Future setTxSoundEnabled(bool enabled) async { + await _audioService.setTxEnabled(enabled); + notifyListeners(); + } + + /// Set RX sound enabled state (repeater echo / RX observation) + Future setRxSoundEnabled(bool enabled) async { + await _audioService.setRxEnabled(enabled); + notifyListeners(); + } + + /// Set disconnect alert enabled state + Future setDisconnectAlertEnabled(bool enabled) async { + _preferences = _preferences.copyWith(disconnectAlertEnabled: enabled); + await _savePreferences(); + debugLog('[AUDIO] Disconnect alert ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) + void _playDisconnectAlert() { + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); + _audioService.playAlertSound(); + } + /// Navigate to coordinates on map (triggered from log entries) void navigateToMapCoordinates(double latitude, double longitude) { _mapNavigationTarget = (lat: latitude, lon: longitude); @@ -3901,6 +4065,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _handleMaintenanceModeConnected(String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); + // Alert if auto-ping was running (maintenance is not user-initiated) + if (_autoPingEnabled) { + _playDisconnectAlert(); + } + // Log to error log (this sets _requestErrorLogSwitch = true) logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); @@ -4710,6 +4879,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Apply saved min ping distance to GpsService and PingService _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; + + // Apply saved color vision type + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere( + (e) => e.name == _preferences.colorVisionType, + orElse: () => ColorVisionType.none, + ), + ); } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -5088,6 +5265,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); _restoreAutoPingTimer?.cancel(); + _idleDisconnectTimer?.cancel(); _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _tileRefreshTimer?.cancel(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ce97f871..993466e3 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -439,7 +439,7 @@ class _ConnectionScreenState extends State with WidgetsBinding } // Portrait: compact vertical layout (bottom bar provided by _buildBody) - return Padding( + return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d984a989..0064326f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import '../widgets/connection_panel.dart'; import '../widgets/map_widget.dart'; import '../widgets/ping_controls.dart'; @@ -155,7 +156,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_upward, appState.pingStats.txCount, - Colors.green, + PingColors.txSuccess, onTap: withTapHandlers ? () => _showInfoPopup('tx', appState) : null, ), const SizedBox(width: 8), @@ -163,7 +164,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_downward, appState.pingStats.rxCount, - Colors.blue, + PingColors.rx, onTap: withTapHandlers ? () => _showInfoPopup('rx', appState) : null, ), const SizedBox(width: 8), @@ -171,7 +172,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.radar, appState.pingStats.discCount, - const Color(0xFF7B68EE), + PingColors.discSuccess, onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), @@ -179,7 +180,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.route, appState.pingStats.traceCount, - Colors.cyan, + PingColors.traceSuccess, onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), @@ -331,16 +332,16 @@ class _HomeScreenState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -998,9 +999,7 @@ class _HomeScreenState extends State { /// Get color based on noise floor value (lower is better) Color _getNoiseFloorColor(int noiseFloor) { - if (noiseFloor <= -100) return Colors.green; // -100 to -120: great - if (noiseFloor <= -90) return Colors.orange; // -90 to -100: okay - return Colors.red; // 0 to -90: bad + return PingColors.noiseFloorColor(noiseFloor.toDouble()); } /// Get battery icon based on percentage diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 97267e0b..565f3920 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/log_entry.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; /// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors @@ -434,13 +435,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), ], ), ), @@ -548,10 +549,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Widget _buildTypeBadge(PingLogType type) { final (label, color) = switch (type) { - PingLogType.tx => ('TX', Colors.green), - PingLogType.rx => ('RX', Colors.blue), - PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), - PingLogType.trace => ('TRC', Colors.cyan), + PingLogType.tx => ('TX', PingColors.txSuccess), + PingLogType.rx => ('RX', PingColors.rx), + PingLogType.disc => ('DISC', PingColors.discSuccess), + PingLogType.trace => ('TRC', PingColors.traceSuccess), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -808,14 +809,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -830,10 +824,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: Color(0xFF7B68EE), + color: PingColors.discSuccess, ), ), ], @@ -984,29 +978,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Color _snrColor(SnrSeverity? severity) { return switch (severity) { - SnrSeverity.poor => Colors.red, - SnrSeverity.fair => Colors.orange, - SnrSeverity.good => Colors.green, + SnrSeverity.poor => PingColors.signalBad, + SnrSeverity.fair => PingColors.signalMedium, + SnrSeverity.good => PingColors.signalGood, null => Colors.grey, }; } - static Color _snrColorFromValue(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + static Color _snrColorFromValue(double snr) => PingColors.snrColor(snr); static Color _snrColorFromNullableValue(double? snr) { if (snr == null) return Colors.grey; - return _snrColorFromValue(snr); + return PingColors.snrColor(snr); } static Color _rssiColor(int? rssi) { if (rssi == null) return Colors.grey; - if (rssi >= -70) return Colors.green; - if (rssi >= -100) return Colors.orange; - return Colors.red; + return PingColors.rssiColor(rssi); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c99446f8..b0013667 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -146,6 +146,26 @@ class _SettingsScreenState extends State { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + SwitchListTile( + secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + title: const Text('Disable Map Tiles'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Map and coverage tiles load normally' + : 'Disabled to save mobile data'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + }, + ), + ListTile( + leading: const Icon(Icons.visibility), + title: const Text('Color Vision'), + subtitle: Text(_colorVisionLabel(prefs.colorVisionType)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showColorVisionSelector(context, appState), + ), SwitchListTile( secondary: Icon( prefs.isImperial ? Icons.square_foot : Icons.straighten, @@ -166,8 +186,50 @@ class _SettingsScreenState extends State { appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + ListTile( + leading: const Icon(Icons.place), + title: const Text('Map Marker Style'), + subtitle: Text(_markerStyleLabel(prefs.markerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMarkerStyleSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.my_location), + title: const Text('GPS Marker'), + subtitle: Text(_gpsMarkerLabel(prefs.gpsMarkerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showGpsMarkerSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), + ], ]), // Ping Settings @@ -207,13 +269,6 @@ class _SettingsScreenState extends State { enabled: !isAutoMode, onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), ), - SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), - ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), @@ -879,6 +934,179 @@ class _SettingsScreenState extends State { ); } + String _markerStyleLabel(String style) { + switch (style) { + case 'circle': return 'Outlined Dot'; + case 'pin': return 'Pin'; + case 'diamond': return 'Diamond'; + case 'dot': + default: return 'Dot'; + } + } + + String _gpsMarkerLabel(String style) { + switch (style) { + case 'car': return 'Car'; + case 'bike': return 'Bike'; + case 'boat': return 'Boat'; + case 'walk': return 'Walk'; + case 'arrow': + default: return 'Arrow'; + } + } + + void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('dot', 'Dot', Icons.circle), + ('circle', 'Outlined Dot', Icons.circle_outlined), + ('pin', 'Pin', Icons.place), + ('diamond', 'Diamond', Icons.diamond), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.markerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + void _showGpsMarkerSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('arrow', 'Arrow', Icons.navigation), + ('car', 'Car', Icons.directions_car), + ('bike', 'Bike', Icons.directions_bike), + ('boat', 'Boat', Icons.directions_boat), + ('walk', 'Walk', Icons.directions_walk), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.gpsMarkerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + String _colorVisionLabel(String type) { + return switch (type) { + 'protanopia' => 'Protanopia (red-blind)', + 'deuteranopia' => 'Deuteranopia (green-blind)', + 'tritanopia' => 'Tritanopia (blue-blind)', + 'achromatopsia' => 'Achromatopsia (monochrome)', + _ => 'Default', + }; + } + + void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('none', 'Default', 'Standard color palette'), + ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), + ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), + ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), + ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.colorVisionType, + onChanged: (v) { + if (v != null) appState.setColorVisionType(v); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.visibility), + title: Text(label), + subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null, + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + Widget _buildSection(BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 2cb45faf..eb8b70fc 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -223,6 +223,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -231,6 +232,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -266,6 +268,7 @@ class ApiQueueService { required String repeaterId, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromRx( latitude: latitude, @@ -274,6 +277,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -309,6 +313,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDisc( latitude: latitude, @@ -322,6 +327,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -358,6 +364,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTrace( latitude: latitude, @@ -369,6 +376,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -401,6 +409,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDiscDrop( latitude: latitude, @@ -408,6 +417,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 064c25a5..2da5d0ce 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -40,7 +40,7 @@ class ApiService { int? _sessionExpiresAt; Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; - Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; @@ -607,8 +607,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -642,9 +640,6 @@ class ApiService { _sendScheduledHeartbeat(); }); } - - // Schedule session deadline timer at exact expiry - _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -693,32 +688,6 @@ class ApiService { } } - /// Schedule a hard deadline timer at the exact session expiry time. - /// If the server is unreachable and all heartbeat retries fail, this fires - /// and triggers the same disconnect flow as a server-returned session_expired. - void _scheduleSessionDeadline(int expiresAt) { - _sessionDeadlineTimer?.cancel(); - if (!_heartbeatEnabled) return; - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final secondsUntilExpiry = expiresAt - now; - - if (secondsUntilExpiry <= 0) { - _onSessionDeadlineReached(); - return; - } - - debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); - _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); - } - - /// Called when the session deadline timer fires — server was unreachable - void _onSessionDeadlineReached() { - debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); - _clearSession(); - onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); - } - /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; @@ -736,8 +705,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -982,8 +949,6 @@ class ApiService { _heartbeatTimer = null; _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 41898ca0..6b74c07c 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,11 +11,23 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txEnabledKey = 'tx_sound_enabled'; + static const String _rxEnabledKey = 'rx_sound_enabled'; + static const String _txAsset = 'assets/transmitted_packet.mp3'; + static const String _rxAsset = 'assets/received_packet.mp3'; + + /// Delay before releasing audio focus after the last sound plays. + /// Prevents rapid activate/deactivate cycles that break Android audio, + /// while still releasing focus for Android Auto ducking. + static const Duration _focusReleaseDelay = Duration(seconds: 3); AudioPlayer? _txPlayer; AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it + bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) + Timer? _focusReleaseTimer; /// Whether the audio service is initialized bool get isInitialized => _initialized; @@ -23,6 +35,12 @@ class AudioService { /// Whether sound notifications are enabled bool get isEnabled => _enabled; + /// Whether TX sound is enabled (ping sent / discovery sent) + bool get isTxEnabled => _txEnabled; + + /// Whether RX sound is enabled (repeater echo / RX observation) + bool get isRxEnabled => _rxEnabled; + /// Initialize the audio service and pre-load sounds Future initialize() async { if (_initialized) return; @@ -66,9 +84,8 @@ class AudioService { _rxPlayer = AudioPlayer(); // Pre-load the audio assets for instant playback - // Using asset:// prefix for just_audio - await _txPlayer!.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer!.setAsset('assets/received_packet.mp3'); + await _txPlayer!.setAsset(_txAsset); + await _rxPlayer!.setAsset(_rxAsset); _initialized = true; debugLog('[AUDIO] Audio service initialized, enabled=$_enabled'); @@ -92,9 +109,15 @@ class AudioService { } else { debugLog('[AUDIO] No saved preference, using default: $_enabled'); } + + final txEnabled = box.get(_txEnabledKey); + if (txEnabled != null) _txEnabled = txEnabled as bool; + final rxEnabled = box.get(_rxEnabledKey); + if (rxEnabled != null) _rxEnabled = rxEnabled as bool; + debugLog('[AUDIO] Loaded sub-toggles: tx=$_txEnabled, rx=$_rxEnabled'); } catch (e) { debugError('[AUDIO] Failed to load enabled state: $e'); - // Keep default (disabled) + // Keep defaults } } @@ -147,55 +170,79 @@ class AudioService { /// Play the transmit sound (when TX ping or Discovery request is sent) Future playTransmitSound() async { - debugLog('[AUDIO] playTransmitSound called - initialized=$_initialized, enabled=$_enabled'); - if (!_initialized || !_enabled) { - debugLog('[AUDIO] playTransmitSound skipped - not initialized or disabled'); - return; - } + if (!_txEnabled) return; + await _playSound(_txPlayer, _txAsset, 'TX'); + } + + /// Play the receive sound (when repeater echo or RX observation is detected) + Future playReceiveSound() async { + if (!_rxEnabled) return; + await _playSound(_rxPlayer, _rxAsset, 'RX'); + } + + /// Shared playback logic for both TX and RX sounds. + /// Ensures audio session is active before playing and debounces focus release. + Future _playSound(AudioPlayer? player, String assetPath, String label) async { + if (!_initialized || !_enabled || player == null) return; try { - debugLog('[AUDIO] Playing transmit sound...'); - // Seek to start and play with timeout to prevent indefinite hangs - // (iOS audio session corruption can cause play() to never complete) - await _txPlayer?.seek(Duration.zero); - await _txPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Transmit sound played successfully'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); + await _ensureSessionActive(); + await player.seek(Duration.zero); + await player.play().timeout(const Duration(seconds: 3)); + debugLog('[AUDIO] Played $label sound'); + _scheduleFocusRelease(); } on TimeoutException { - debugWarn('[AUDIO] Transmit play() timed out after 3s — resetting audio session'); - await _txPlayer?.stop(); + debugWarn('[AUDIO] $label play() timed out — resetting audio session'); + await player.stop(); await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to play transmit sound: $e'); + debugError('[AUDIO] Failed to play $label sound: $e'); + // Try to recover the player for next time + try { + await player.stop(); + await player.setAsset(assetPath); + debugLog('[AUDIO] Reloaded $label player after error'); + } catch (reloadError) { + debugError('[AUDIO] Failed to reload $label player: $reloadError'); + } } } - /// Play the receive sound (when repeater echo or RX observation is detected) - Future playReceiveSound() async { - if (!_initialized || !_enabled) return; - + /// Ensure audio session is active before playback. + /// Cancels any pending focus release to prevent a race where releasing + /// focus from a previous sound kills the session for the current sound. + Future _ensureSessionActive() async { + _focusReleaseTimer?.cancel(); try { - // Seek to start and play with timeout to prevent indefinite hangs - await _rxPlayer?.seek(Duration.zero); - await _rxPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Played receive sound'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); - } on TimeoutException { - debugWarn('[AUDIO] Receive play() timed out after 3s — resetting audio session'); - await _rxPlayer?.stop(); - await _resetAudioSession(); + final session = await AudioSession.instance; + await session.setActive(true); } catch (e) { - debugError('[AUDIO] Failed to play receive sound: $e'); + debugError('[AUDIO] Failed to activate audio session: $e'); + // Continue anyway — playback may still work } } + /// Schedule a delayed audio focus release. + /// Debounced: if another sound plays within the delay window, the timer + /// resets so focus stays active throughout rapid TX→RX sequences. + /// Critical for Android Auto: eventually releases ducking so car audio resumes. + void _scheduleFocusRelease() { + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = Timer(_focusReleaseDelay, () async { + try { + final session = await AudioSession.instance; + await session.setActive(false); + debugLog('[AUDIO] Audio focus released (debounced)'); + } catch (e) { + debugError('[AUDIO] Failed to release audio focus: $e'); + } + }); + } + /// Reset audio session after a play() timeout /// Stops both players, reconfigures the audio session, and reloads assets Future _resetAudioSession() async { + _focusReleaseTimer?.cancel(); try { // Stop both players await _txPlayer?.stop(); @@ -222,8 +269,8 @@ class AudioService { ); // Reload assets so players are ready for next play() - await _txPlayer?.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer?.setAsset('assets/received_packet.mp3'); + await _txPlayer?.setAsset(_txAsset); + await _rxPlayer?.setAsset(_rxAsset); debugLog('[AUDIO] Audio session reset after timeout'); } catch (e) { @@ -231,17 +278,28 @@ class AudioService { } } - /// Release audio focus after playback completes - /// This is critical for Android Auto - without explicitly releasing focus, - /// the car audio system stays ducked indefinitely - Future _releaseAudioFocus() async { + /// Play disconnect alert sound (triple beep pattern). + /// Independent of master sound toggle — this is a safety alert. + Future playAlertSound() async { + if (!_initialized || _txPlayer == null) return; + try { - final session = await AudioSession.instance; - await session.setActive(false); - debugLog('[AUDIO] Audio focus released'); + await _ensureSessionActive(); + for (int i = 0; i < 3; i++) { + await _txPlayer!.seek(Duration.zero); + await _txPlayer!.play().timeout(const Duration(seconds: 3)); + if (i < 2) { + await Future.delayed(const Duration(milliseconds: 300)); + } + } + debugLog('[AUDIO] Played disconnect alert (triple beep)'); + _scheduleFocusRelease(); + } on TimeoutException { + debugWarn('[AUDIO] Alert play() timed out — resetting audio session'); + await _txPlayer!.stop(); + await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to release audio focus: $e'); - // Non-critical - audio still works, just may leave other audio ducked + debugError('[AUDIO] Failed to play alert sound: $e'); } } @@ -259,9 +317,38 @@ class AudioService { await setEnabled(!_enabled); } + /// Enable or disable TX sound notifications + Future setTxEnabled(bool enabled) async { + if (_txEnabled == enabled) return; + _txEnabled = enabled; + debugLog('[AUDIO] TX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_txEnabledKey, enabled); + } + + /// Enable or disable RX sound notifications + Future setRxEnabled(bool enabled) async { + if (_rxEnabled == enabled) return; + _rxEnabled = enabled; + debugLog('[AUDIO] RX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_rxEnabledKey, enabled); + } + + /// Save a single setting to Hive + Future _saveSetting(String key, dynamic value) async { + final box = await _openBoxSafely(_prefsBoxName); + if (box == null) return; + try { + await box.put(key, value); + } catch (e) { + debugError('[AUDIO] Failed to save $key: $e'); + } + } + /// Dispose of audio resources void dispose() { debugLog('[AUDIO] Disposing audio service'); + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = null; _txPlayer?.dispose(); _rxPlayer?.dispose(); _txPlayer = null; diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index e1fe5eb6..47021780 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -86,6 +86,12 @@ abstract class BluetoothService { /// Used for remembered devices to ensure name is available during connect void cacheDeviceInfo(DiscoveredDevice device); + /// Remove BLE bond/pairing for a device + /// On Android: removes the system bond entry + /// On iOS: best-effort — calls cancelPeripheralConnection to nudge CoreBluetooth + /// into clearing stale encryption keys (used after apple-code 14 errors) + Future removeBond(String deviceId); + /// Dispose of resources void dispose(); } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index d279477d..8fb3d629 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -397,13 +397,26 @@ class MobileBluetoothService implements BluetoothService { return; // Success - exit retry loop } catch (e, stackTrace) { + final errorStr = e.toString(); + // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && e.toString().contains('android-code: 133'); - - if (isError133 && attempt < _maxRetries) { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); - await Future.delayed(_retryDelay); + final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + + // Check for iOS apple-code 14 (Peer removed pairing information) or + // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys + final isBondError = Platform.isIOS && + (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + + if ((isError133 || isBondError) && attempt < _maxRetries) { + if (isBondError) { + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + await removeBond(deviceId); + await Future.delayed(const Duration(seconds: 2)); + } else { + debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + await Future.delayed(_retryDelay); + } // Force cleanup before retry try { await _bleDevice?.disconnect(); @@ -470,6 +483,19 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Cached device info: ${device.name} (${device.id})'); } + @override + Future removeBond(String deviceId) async { + try { + final device = fbp.BluetoothDevice.fromId(deviceId); + debugLog('[BLE] Removing bond for $deviceId'); + await device.removeBond(); + debugLog('[BLE] Bond removed for $deviceId'); + } catch (e) { + // removeBond may not be supported on all platforms/devices — log and continue + debugLog('[BLE] removeBond failed (continuing): $e'); + } + } + @override void dispose() { _isDisposed = true; diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index 81054ede..ef5ed4c2 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -262,6 +262,11 @@ class WebBluetoothService implements BluetoothService { // No caching needed - this method is for mobile remembered devices } + @override + Future removeBond(String deviceId) async { + // Web Bluetooth does not support bond management + } + @override void dispose() { _notificationSubscription?.cancel(); diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 753c67c2..6eced5cd 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -358,6 +358,30 @@ class GpsService { return null; // Valid } + /// Request a fresh GPS position from the hardware for auto-ping accuracy. + /// On mobile, this forces a warm-start GPS read (typically < 1 second when + /// GPS is already streaming). Falls back to lastPosition on timeout/error. + Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + // Simulator provides its own positions — use cached + if (_simulatorEnabled) { + return _lastPosition; + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: timeout, + ); + debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + _lastPosition = position; + return position; + } catch (e) { + debugLog('[GPS] Fresh position request failed, using cached: $e'); + return _lastPosition; + } + } + /// Get current position (single request) Future getCurrentPosition() async { if (!await requestPermissions()) { diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 07c08100..5f7513dc 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -65,6 +65,7 @@ class SelfInfo { /// 9. Connected State class MeshCoreConnection { final BluetoothService _bluetooth; + bool _disposed = false; final _stepController = StreamController.broadcast(); final _channelMessageController = StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); @@ -84,6 +85,7 @@ class MeshCoreConnection { Completer? _deviceQueryCompleter; Completer? _selfInfoCompleter; Completer? _sentCompleter; + Completer? _setTimeCompleter; Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; @@ -176,8 +178,8 @@ class MeshCoreConnection { void _updateStep(ConnectionStep step) { _currentStep = step; - if (_stepController.isClosed) { - debugError('[CONN] Cannot update step - controller is closed!'); + if (_disposed || _stepController.isClosed) { + debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -188,8 +190,11 @@ class MeshCoreConnection { /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + if (_disposed) { + throw Exception('Connection instance has been disposed'); + } bool deviceModelMatched = false; - + try { // Step 1: BLE Connect _updateStep(ConnectionStep.bleConnecting); @@ -369,10 +374,23 @@ class MeshCoreConnection { switch (responseCode) { case ResponseCodes.ok: debugLog('[CONN] Received OK response'); + _setTimeCompleter?.complete(); + _setTimeCompleter = null; break; case ResponseCodes.err: final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); + // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success + if (_setTimeCompleter != null) { + if (errorCode == 6) { + debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + } else { + debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } // Complete any pending completers with error final errException = Exception('Command error (code $errorCode)'); _statsCompleter?.completeError(errException); @@ -641,10 +659,16 @@ class MeshCoreConnection { if (statsType == StatsTypes.radio) { final noiseFloor = reader.readInt16LE(); // Skip remaining fields (lastRssi, lastSnr, txAirSecs, rxAirSecs) - _lastNoiseFloor = noiseFloor; - _noiseFloorController.add(noiseFloor); // Emit to stream - debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); - _statsCompleter?.complete(noiseFloor); + if (noiseFloor == 0) { + // MeshCore 1.14.x AGC reset zeroes out noise floor briefly; discard + debugLog('[CONN] Noise floor reading is 0dBm (AGC reset), ignoring'); + _statsCompleter?.complete(0); + } else { + _lastNoiseFloor = noiseFloor; + _noiseFloorController.add(noiseFloor); // Emit to stream + debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); + _statsCompleter?.complete(noiseFloor); + } } else { debugLog('[CONN] Unknown stats type: $statsType'); _statsCompleter?.complete(0); @@ -758,12 +782,23 @@ class MeshCoreConnection { ); } - /// Set device time + /// Set device time and await OK/ERROR response from device Future setDeviceTime(int epochSecs) async { + _setTimeCompleter = Completer(); + final future = _setTimeCompleter!.future; + final data = BufferWriter(); data.writeByte(CommandCodes.setDeviceTime); data.writeUInt32LE(epochSecs); await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _setTimeCompleter = null; + debugWarn('[CONN] Time sync timed out - continuing anyway'); + }, + ); } /// Set TX power @@ -925,9 +960,10 @@ class MeshCoreConnection { } /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON [power] + /// Format: @[MapperBot] LAT, LON + /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon, double powerWatts) async { + Future sendPing(double lat, double lon) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); @@ -935,9 +971,7 @@ class MeshCoreConnection { // Format coordinates to 5 decimal places with comma separator final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - // Format power as "X.Xw" (e.g., "1.0w", "0.3w") - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final message = '@[MapperBot] $coordsStr [$powerStr]'; + final message = '@[MapperBot] $coordsStr'; debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -1156,8 +1190,10 @@ class MeshCoreConnection { /// Dispose of resources void dispose() { + _disposed = true; _stopNoiseFloorPolling(); _stopBatteryPolling(); + _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); _channelMessageController.close(); diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 5796d6f1..23dd9d60 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -45,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index d7822824..50bc46eb 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -44,8 +44,8 @@ class PingService { static const Duration _rxListeningWindow = Duration(seconds: 5); /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); - /// Discovery listening window duration (5 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 7); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) @@ -137,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to get the power level in watts (0.3, 0.6, 1.0, 2.0) from user preferences + double Function()? getPowerLevel; + /// Callback to check if discovery drop is enabled (failed discoveries → API) bool Function()? getDiscDropEnabled; @@ -471,6 +474,13 @@ class PingService { Future sendTxPing({bool manual = true}) async { debugLog('[PING] sendTxPing called (manual=$manual)'); + // Guard: don't send pings if connection is not in connected state + // Handles race where timer callback fires after reconnect started + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + return false; + } + // Early guard: prevent concurrent ping execution (critical for preventing BLE GATT errors) // Reference: state.pingInProgress check in wardrive.js if (_pingInProgress) { @@ -480,6 +490,13 @@ class PingService { _pingInProgress = true; try { + // For auto pings, request a fresh GPS position before validation. + // This ensures the 25m distance check and ping coordinates reflect + // where the device is NOW, not where it was at the last stream event. + if (!manual) { + await _gpsService.getFreshPosition(); + } + // Use different validation and cooldown for manual vs auto pings if (manual) { // Manual ping: 15-second cooldown, no distance check @@ -515,7 +532,11 @@ class PingService { _skipReason = 'too close'; debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); } - _scheduleNextAutoPing(); + if (_hybridModeEnabled) { + _scheduleNextHybridPing(); + } else { + _scheduleNextAutoPing(); + } } _pingInProgress = false; return false; @@ -531,15 +552,12 @@ class PingService { _pingInProgress = false; return false; } - // Use power in watts (0.3, 0.6, 1.0, 2.0) - matches web client buildPayload() - final powerWatts = _connection.deviceModel?.power ?? 0.3; - // Also get txPower in dBm for API queue (for database records) final txPowerDbm = _connection.deviceModel?.txPower ?? 22; // Build ping message (same format used for TxTracker correlation) + // Power is no longer included in the mesh message — sent per-ping in API payload final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final pingMessage = '@[MapperBot] $coordsStr [$powerStr]'; + final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -620,8 +638,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE - uses watts format like "1.0w" - await _connection.sendPing(position.latitude, position.longitude, powerWatts); + // Send ping via BLE (coordinates only — power is in API payload) + await _connection.sendPing(position.latitude, position.longitude); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -725,6 +743,7 @@ class PingService { timestamp: txTimestamp, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); @@ -808,6 +827,11 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[ACTIVE MODE] Auto ping timer fired'); + // Guard: connection may have dropped since timer was scheduled + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[ACTIVE MODE] Not connected, ignoring timer'); + return; + } // Double-check guards before sending ping if (!_autoPingEnabled || _passiveModeEnabled) { debugLog('[ACTIVE MODE] Auto mode no longer running, ignoring timer'); @@ -1081,13 +1105,19 @@ class PingService { /// Send a discovery request and start listening window Future _sendDiscoveryRequest() async { + // Guard: don't send discovery during reconnect (race with timer queue) + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + return; + } + if (!_autoPingEnabled || (!_passiveModeEnabled && !_hybridModeEnabled)) { debugLog('[DISC] Not in Passive/Hybrid Mode, skipping discovery request'); return; } - // Check GPS - final position = _gpsService.lastPosition; + // Request fresh GPS position before discovery (same rationale as TX auto-ping) + final position = await _gpsService.getFreshPosition(); if (position == null) { debugLog('[DISC] No GPS position, skipping discovery request'); _pingInProgress = false; @@ -1205,6 +1235,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); } @@ -1222,6 +1253,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[DISC] Discovery drop queued (no response)'); } @@ -1275,8 +1307,10 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 5000 + // TX uses 5s RX window, discovery uses 7s window + final listenMs = _nextPingIsDiscovery + ? _discoveryListeningWindow.inMilliseconds + : _rxListeningWindow.inMilliseconds; final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1286,6 +1320,10 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: waitMs), () { if (!_autoPingEnabled || !_hybridModeEnabled) return; + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[HYBRID] Not connected, ignoring timer'); + return; + } if (_pingInProgress) { debugLog('[HYBRID] Ping already in progress, skipping'); return; @@ -1471,6 +1509,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); // Update stats @@ -1500,6 +1539,10 @@ class PingService { _targetedTimer?.cancel(); _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[TRACE] Targeted ping timer fired'); + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[TRACE] Not connected, ignoring timer'); + return; + } if (_autoPingEnabled && _targetedModeEnabled) { if (_pingInProgress) { debugLog('[TRACE] Ping already in progress, skipping'); diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart new file mode 100644 index 00000000..6455db0b --- /dev/null +++ b/lib/utils/ping_colors.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../utils/debug_logger_io.dart'; + +/// Color vision deficiency types for accessibility. +/// +/// Users select their CVD type in Settings > General > Color Vision. +/// The app adapts all semantic colors (ping types, signal quality, +/// repeater status, noise floor) to a distinguishable palette. +enum ColorVisionType { + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) +} + +/// Immutable palette holding every semantic color the app uses. +class ColorPalette { + // Ping type colors + final Color txSuccess; + final Color txSuccessLegend; + final Color txFail; + final Color rx; + final Color discSuccess; + final Color discFail; + final Color traceSuccess; + final Color noResponse; + + // Signal quality (SNR/RSSI) traffic-light + final Color signalGood; + final Color signalMedium; + final Color signalBad; + + // Repeater status on map + final Color repeaterActive; + final Color repeaterNew; + final Color repeaterDead; + final Color repeaterDuplicate; + + // Noise floor gradient (good → medium → bad) + final Color noiseFloorGood; + final Color noiseFloorMedium; + final Color noiseFloorBad; + + const ColorPalette({ + required this.txSuccess, + required this.txSuccessLegend, + required this.txFail, + required this.rx, + required this.discSuccess, + required this.discFail, + required this.traceSuccess, + required this.noResponse, + required this.signalGood, + required this.signalMedium, + required this.signalBad, + required this.repeaterActive, + required this.repeaterNew, + required this.repeaterDead, + required this.repeaterDuplicate, + required this.noiseFloorGood, + required this.noiseFloorMedium, + required this.noiseFloorBad, + }); +} + +/// Concrete palette definitions for each CVD type. +/// +/// Color choices for CVD palettes based on Wong (2011) "Points of view: +/// Color blindness" — Nature Methods. All colors within each palette are +/// mutually distinguishable for the target CVD type. +class ColorPalettes { + ColorPalettes._(); + + /// Default palette — matches original app colors and web map squares. + /// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, + /// DEAD=#9E9689, DROP=#E04F5D + static const none = ColorPalette( + txSuccess: Color(0xFF4CAF50), + txSuccessLegend: Color(0xFF22C55E), + txFail: Color(0xFFF44336), + rx: Color(0xFF7D54C7), + discSuccess: Color(0xFF51D4E9), + discFail: Color(0xFF9E9E9E), + traceSuccess: Color(0xFF00BCD4), + noResponse: Color(0xFF9E9E9E), + signalGood: Colors.green, + signalMedium: Colors.orange, + signalBad: Colors.red, + repeaterActive: Color(0xFFD63384), + repeaterNew: Color(0xFFFD7E14), + repeaterDead: Color(0xFF6C757D), + repeaterDuplicate: Color(0xFFDC3545), + noiseFloorGood: Colors.green, + noiseFloorMedium: Colors.orange, + noiseFloorBad: Colors.red, + ); + + /// Protanopia (red-blind) — replaces red/green axis with blue/orange. + /// Also used for deuteranopia since both are red-green CVD. + static const protanopia = ColorPalette( + txSuccess: Color(0xFF0072B2), // Wong blue + txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF0072B2), + noiseFloorMedium: Color(0xFFF0E442), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. + /// Red/green distinction is preserved since tritan users can see those. + static const tritanopia = ColorPalette( + txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF009E73), + noiseFloorMedium: Color(0xFFE69F00), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Achromatopsia (monochrome) — luminance-only palette. + /// Relies on maximum brightness contrast between categories. + /// Secondary indicators (icons, text) are essential with this palette. + static const achromatopsia = ColorPalette( + txSuccess: Color(0xFFE0E0E0), // Light + txSuccessLegend: Color(0xFFE0E0E0), + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark + repeaterDuplicate: Color(0xFF424242), // Very dark + noiseFloorGood: Color(0xFFE0E0E0), + noiseFloorMedium: Color(0xFF9E9E9E), + noiseFloorBad: Color(0xFF424242), + ); + + /// Look up palette for a given CVD type. + static ColorPalette forType(ColorVisionType type) { + return switch (type) { + ColorVisionType.none => none, + ColorVisionType.protanopia => protanopia, + ColorVisionType.deuteranopia => protanopia, // Same as protanopia + ColorVisionType.tritanopia => tritanopia, + ColorVisionType.achromatopsia => achromatopsia, + }; + } +} + +/// Centralized color accessors for ping types, signal quality, repeater +/// status, and noise floor. +/// +/// All getters delegate to the active [ColorPalette], which changes when the +/// user selects a different color vision type in Settings. Existing call +/// sites (`PingColors.txSuccess`, etc.) continue to work unchanged. +class PingColors { + PingColors._(); + + static ColorPalette _activePalette = ColorPalettes.none; + static ColorVisionType _currentType = ColorVisionType.none; + + /// Set the active palette. Called by AppStateProvider when the + /// colorVisionType preference changes or on app startup. + static void setColorVisionType(ColorVisionType type) { + _currentType = type; + _activePalette = ColorPalettes.forType(type); + debugLog('[A11Y] Color palette set to ${type.name}'); + } + + /// Current CVD type (for UI display in settings). + static ColorVisionType get currentType => _currentType; + + // ── Ping type colors (same API as before) ── + static Color get txSuccess => _activePalette.txSuccess; + static Color get txSuccessLegend => _activePalette.txSuccessLegend; + static Color get txFail => _activePalette.txFail; + static Color get rx => _activePalette.rx; + static Color get discSuccess => _activePalette.discSuccess; + static Color get discFail => _activePalette.discFail; + static Color get traceSuccess => _activePalette.traceSuccess; + static Color get noResponse => _activePalette.noResponse; + + // ── Signal quality (SNR/RSSI traffic-light) ── + static Color get signalGood => _activePalette.signalGood; + static Color get signalMedium => _activePalette.signalMedium; + static Color get signalBad => _activePalette.signalBad; + + // ── Repeater status ── + static Color get repeaterActive => _activePalette.repeaterActive; + static Color get repeaterNew => _activePalette.repeaterNew; + static Color get repeaterDead => _activePalette.repeaterDead; + static Color get repeaterDuplicate => _activePalette.repeaterDuplicate; + + // ── Noise floor gradient ── + static Color get noiseFloorGood => _activePalette.noiseFloorGood; + static Color get noiseFloorMedium => _activePalette.noiseFloorMedium; + static Color get noiseFloorBad => _activePalette.noiseFloorBad; + + // ── Convenience: SNR color from value ── + static Color snrColor(double snr) { + if (snr <= -1) return signalBad; + if (snr <= 5) return signalMedium; + return signalGood; + } + + // ── Convenience: RSSI color from value ── + static Color rssiColor(int rssi) { + if (rssi >= -70) return signalGood; + if (rssi >= -100) return signalMedium; + return signalBad; + } + + // ── Convenience: Noise floor color from dBm ── + static Color noiseFloorColor(double dbm) { + if (dbm <= -100) return noiseFloorGood; + if (dbm <= -90) return noiseFloorMedium; + return noiseFloorBad; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7d2e2ef5..caa4b97b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -14,6 +14,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Map style options @@ -653,22 +654,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), children: [ // Tile layer (dynamic based on selected style from preferences) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + // Skipped entirely when map tiles are disabled to save mobile data + if (appState.preferences.mapTilesEnabled) + Builder( + builder: (context) { + final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + return TileLayer( + urlTemplate: mapStyle.urlTemplate, + subdomains: mapStyle.subdomains ?? const [], + userAgentPackageName: 'com.meshmapper.app', + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), + ); + }, + ), - // MeshMapper coverage overlay (only when zone code available and overlay enabled) - if (appState.zoneCode != null && _showMeshMapperOverlay) + // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) + if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', @@ -680,24 +683,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { tileProvider: SilentCancellableNetworkTileProvider(), ), - // TX markers (green) - MarkerLayer( - markers: _buildTxMarkers(appState.txPings), - ), - - // RX markers (colored by repeater) - MarkerLayer( - markers: _buildRxMarkers(appState.rxPings), - ), - - // DISC markers (purple circles for discovery observations) - MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), - ), - - // Trace markers (cyan/red circles for targeted ping results) + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top MarkerLayer( - markers: _buildTraceMarkers(appState.traceLogEntries), + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), ), // Repeater markers (magenta with ID, rotate with map) @@ -709,9 +703,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), - // Current position marker (car icon) + // Current position marker if (appState.currentPosition != null) MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow and walk rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk', markers: [ Marker( point: LatLng( @@ -732,10 +730,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Color for the overlay ping-type dot static Color _overlayTypeColor(OverlayPingType type) { return switch (type) { - OverlayPingType.tx => Colors.green, - OverlayPingType.disc => Colors.purple, - OverlayPingType.trace => Colors.cyan, - OverlayPingType.rx => Colors.blue, + OverlayPingType.tx => PingColors.txSuccess, + OverlayPingType.disc => PingColors.discSuccess, + OverlayPingType.trace => PingColors.traceSuccess, + OverlayPingType.rx => PingColors.rx, }; } @@ -844,12 +842,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// SNR color: green > 5, orange -1..5, red <= -1 - static Color _snrColor(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + /// SNR color (delegates to active palette) + static Color _snrColor(double snr) => PingColors.snrColor(snr); Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; @@ -904,9 +898,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Color _getAccuracyColor(double accuracy) { - if (accuracy <= 10) return Colors.green; - if (accuracy <= 30) return Colors.orange; - return Colors.red; + if (accuracy <= 10) return PingColors.signalGood; + if (accuracy <= 30) return PingColors.signalMedium; + return PingColors.signalBad; } /// Map controls (always vertical, used inside collapsible wrapper) @@ -1229,49 +1223,49 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ _buildLegendItem( context: context, - color: const Color(0xFF22C55E), + color: PingColors.txSuccessLegend, label: 'TX', description: 'Location where you sent a ping and heard a repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.red, + color: PingColors.txFail, label: 'TX', description: 'Location where you sent a ping but no repeater was heard', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF0EA5E9), + color: PingColors.rx, label: 'RX', description: 'Location where you received a message from the mesh', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF7D54C7), + color: PingColors.discSuccess, label: 'DISC', description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.cyan, + color: PingColors.traceSuccess, label: 'TRC', description: 'Location where a trace reached the repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.discFail, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.noResponse, label: 'TRC', description: 'Location where a trace got no response', ), @@ -1660,119 +1654,120 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - List _buildTxMarkers(List pings) { - return pings.map((ping) { - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? Colors.red : Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) + /// Build a coverage marker child widget based on the user's marker style preference. + Widget _buildCoverageMarkerChild(Color color) { + final style = context.read().preferences.markerStyle; + switch (style) { + case 'circle': + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.0), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], ), - ), - ); - }).toList(); + ); + case 'pin': + return CustomPaint( + size: const Size(20, 20), + painter: _PinMarkerPainter(color), + ); + case 'diamond': + return CustomPaint( + size: const Size(20, 20), + painter: _DiamondMarkerPainter(color), + ); + case 'dot': + default: + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ), + ); + } } - List _buildRxMarkers(List pings) { - return pings.map((ping) { - // Use blue to match the RX chip in status bar - const color = Colors.blue; + /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). + /// Newer pings always render on top regardless of type. + List _buildCoverageMarkers({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + final timestamped = <(DateTime, Marker)>[ + for (final ping in txPings) + (ping.timestamp, _buildTxMarker(ping)), + for (final ping in rxPings) + (ping.timestamp, _buildRxMarker(ping)), + for (final entry in discEntries) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + for (final entry in traceEntries) + (entry.timestamp, _buildTraceMarker(entry)), + ]; + + timestamped.sort((a, b) => a.$1.compareTo(b.$1)); + return timestamped.map((e) => e.$2).toList(); + } - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) - ), + Marker _buildTxMarker(TxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: _buildCoverageMarkerChild( + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, ), - ); - }).toList(); + ), + ); } - List _buildDiscMarkers(List entries, bool discDropEnabled) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - ), + Marker _buildRxMarker(RxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: _buildCoverageMarkerChild(PingColors.rx), + ), + ); + } + + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: _buildCoverageMarkerChild( + entry.nodeCount == 0 + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) + : _discMarkerColor, ), - ); - }).toList(); + ), + ); } - List _buildTraceMarkers(List entries) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.success ? Colors.cyan : Colors.grey, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - ), + Marker _buildTraceMarker(TraceLogEntry entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: _buildCoverageMarkerChild( + entry.success ? Colors.cyan : Colors.grey, ), - ); - }).toList(); + ), + ); } void _showTraceDetails(TraceLogEntry entry) { @@ -1948,32 +1943,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final localRssi = entry.localRssi ?? 0; final remoteSnr = entry.remoteSnr ?? 0; - Color rxSnrColor; - if (localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (localRssi >= -70) { - rssiColor = Colors.green; - } else if (localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(localSnr); + final rssiColor = PingColors.rssiColor(localRssi); + final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), @@ -2026,20 +1998,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// DISC marker color (#7B68EE - medium slate blue/purple) - static const Color _discMarkerColor = Color(0xFF7B68EE); + /// DISC marker color (delegates to active palette) + static Color get _discMarkerColor => PingColors.discSuccess; - /// Repeater marker color (#a52163 - magenta/pink) - Active - static const Color _repeaterMarkerColor = Color(0xFFA52163); + /// Repeater marker color - Active (delegates to active palette) + static Color get _repeaterMarkerColor => PingColors.repeaterActive; - /// Duplicate repeater marker color (#a51d2a - red) - static const Color _repeaterDuplicateColor = Color(0xFFA51D2A); + /// Duplicate repeater marker color (delegates to active palette) + static Color get _repeaterDuplicateColor => PingColors.repeaterDuplicate; - /// New repeater marker color (#c05802 - orange) - static const Color _repeaterNewColor = Color(0xFFC05802); + /// New repeater marker color (delegates to active palette) + static Color get _repeaterNewColor => PingColors.repeaterNew; - /// Dead repeater marker color (grey) - static const Color _repeaterDeadColor = Colors.grey; + /// Dead repeater marker color (delegates to active palette) + static Color get _repeaterDeadColor => PingColors.repeaterDead; /// Get set of duplicate repeater IDs Set _getDuplicateRepeaterIds(List repeaters) { @@ -2127,15 +2099,28 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Convert heading from degrees to radians // heading is 0-360 degrees, 0 = North, 90 = East final headingRadians = heading * (math.pi / 180); + final style = context.read().preferences.gpsMarkerStyle; + + // Arrow and walk rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk'; + + final CustomPainter painter; + switch (style) { + case 'car': + painter = const _CarMarkerPainter(); + case 'bike': + painter = const _BikeMarkerPainter(); + case 'boat': + painter = const _BoatMarkerPainter(); + case 'walk': + painter = const _WalkMarkerPainter(); + case 'arrow': + default: + painter = const _ArrowPainter(); + } - // Clean directional arrow - return Transform.rotate( - angle: headingRadians, - child: CustomPaint( - size: const Size(24, 24), - painter: _ArrowPainter(), - ), - ); + final child = CustomPaint(size: const Size(24, 24), painter: painter); + return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; } /// Compute node column width based on hop byte count. @@ -2184,11 +2169,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), + color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2314,29 +2299,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr == null) { - snrColor = Colors.grey; - } else if (repeater.snr! <= -1) { - snrColor = Colors.red; - } else if (repeater.snr! <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi == null) { - rssiColor = Colors.grey; - } else if (repeater.rssi! >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; + final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), @@ -2383,25 +2347,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Show RX ping details popup void _showRxPingDetails(RxPing ping) { - // Calculate SNR severity for chip color - Color snrColor; - if (ping.snr <= -1) { - snrColor = Colors.red; - } else if (ping.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color based on signal strength - Color rssiColor; - if (ping.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (ping.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); showModalBottomSheet( context: context, @@ -2622,7 +2569,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.circular(12), border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2761,33 +2708,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(node.localSnr); + final rssiColor = PingColors.rssiColor(node.localRssi); + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -2803,7 +2726,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: _discMarkerColor, @@ -3057,6 +2980,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Paints a crisp directional arrow pointing up class _ArrowPainter extends CustomPainter { + const _ArrowPainter(); + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); @@ -3094,6 +3019,320 @@ class _ArrowPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a car silhouette for GPS position marker +class _CarMarkerPainter extends CustomPainter { + const _CarMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + // Car body outline (rounded rect, slightly larger) + final outlineRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 14, height: 20), + const Radius.circular(4), + ); + canvas.drawRRect(outlineRect, outlinePaint); + + // Blue car body + final bodyPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + final bodyRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 11, height: 17), + const Radius.circular(3), + ); + canvas.drawRRect(bodyRect, bodyPaint); + + // Windshield (darker blue rectangle near top) + final windshieldPaint = Paint() + ..color = const Color(0xFF1565C0) + ..style = PaintingStyle.fill; + final windshieldRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy - 3), width: 7, height: 4), + const Radius.circular(1), + ); + canvas.drawRRect(windshieldRect, windshieldPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a bicycle silhouette for GPS position marker +class _BikeMarkerPainter extends CustomPainter { + const _BikeMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + + final bikePaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Two wheels + const wheelR = 4.0; + final leftWheel = Offset(cx - 5, cy + 3); + final rightWheel = Offset(cx + 5, cy + 3); + + // White outlines for wheels + canvas.drawCircle(leftWheel, wheelR + 1, outlinePaint); + canvas.drawCircle(rightWheel, wheelR + 1, outlinePaint); + + // Frame outline + final framePath = ui.Path() + ..moveTo(leftWheel.dx, leftWheel.dy) + ..lineTo(cx, cy - 5) // Up to handlebars + ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear + ..moveTo(cx, cy - 5) + ..lineTo(cx + 2, cy - 7); // Handlebar + canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + + // Blue wheels + canvas.drawCircle(leftWheel, wheelR, bikePaint); + canvas.drawCircle(rightWheel, wheelR, bikePaint); + + // Blue frame + canvas.drawPath(framePath, bikePaint); + + // Seat dot + canvas.drawCircle(Offset(cx - 1, cy - 4), 1.5, fillPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a boat silhouette for GPS position marker +class _BoatMarkerPainter extends CustomPainter { + const _BoatMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Hull outline (wider) + final hullOutline = ui.Path() + ..moveTo(cx - 9, cy + 1) + ..lineTo(cx - 6, cy + 8) + ..lineTo(cx + 6, cy + 8) + ..lineTo(cx + 9, cy + 1) + ..close(); + canvas.drawPath(hullOutline, outlinePaint); + + // Hull fill + final hull = ui.Path() + ..moveTo(cx - 7, cy + 2) + ..lineTo(cx - 5, cy + 7) + ..lineTo(cx + 5, cy + 7) + ..lineTo(cx + 7, cy + 2) + ..close(); + canvas.drawPath(hull, fillPaint); + + // Mast outline + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + // Mast + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + + // Sail outline + final sailOutline = ui.Path() + ..moveTo(cx + 1, cy - 8) + ..lineTo(cx + 7, cy) + ..lineTo(cx + 1, cy) + ..close(); + canvas.drawPath(sailOutline, outlinePaint); + + // Sail + final sail = ui.Path() + ..moveTo(cx + 1, cy - 7) + ..lineTo(cx + 6, cy - 0.5) + ..lineTo(cx + 1, cy - 0.5) + ..close(); + canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a walking person silhouette for GPS position marker +class _WalkMarkerPainter extends CustomPainter { + const _WalkMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round; + + final personPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Head outline + fill + canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); + + // Body outline + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), outlinePaint); + // Body + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); + + // Arms outline + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + // Arms + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + + // Left leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); + // Right leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), outlinePaint); + // Left leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), personPaint); + // Right leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), personPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a teardrop/pin marker for coverage dots +class _PinMarkerPainter extends CustomPainter { + final Color color; + const _PinMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.15) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + const headRadius = 6.0; + final headCenter = Offset(cx, cy - 2); + final tipY = cy + 9; + + // Combined shadow + final pinPath = ui.Path() + ..addOval(Rect.fromCircle(center: headCenter, radius: headRadius)) + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(pinPath, shadowPaint); + + // Triangle point + final triPath = ui.Path() + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(triPath, fillPaint); + + // Circle head + canvas.drawCircle(headCenter, headRadius, fillPaint); + canvas.drawCircle(headCenter, headRadius, outlinePaint); + + // Inner dot + canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + } + + @override + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; +} + +/// Paints a diamond marker for coverage dots +class _DiamondMarkerPainter extends CustomPainter { + final Color color; + const _DiamondMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.12) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + final path = ui.Path() + ..moveTo(cx, cy - 8) // Top + ..lineTo(cx + 8, cy) // Right + ..lineTo(cx, cy + 8) // Bottom + ..lineTo(cx - 8, cy) // Left + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, outlinePaint); + } + + @override + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index aa760b46..568807cf 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan @@ -482,25 +483,8 @@ class InteractiveNoiseFloorChartState extends State /// Build a table row for a repeater (matching TX log style) Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { - // SNR color: good (>5), fair (-1 to 5), poor (<-1) - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = PingColors.snrColor(repeater.snr); + final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), @@ -702,20 +686,20 @@ class InteractiveNoiseFloorChartState extends State return ((dbm - minY) / yRange).clamp(0.0, 1.0); } - // Smooth gradient with faded transitions + // Smooth gradient with faded transitions (palette-aware) final lineColors = [ - Colors.green, - Colors.green, - Colors.orange, - Colors.red, - Colors.red, + PingColors.noiseFloorGood, + PingColors.noiseFloorGood, + PingColors.noiseFloorMedium, + PingColors.noiseFloorBad, + PingColors.noiseFloorBad, ]; final fillColors = [ - Colors.green.withValues(alpha: 0.2), - Colors.green.withValues(alpha: 0.15), - Colors.orange.withValues(alpha: 0.12), - Colors.red.withValues(alpha: 0.1), - Colors.red.withValues(alpha: 0.08), + PingColors.noiseFloorGood.withValues(alpha: 0.2), + PingColors.noiseFloorGood.withValues(alpha: 0.15), + PingColors.noiseFloorMedium.withValues(alpha: 0.12), + PingColors.noiseFloorBad.withValues(alpha: 0.1), + PingColors.noiseFloorBad.withValues(alpha: 0.08), ]; final stops = [ 0.0, @@ -848,12 +832,12 @@ class InteractiveNoiseFloorChartState extends State runSpacing: 8, alignment: WrapAlignment.center, children: [ - _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX Fail'), - _legendItem(context, Colors.blue, 'RX'), - _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.cyan, 'Trace Success'), - _legendItem(context, Colors.grey, 'No Response'), + _legendItem(context, PingColors.txSuccess, 'TX Success'), + _legendItem(context, PingColors.txFail, 'TX Fail'), + _legendItem(context, PingColors.rx, 'RX'), + _legendItem(context, PingColors.discSuccess, 'DISC Success'), + _legendItem(context, PingColors.traceSuccess, 'Trace Success'), + _legendItem(context, PingColors.noResponse, 'No Response'), ], ); } diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 805417de..c05b96f0 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -86,6 +86,7 @@ class PingControls extends StatelessWidget { // Action buttons row Row( children: [ + if (!txNotAllowed) ...[ // Send Ping button // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" // Manual pings use 15-second cooldown, no distance requirement @@ -169,6 +170,7 @@ class PingControls extends StatelessWidget { ), ), const SizedBox(width: 10), + ], // Passive Mode button (toggle) // When ON: shows "Listening..." → "Next Disc Xs" cycle @@ -934,6 +936,7 @@ class _CompactPingControlsState extends State { // - Grey non-expanded buttons are icon-only return Row( children: [ + if (!txNotAllowed) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -951,6 +954,7 @@ class _CompactPingControlsState extends State { else activeModeButton, const SizedBox(width: 6), + ], // Passive Mode if (passiveModeExpanded) @@ -1167,6 +1171,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ + if (!txNotAllowed) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( @@ -1219,6 +1224,7 @@ class LandscapePingControls extends StatelessWidget { ), ), const SizedBox(width: 8), + ], // Passive Mode button Expanded( diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 0088da57..d2ae8e24 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -7,6 +7,7 @@ import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// @@ -201,8 +202,9 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Calculate distance string if GPS is available String? distanceText; @@ -273,7 +275,7 @@ class RepeaterIdChip extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -281,13 +283,20 @@ class RepeaterIdChip extends StatelessWidget { borderRadius: BorderRadius.circular(10), border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - statusText, - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index a10fa2da..f78950b5 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -6,6 +6,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Show a bottom sheet repeater picker and return the selected repeater. Future showRepeaterPicker(BuildContext context) { @@ -226,7 +227,8 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; @@ -315,7 +317,7 @@ class _RepeaterTile extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -324,13 +326,20 @@ class _RepeaterTile extends StatelessWidget { border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - isActive ? 'Active' : 'Stale', - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 563d28f6..403b6d99 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Status bar showing GPS, connection, and queue status class StatusBar extends StatefulWidget { @@ -150,16 +151,16 @@ class _StatusBarState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -239,7 +240,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_upward, value: appState.pingStats.txCount, - color: Colors.green, + color: PingColors.txSuccess, onTap: () => _showInfoPopup(context, 'tx'), ), @@ -249,7 +250,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_downward, value: appState.pingStats.rxCount, - color: Colors.blue, + color: PingColors.rx, onTap: () => _showInfoPopup(context, 'rx'), ), @@ -259,7 +260,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.radar, value: appState.pingStats.discCount, - color: const Color(0xFF7B68EE), // DISC purple + color: PingColors.discSuccess, onTap: () => _showInfoPopup(context, 'disc'), ), @@ -269,7 +270,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.route, value: appState.pingStats.traceCount, - color: Colors.cyan, + color: PingColors.traceSuccess, onTap: () => _showInfoPopup(context, 'trace'), ), From 4d5ecfc9284ef52200397303d2071e73d813be8d Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:18:46 -0400 Subject: [PATCH 040/360] Release of v1.2.1 (#17) (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. * - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. - Map coverage dots now draw in chronological order instead of by category. When your path crosses over itself, the most recent ping always renders on top regardless of type. - Reduced the thick white outline on map coverage dots to a subtle semi-transparent border, improving readability when markers cluster together. Repeater ID markers retain their original styling. * - Fixed inconsistent audio playback on Android where notification sounds (TX/RX pings) would work reliably in some sessions but not at all in others. * - When a zone reports TX capacity full or TX not allowed, Send Ping and Active/Hybrid mode buttons are now hidden instead of showing disabled "Zone Full" states. The Passive mode button expands to fill the full control panel width. Applies to portrait, compact, and landscape layouts. * - Fixed an error when switching from Offline Mode back to Online Mode while connected. The app would display "Missing contact_uri or iata for registration" instead of resuming online wardriving. The mode switch now correctly re-establishes your zone before authenticating with the server. If zone detection fails (e.g., poor GPS or no internet), a clear error message is shown instead. * - Map Marker Style: choose between four coverage marker shapes under Settings > General. Dot (default, subtle border), Outlined Dot (prominent white border), Pin (teardrop), and Diamond. - GPS Marker: choose your position icon on the map. Arrow (default), Car, Bike, Boat, or Walk. Arrow and Walk rotate with heading; vehicle icons stay upright. - Sound Notifications: now under Settings > General with independent toggles for "Ping Sent" (TX/Discovery) and "Response Received" (repeater echo/RX). * Fixed new pin marker * - Fixed iOS reconnect failures when the device drops BLE bond keys mid-session (every 13-23 min on affected devices). iOS would cache stale keys and all reconnect attempts would fail, requiring a manual forget/re-pair in Settings. The app now detects the pairing error and clears the stale bond automatically before retrying * ⏺ TX Power Reporting: Removed radio power from the BLE mesh message ([0.3w]) and moved it to a per-ping power field in every API wardrive data post (TX, RX, DISC, TRACE). Previously, the power value embedded in the mesh broadcast fell back to 0.3W for devices not in the model database, causing incorrect wattage in the admin panel even when the user had configured the correct power. Power is now read from user preferences and sent directly to the API with each ping, ensuring accurate reporting regardless of device model recognition. * Fix hybrid mode fallback, discovery window timing, connection lifecycle races, and auto-ping guards - Hybrid mode: route blocked TX pings back to hybrid scheduler instead of active-only - Discovery window: increase from 5s to 7s, fix hybrid interval math - Add apple-code 15 to bond error detection - Replace session deadline timer with 15-min idle disconnect timer - Enable heartbeat immediately on connect, keep alive while connected - Add idempotency guard to _fullDisconnectCleanup() for double-call race - Add _disposed flag and connect() guard for controller-closed race - Add reconnect/connection guards to sendPing, sendTxPing, _sendDiscoveryRequest, and all auto-ping timer callbacks - Throttle geofence logs to 1 per 30s with suppressed count * Colorblind Accessibility Setting — COMPLETED Status: Implementation complete, pending commit Approach Convert PingColors from static const fields to static get properties backed by a swappable palette. Add a colorVisionType user preference. All 7+ files that reference PingColors.txSuccess etc. continue to work without call-site changes -- the getters return the active palette's colors. CVD Types Supported ┌───────────────┬────────────────────────────────────┐ │ Setting │ Description │ ├───────────────┼────────────────────────────────────┤ │ none │ Default (current colors) │ ├───────────────┼────────────────────────────────────┤ │ protanopia │ Red-blind -- confuses red/green │ ├───────────────┼────────────────────────────────────┤ │ deuteranopia │ Green-blind -- confuses red/green │ ├───────────────┼────────────────────────────────────┤ │ tritanopia │ Blue-blind -- confuses blue/yellow │ ├───────────────┼────────────────────────────────────┤ │ achromatopsia │ Total color blindness (monochrome) │ └───────────────┴────────────────────────────────────┘ Palettes (Wong 2011 colorblind-safe colors) Normal -- current app colors unchanged. Protanopia / Deuteranopia (both use same palette since both are red-green CVD): - txSuccess: #0072B2 (blue) -- replaces green - txFail: #D55E00 (vermillion) -- replaces red - txSuccessLegend: #56B4E9 (sky blue) - rx: #CC79A7 (reddish purple) - discSuccess: #56B4E9 (sky blue) - traceSuccess: #009E73 (bluish green) - signalGood/Medium/Bad: #0072B2 / #F0E442 / #D55E00 - repeaterActive/New/Dead/Duplicate: #CC79A7 / #F0E442 / #9E9E9E / #D55E00 Tritanopia: - txSuccess: #009E73 (bluish green) -- green visible - txFail: #D55E00 (vermillion) - txSuccessLegend: #22C55E - rx: #CC79A7 (reddish purple) - discSuccess: #E69F00 (orange) -- replaces cyan - traceSuccess: #D55E00 (vermillion) -- replaces cyan - signalGood/Medium/Bad: #009E73 / #E69F00 / #D55E00 - repeaterActive/New/Dead/Duplicate: #CC79A7 / #E69F00 / #9E9E9E / #D55E00 Achromatopsia: - txSuccess: #E0E0E0 (light) / txFail: #616161 (dark) - txSuccessLegend: #E0E0E0 - rx: #9E9E9E (medium) - discSuccess: #BDBDBD (medium-light) / traceSuccess: #757575 (medium-dark) - signalGood/Medium/Bad: #E0E0E0 / #9E9E9E / #424242 - repeaterActive/New/Dead/Duplicate: #E0E0E0 / #BDBDBD / #616161 / #424242 * - Disconnect alert: triple-beep sound when BLE drops during wardriving, so you know pings have stopped. Has its own toggle, independent from the master sound setting, so you can wardrive silently and still get alerted on disconnect * Updating the four repeater colors in the default palette to match the web * - Fixed auto-ping using stale GPS positions. The position stream and ping timers run independently, so the cached position could be well behind your actual location. Auto-pings now force a fresh GPS read before each ping --- lib/models/api_queue_item.dart | 19 + lib/models/noise_floor_session.dart | 15 +- lib/models/user_preferences.dart | 52 +- lib/providers/app_state_provider.dart | 198 ++++- lib/screens/connection_screen.dart | 2 +- lib/screens/home_screen.dart | 21 +- lib/screens/log_screen.dart | 48 +- lib/screens/settings_screen.dart | 246 +++++- lib/services/api_queue_service.dart | 10 + lib/services/api_service.dart | 37 +- lib/services/audio_service.dart | 183 ++-- lib/services/bluetooth/bluetooth_service.dart | 6 + lib/services/bluetooth/mobile_bluetooth.dart | 36 +- lib/services/bluetooth/web_bluetooth.dart | 5 + lib/services/gps_service.dart | 24 + lib/services/meshcore/connection.dart | 62 +- lib/services/meshcore/disc_tracker.dart | 2 +- lib/services/ping_service.dart | 71 +- lib/utils/ping_colors.dart | 250 ++++++ lib/widgets/map_widget.dart | 785 ++++++++++++------ lib/widgets/noise_floor_chart.dart | 56 +- lib/widgets/ping_controls.dart | 6 + lib/widgets/repeater_id_chip.dart | 27 +- lib/widgets/repeater_picker_sheet.dart | 27 +- lib/widgets/status_bar.dart | 17 +- 25 files changed, 1684 insertions(+), 521 deletions(-) create mode 100644 lib/utils/ping_colors.dart diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 337cab1e..0f58d317 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -52,6 +52,10 @@ class ApiQueueItem extends HiveObject { @HiveField(14) final bool externalAntenna; + /// Radio power in watts (e.g., 0.3, 1.0, 2.0) — included in every API post + @HiveField(15) + final double? power; + ApiQueueItem({ required this.type, required this.latitude, @@ -63,6 +67,7 @@ class ApiQueueItem extends HiveObject { this.retryCount = 0, this.lastRetryAt, this.noiseFloor, + this.power, }); /// Create from TX ping @@ -74,6 +79,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'TX', @@ -84,6 +90,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -96,6 +103,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'RX', @@ -106,6 +114,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -123,6 +132,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; @@ -135,6 +145,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -150,6 +161,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( @@ -161,6 +173,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -171,6 +184,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'DISC', @@ -181,6 +195,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -201,6 +216,7 @@ class ApiQueueItem extends HiveObject { 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -216,6 +232,7 @@ class ApiQueueItem extends HiveObject { 'repeater_id': 'None', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -234,6 +251,7 @@ class ApiQueueItem extends HiveObject { 'public_key': parts.length > 5 ? parts[5] : '', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -245,6 +263,7 @@ class ApiQueueItem extends HiveObject { 'heard_repeats': heardRepeats, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 9c6b574d..6bd9fbf3 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import '../utils/ping_colors.dart'; part 'noise_floor_session.g.dart'; @@ -100,13 +101,13 @@ class PingEventMarker extends HiveObject { /// Get the color for this event type Color get color => switch (type) { - PingEventType.txSuccess => Colors.green, - PingEventType.txFail => Colors.red, - PingEventType.rx => Colors.blue, - PingEventType.discSuccess => Colors.purple, - PingEventType.discFail => Colors.grey, - PingEventType.traceSuccess => Colors.cyan, - PingEventType.traceFail => Colors.grey, + PingEventType.txSuccess => PingColors.txSuccess, + PingEventType.txFail => PingColors.txFail, + PingEventType.rx => PingColors.rx, + PingEventType.discSuccess => PingColors.discSuccess, + PingEventType.discFail => PingColors.discFail, + PingEventType.traceSuccess => PingColors.traceSuccess, + PingEventType.traceFail => PingColors.noResponse, }; /// Get a display label for this event type diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 4063fc8b..3275a1fd 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -85,6 +85,21 @@ class UserPreferences { /// Show top 3 repeaters by SNR on the map during wardriving final bool showTopRepeaters; + /// Coverage marker style on the map (dot, pin, diamond) + final String markerStyle; + + /// GPS position marker style (arrow, car, bike, boat, walk) + final String gpsMarkerStyle; + + /// Color vision type for accessibility (none, protanopia, deuteranopia, tritanopia, achromatopsia) + final String colorVisionType; + + /// Download map tiles (base map + coverage overlay). When false, no tile network requests are made to save mobile data. + final bool mapTilesEnabled; + + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) + final bool disconnectAlertEnabled; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -114,6 +129,11 @@ class UserPreferences { this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, this.showTopRepeaters = false, + this.markerStyle = 'dot', + this.gpsMarkerStyle = 'arrow', + this.colorVisionType = 'none', + this.mapTilesEnabled = true, + this.disconnectAlertEnabled = false, }); /// Create from JSON (for persistence) @@ -147,6 +167,11 @@ class UserPreferences { minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, + markerStyle: (json['markerStyle'] as String?) ?? 'dot', + gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + colorVisionType: (json['colorVisionType'] as String?) ?? 'none', + mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, + disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, ); } @@ -181,6 +206,11 @@ class UserPreferences { 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, 'showTopRepeaters': showTopRepeaters, + 'markerStyle': markerStyle, + 'gpsMarkerStyle': gpsMarkerStyle, + 'colorVisionType': colorVisionType, + 'mapTilesEnabled': mapTilesEnabled, + 'disconnectAlertEnabled': disconnectAlertEnabled, }; } @@ -214,6 +244,11 @@ class UserPreferences { int? minPingDistanceMeters, bool? autoStopAfterIdle, bool? showTopRepeaters, + String? markerStyle, + String? gpsMarkerStyle, + String? colorVisionType, + bool? mapTilesEnabled, + bool? disconnectAlertEnabled, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -244,6 +279,11 @@ class UserPreferences { minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, + markerStyle: markerStyle ?? this.markerStyle, + gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, + colorVisionType: colorVisionType ?? this.colorVisionType, + mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, + disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, ); } @@ -302,7 +342,12 @@ class UserPreferences { other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && - other.showTopRepeaters == showTopRepeaters; + other.showTopRepeaters == showTopRepeaters && + other.markerStyle == markerStyle && + other.gpsMarkerStyle == gpsMarkerStyle && + other.colorVisionType == colorVisionType && + other.mapTilesEnabled == mapTilesEnabled && + other.disconnectAlertEnabled == disconnectAlertEnabled; } @override @@ -335,6 +380,11 @@ class UserPreferences { minPingDistanceMeters, autoStopAfterIdle, showTopRepeaters, + markerStyle, + gpsMarkerStyle, + colorVisionType, + mapTilesEnabled, + disconnectAlertEnabled, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e090f55d..712f8cf7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -37,6 +37,7 @@ import '../services/meshcore/unified_rx_handler.dart'; import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../utils/constants.dart'; +import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -260,6 +261,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); + static const Duration _reconnectDelayAfterBondError = Duration(seconds: 5); + bool _lastReconnectWasBondError = false; + + // Idle disconnect timer — disconnects after 15 min without manual ping or auto-ping + Timer? _idleDisconnectTimer; + static const Duration _idleDisconnectTimeout = Duration(minutes: 15); + + // Geofence zone check log throttle (while disconnected) + DateTime? _lastZoneCheckLogTime; + int _zoneCheckSuppressedCount = 0; // Map navigation trigger (for navigating to log entry coordinates) ({double lat, double lon})? _mapNavigationTarget; @@ -487,6 +498,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; + bool get isTxSoundEnabled => _audioService.isTxEnabled; + bool get isRxSoundEnabled => _audioService.isRxEnabled; + bool get isDisconnectAlertEnabled => _preferences.disconnectAlertEnabled; AudioService get audioService => _audioService; bool get isConnected => _connectionStep == ConnectionStep.connected; @@ -740,7 +754,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + // Throttle log to once per 30s to avoid spam while driving + final now = DateTime.now(); + if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_zoneCheckSuppressedCount > 0) { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + } else { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + } + _lastZoneCheckLogTime = now; + _zoneCheckSuppressedCount = 0; + } else { + _zoneCheckSuppressedCount++; + } await checkZoneStatus(); } @@ -1310,6 +1336,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Get external antenna value for API payloads _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + // Get power level from preferences (includes per-device overrides and manual selection) + _pingService!.getPowerLevel = () => _preferences.powerLevel; + // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; @@ -1707,6 +1736,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!_preferences.offlineMode) { _startZoneRefreshTimer(); } + + // Enable heartbeat immediately on connection to keep server session alive + // Previously only enabled on auto-ping start, causing silent session expiry + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); + } + + // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) + _startIdleDisconnectTimer(); } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1955,6 +2000,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: entry.repeaterId, externalAntenna: _preferences.externalAntenna, noiseFloor: _meshCoreConnection?.lastNoiseFloor, + power: _preferences.powerLevel, ); // Update UI @@ -2167,6 +2213,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } Future _fullDisconnectCleanup() async { + // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) + if (_connectionStep == ConnectionStep.disconnected) { + debugLog('[CONN] Already disconnected, skipping duplicate cleanup'); + return; + } _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; @@ -2182,6 +2233,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { + if (!_userRequestedDisconnect) { + _playDisconnectAlert(); + } _autoPingEnabled = false; _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); @@ -2237,8 +2291,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { _cancelPendingAutoPingRestore(); + _cancelIdleDisconnectTimer(); _isAutoReconnecting = true; _reconnectAttempt = 0; + _lastReconnectWasBondError = false; _connectionStep = ConnectionStep.reconnecting; // Remember auto-ping state before cleanup @@ -2307,8 +2363,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); + // Use longer delay after bond errors to give iOS time to clear stale keys + final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + // Delay before attempting reconnection - _reconnectTimer = Timer(_reconnectDelay, () async { + _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { @@ -2318,6 +2377,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again @@ -2329,6 +2389,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { + // Check for iOS apple-code 14 (Peer removed pairing information) + // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying + await _handleBondErrorIfNeeded(e); + // Reset step back to reconnecting for UI _connectionStep = ConnectionStep.reconnecting; _connectionError = null; @@ -2339,6 +2403,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); } + /// Start 15-minute idle disconnect timer. + /// Fires if user does not send a manual ping or start auto-ping within 15 minutes. + void _startIdleDisconnectTimer() { + _idleDisconnectTimer?.cancel(); + _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { + if (!isConnected || _autoPingEnabled) return; + debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); + logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + disconnect(); + }); + debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + } + + /// Cancel the idle disconnect timer + void _cancelIdleDisconnectTimer() { + if (_idleDisconnectTimer != null) { + _idleDisconnectTimer!.cancel(); + _idleDisconnectTimer = null; + debugLog('[IDLE] Idle disconnect timer cancelled'); + } + } + + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry + Future _handleBondErrorIfNeeded(Object error) async { + final errorStr = error.toString(); + if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + _lastReconnectWasBondError = true; + final deviceId = _rememberedDevice?.id; + if (deviceId != null) { + debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + await _bluetoothService.removeBond(deviceId); + } + } + } + /// Called when auto-reconnect succeeds void _onReconnectSuccess() { // Cancel timers @@ -2378,6 +2477,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); + } else { + // No auto-ping to restore — start idle timer + _startIdleDisconnectTimer(); } notifyListeners(); @@ -2398,6 +2500,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimeoutTimer = null; _cancelPendingAutoPingRestore(); + // Alert if auto-ping was running before disconnect + if (_autoPingWasEnabled) { + _playDisconnectAlert(); + } + // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; @@ -2423,6 +2530,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + // Cancel any active auto-reconnect _reconnectTimer?.cancel(); _reconnectTimer = null; @@ -2601,6 +2711,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Send a manual TX ping Future sendPing() async { if (_pingService == null) return false; + if (_isAutoReconnecting) { + debugLog('[PING] Ignoring ping during auto-reconnect'); + return false; + } // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { @@ -2608,6 +2722,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!sessionCheck) return false; } + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + // Set sending state immediately for instant UI feedback _isPingSending = true; notifyListeners(); @@ -2648,6 +2765,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Auto-stop auto-ping after prolonged idle (no movement) void _triggerIdleAutoStop() { if (!_autoPingEnabled) return; + _playDisconnectAlert(); final elapsed = _idleAutoStopReference != null ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; @@ -2706,8 +2824,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // End noise floor session when mode is disabled await _endNoiseFloorSession(); - // Disable heartbeat when stopping auto mode - _apiService.disableHeartbeat(); + // Keep heartbeat enabled (stays on while connected to prevent session expiry) + // Re-start idle disconnect timer now that user is idle again + _startIdleDisconnectTimer(); _autoPingEnabled = false; _idleAutoStopReference = null; @@ -2724,6 +2843,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); + // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { final sessionCheck = await _checkSessionBeforeAction(); @@ -3062,6 +3184,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } + // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + debugLog('[APP] Re-checking zone status before auth...'); + await checkZoneStatus(); + + if (zoneCode == null) { + debugError('[APP] Cannot switch to online mode: not in a zone'); + _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + return (success: false, error: _modeSwitchError); + } + // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ @@ -3189,12 +3321,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } - // 7. Re-check zone status - if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after online mode enabled'); - await checkZoneStatus(); - } - debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { @@ -3702,6 +3828,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Set color vision type for accessibility and persist + void setColorVisionType(String type) { + _preferences = _preferences.copyWith(colorVisionType: type); + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ); + debugLog('[A11Y] Color vision type set to $type'); + notifyListeners(); + _savePreferences(); + } + /// Set unit system preference (metric or imperial) void setUnitSystem(String system) { _preferences = _preferences.copyWith(unitSystem: system); @@ -3754,6 +3891,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Set TX sound enabled state (ping sent / discovery sent) + Future setTxSoundEnabled(bool enabled) async { + await _audioService.setTxEnabled(enabled); + notifyListeners(); + } + + /// Set RX sound enabled state (repeater echo / RX observation) + Future setRxSoundEnabled(bool enabled) async { + await _audioService.setRxEnabled(enabled); + notifyListeners(); + } + + /// Set disconnect alert enabled state + Future setDisconnectAlertEnabled(bool enabled) async { + _preferences = _preferences.copyWith(disconnectAlertEnabled: enabled); + await _savePreferences(); + debugLog('[AUDIO] Disconnect alert ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) + void _playDisconnectAlert() { + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); + _audioService.playAlertSound(); + } + /// Navigate to coordinates on map (triggered from log entries) void navigateToMapCoordinates(double latitude, double longitude) { _mapNavigationTarget = (lat: latitude, lon: longitude); @@ -3901,6 +4065,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _handleMaintenanceModeConnected(String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); + // Alert if auto-ping was running (maintenance is not user-initiated) + if (_autoPingEnabled) { + _playDisconnectAlert(); + } + // Log to error log (this sets _requestErrorLogSwitch = true) logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); @@ -4710,6 +4879,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Apply saved min ping distance to GpsService and PingService _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; + + // Apply saved color vision type + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere( + (e) => e.name == _preferences.colorVisionType, + orElse: () => ColorVisionType.none, + ), + ); } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -5088,6 +5265,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); _restoreAutoPingTimer?.cancel(); + _idleDisconnectTimer?.cancel(); _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _tileRefreshTimer?.cancel(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ce97f871..993466e3 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -439,7 +439,7 @@ class _ConnectionScreenState extends State with WidgetsBinding } // Portrait: compact vertical layout (bottom bar provided by _buildBody) - return Padding( + return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d984a989..0064326f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import '../widgets/connection_panel.dart'; import '../widgets/map_widget.dart'; import '../widgets/ping_controls.dart'; @@ -155,7 +156,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_upward, appState.pingStats.txCount, - Colors.green, + PingColors.txSuccess, onTap: withTapHandlers ? () => _showInfoPopup('tx', appState) : null, ), const SizedBox(width: 8), @@ -163,7 +164,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_downward, appState.pingStats.rxCount, - Colors.blue, + PingColors.rx, onTap: withTapHandlers ? () => _showInfoPopup('rx', appState) : null, ), const SizedBox(width: 8), @@ -171,7 +172,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.radar, appState.pingStats.discCount, - const Color(0xFF7B68EE), + PingColors.discSuccess, onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), @@ -179,7 +180,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.route, appState.pingStats.traceCount, - Colors.cyan, + PingColors.traceSuccess, onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), @@ -331,16 +332,16 @@ class _HomeScreenState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -998,9 +999,7 @@ class _HomeScreenState extends State { /// Get color based on noise floor value (lower is better) Color _getNoiseFloorColor(int noiseFloor) { - if (noiseFloor <= -100) return Colors.green; // -100 to -120: great - if (noiseFloor <= -90) return Colors.orange; // -90 to -100: okay - return Colors.red; // 0 to -90: bad + return PingColors.noiseFloorColor(noiseFloor.toDouble()); } /// Get battery icon based on percentage diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 97267e0b..565f3920 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/log_entry.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; /// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors @@ -434,13 +435,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), ], ), ), @@ -548,10 +549,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Widget _buildTypeBadge(PingLogType type) { final (label, color) = switch (type) { - PingLogType.tx => ('TX', Colors.green), - PingLogType.rx => ('RX', Colors.blue), - PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), - PingLogType.trace => ('TRC', Colors.cyan), + PingLogType.tx => ('TX', PingColors.txSuccess), + PingLogType.rx => ('RX', PingColors.rx), + PingLogType.disc => ('DISC', PingColors.discSuccess), + PingLogType.trace => ('TRC', PingColors.traceSuccess), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -808,14 +809,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -830,10 +824,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: Color(0xFF7B68EE), + color: PingColors.discSuccess, ), ), ], @@ -984,29 +978,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Color _snrColor(SnrSeverity? severity) { return switch (severity) { - SnrSeverity.poor => Colors.red, - SnrSeverity.fair => Colors.orange, - SnrSeverity.good => Colors.green, + SnrSeverity.poor => PingColors.signalBad, + SnrSeverity.fair => PingColors.signalMedium, + SnrSeverity.good => PingColors.signalGood, null => Colors.grey, }; } - static Color _snrColorFromValue(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + static Color _snrColorFromValue(double snr) => PingColors.snrColor(snr); static Color _snrColorFromNullableValue(double? snr) { if (snr == null) return Colors.grey; - return _snrColorFromValue(snr); + return PingColors.snrColor(snr); } static Color _rssiColor(int? rssi) { if (rssi == null) return Colors.grey; - if (rssi >= -70) return Colors.green; - if (rssi >= -100) return Colors.orange; - return Colors.red; + return PingColors.rssiColor(rssi); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c99446f8..b0013667 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -146,6 +146,26 @@ class _SettingsScreenState extends State { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + SwitchListTile( + secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + title: const Text('Disable Map Tiles'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Map and coverage tiles load normally' + : 'Disabled to save mobile data'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + }, + ), + ListTile( + leading: const Icon(Icons.visibility), + title: const Text('Color Vision'), + subtitle: Text(_colorVisionLabel(prefs.colorVisionType)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showColorVisionSelector(context, appState), + ), SwitchListTile( secondary: Icon( prefs.isImperial ? Icons.square_foot : Icons.straighten, @@ -166,8 +186,50 @@ class _SettingsScreenState extends State { appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + ListTile( + leading: const Icon(Icons.place), + title: const Text('Map Marker Style'), + subtitle: Text(_markerStyleLabel(prefs.markerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMarkerStyleSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.my_location), + title: const Text('GPS Marker'), + subtitle: Text(_gpsMarkerLabel(prefs.gpsMarkerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showGpsMarkerSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), + ], ]), // Ping Settings @@ -207,13 +269,6 @@ class _SettingsScreenState extends State { enabled: !isAutoMode, onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), ), - SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), - ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), @@ -879,6 +934,179 @@ class _SettingsScreenState extends State { ); } + String _markerStyleLabel(String style) { + switch (style) { + case 'circle': return 'Outlined Dot'; + case 'pin': return 'Pin'; + case 'diamond': return 'Diamond'; + case 'dot': + default: return 'Dot'; + } + } + + String _gpsMarkerLabel(String style) { + switch (style) { + case 'car': return 'Car'; + case 'bike': return 'Bike'; + case 'boat': return 'Boat'; + case 'walk': return 'Walk'; + case 'arrow': + default: return 'Arrow'; + } + } + + void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('dot', 'Dot', Icons.circle), + ('circle', 'Outlined Dot', Icons.circle_outlined), + ('pin', 'Pin', Icons.place), + ('diamond', 'Diamond', Icons.diamond), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.markerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + void _showGpsMarkerSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('arrow', 'Arrow', Icons.navigation), + ('car', 'Car', Icons.directions_car), + ('bike', 'Bike', Icons.directions_bike), + ('boat', 'Boat', Icons.directions_boat), + ('walk', 'Walk', Icons.directions_walk), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.gpsMarkerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + String _colorVisionLabel(String type) { + return switch (type) { + 'protanopia' => 'Protanopia (red-blind)', + 'deuteranopia' => 'Deuteranopia (green-blind)', + 'tritanopia' => 'Tritanopia (blue-blind)', + 'achromatopsia' => 'Achromatopsia (monochrome)', + _ => 'Default', + }; + } + + void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('none', 'Default', 'Standard color palette'), + ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), + ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), + ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), + ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.colorVisionType, + onChanged: (v) { + if (v != null) appState.setColorVisionType(v); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.visibility), + title: Text(label), + subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null, + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + Widget _buildSection(BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 2cb45faf..eb8b70fc 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -223,6 +223,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -231,6 +232,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -266,6 +268,7 @@ class ApiQueueService { required String repeaterId, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromRx( latitude: latitude, @@ -274,6 +277,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -309,6 +313,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDisc( latitude: latitude, @@ -322,6 +327,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -358,6 +364,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTrace( latitude: latitude, @@ -369,6 +376,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -401,6 +409,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDiscDrop( latitude: latitude, @@ -408,6 +417,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 064c25a5..2da5d0ce 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -40,7 +40,7 @@ class ApiService { int? _sessionExpiresAt; Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; - Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; @@ -607,8 +607,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -642,9 +640,6 @@ class ApiService { _sendScheduledHeartbeat(); }); } - - // Schedule session deadline timer at exact expiry - _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -693,32 +688,6 @@ class ApiService { } } - /// Schedule a hard deadline timer at the exact session expiry time. - /// If the server is unreachable and all heartbeat retries fail, this fires - /// and triggers the same disconnect flow as a server-returned session_expired. - void _scheduleSessionDeadline(int expiresAt) { - _sessionDeadlineTimer?.cancel(); - if (!_heartbeatEnabled) return; - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final secondsUntilExpiry = expiresAt - now; - - if (secondsUntilExpiry <= 0) { - _onSessionDeadlineReached(); - return; - } - - debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); - _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); - } - - /// Called when the session deadline timer fires — server was unreachable - void _onSessionDeadlineReached() { - debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); - _clearSession(); - onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); - } - /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; @@ -736,8 +705,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -982,8 +949,6 @@ class ApiService { _heartbeatTimer = null; _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 41898ca0..6b74c07c 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,11 +11,23 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txEnabledKey = 'tx_sound_enabled'; + static const String _rxEnabledKey = 'rx_sound_enabled'; + static const String _txAsset = 'assets/transmitted_packet.mp3'; + static const String _rxAsset = 'assets/received_packet.mp3'; + + /// Delay before releasing audio focus after the last sound plays. + /// Prevents rapid activate/deactivate cycles that break Android audio, + /// while still releasing focus for Android Auto ducking. + static const Duration _focusReleaseDelay = Duration(seconds: 3); AudioPlayer? _txPlayer; AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it + bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) + Timer? _focusReleaseTimer; /// Whether the audio service is initialized bool get isInitialized => _initialized; @@ -23,6 +35,12 @@ class AudioService { /// Whether sound notifications are enabled bool get isEnabled => _enabled; + /// Whether TX sound is enabled (ping sent / discovery sent) + bool get isTxEnabled => _txEnabled; + + /// Whether RX sound is enabled (repeater echo / RX observation) + bool get isRxEnabled => _rxEnabled; + /// Initialize the audio service and pre-load sounds Future initialize() async { if (_initialized) return; @@ -66,9 +84,8 @@ class AudioService { _rxPlayer = AudioPlayer(); // Pre-load the audio assets for instant playback - // Using asset:// prefix for just_audio - await _txPlayer!.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer!.setAsset('assets/received_packet.mp3'); + await _txPlayer!.setAsset(_txAsset); + await _rxPlayer!.setAsset(_rxAsset); _initialized = true; debugLog('[AUDIO] Audio service initialized, enabled=$_enabled'); @@ -92,9 +109,15 @@ class AudioService { } else { debugLog('[AUDIO] No saved preference, using default: $_enabled'); } + + final txEnabled = box.get(_txEnabledKey); + if (txEnabled != null) _txEnabled = txEnabled as bool; + final rxEnabled = box.get(_rxEnabledKey); + if (rxEnabled != null) _rxEnabled = rxEnabled as bool; + debugLog('[AUDIO] Loaded sub-toggles: tx=$_txEnabled, rx=$_rxEnabled'); } catch (e) { debugError('[AUDIO] Failed to load enabled state: $e'); - // Keep default (disabled) + // Keep defaults } } @@ -147,55 +170,79 @@ class AudioService { /// Play the transmit sound (when TX ping or Discovery request is sent) Future playTransmitSound() async { - debugLog('[AUDIO] playTransmitSound called - initialized=$_initialized, enabled=$_enabled'); - if (!_initialized || !_enabled) { - debugLog('[AUDIO] playTransmitSound skipped - not initialized or disabled'); - return; - } + if (!_txEnabled) return; + await _playSound(_txPlayer, _txAsset, 'TX'); + } + + /// Play the receive sound (when repeater echo or RX observation is detected) + Future playReceiveSound() async { + if (!_rxEnabled) return; + await _playSound(_rxPlayer, _rxAsset, 'RX'); + } + + /// Shared playback logic for both TX and RX sounds. + /// Ensures audio session is active before playing and debounces focus release. + Future _playSound(AudioPlayer? player, String assetPath, String label) async { + if (!_initialized || !_enabled || player == null) return; try { - debugLog('[AUDIO] Playing transmit sound...'); - // Seek to start and play with timeout to prevent indefinite hangs - // (iOS audio session corruption can cause play() to never complete) - await _txPlayer?.seek(Duration.zero); - await _txPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Transmit sound played successfully'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); + await _ensureSessionActive(); + await player.seek(Duration.zero); + await player.play().timeout(const Duration(seconds: 3)); + debugLog('[AUDIO] Played $label sound'); + _scheduleFocusRelease(); } on TimeoutException { - debugWarn('[AUDIO] Transmit play() timed out after 3s — resetting audio session'); - await _txPlayer?.stop(); + debugWarn('[AUDIO] $label play() timed out — resetting audio session'); + await player.stop(); await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to play transmit sound: $e'); + debugError('[AUDIO] Failed to play $label sound: $e'); + // Try to recover the player for next time + try { + await player.stop(); + await player.setAsset(assetPath); + debugLog('[AUDIO] Reloaded $label player after error'); + } catch (reloadError) { + debugError('[AUDIO] Failed to reload $label player: $reloadError'); + } } } - /// Play the receive sound (when repeater echo or RX observation is detected) - Future playReceiveSound() async { - if (!_initialized || !_enabled) return; - + /// Ensure audio session is active before playback. + /// Cancels any pending focus release to prevent a race where releasing + /// focus from a previous sound kills the session for the current sound. + Future _ensureSessionActive() async { + _focusReleaseTimer?.cancel(); try { - // Seek to start and play with timeout to prevent indefinite hangs - await _rxPlayer?.seek(Duration.zero); - await _rxPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Played receive sound'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); - } on TimeoutException { - debugWarn('[AUDIO] Receive play() timed out after 3s — resetting audio session'); - await _rxPlayer?.stop(); - await _resetAudioSession(); + final session = await AudioSession.instance; + await session.setActive(true); } catch (e) { - debugError('[AUDIO] Failed to play receive sound: $e'); + debugError('[AUDIO] Failed to activate audio session: $e'); + // Continue anyway — playback may still work } } + /// Schedule a delayed audio focus release. + /// Debounced: if another sound plays within the delay window, the timer + /// resets so focus stays active throughout rapid TX→RX sequences. + /// Critical for Android Auto: eventually releases ducking so car audio resumes. + void _scheduleFocusRelease() { + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = Timer(_focusReleaseDelay, () async { + try { + final session = await AudioSession.instance; + await session.setActive(false); + debugLog('[AUDIO] Audio focus released (debounced)'); + } catch (e) { + debugError('[AUDIO] Failed to release audio focus: $e'); + } + }); + } + /// Reset audio session after a play() timeout /// Stops both players, reconfigures the audio session, and reloads assets Future _resetAudioSession() async { + _focusReleaseTimer?.cancel(); try { // Stop both players await _txPlayer?.stop(); @@ -222,8 +269,8 @@ class AudioService { ); // Reload assets so players are ready for next play() - await _txPlayer?.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer?.setAsset('assets/received_packet.mp3'); + await _txPlayer?.setAsset(_txAsset); + await _rxPlayer?.setAsset(_rxAsset); debugLog('[AUDIO] Audio session reset after timeout'); } catch (e) { @@ -231,17 +278,28 @@ class AudioService { } } - /// Release audio focus after playback completes - /// This is critical for Android Auto - without explicitly releasing focus, - /// the car audio system stays ducked indefinitely - Future _releaseAudioFocus() async { + /// Play disconnect alert sound (triple beep pattern). + /// Independent of master sound toggle — this is a safety alert. + Future playAlertSound() async { + if (!_initialized || _txPlayer == null) return; + try { - final session = await AudioSession.instance; - await session.setActive(false); - debugLog('[AUDIO] Audio focus released'); + await _ensureSessionActive(); + for (int i = 0; i < 3; i++) { + await _txPlayer!.seek(Duration.zero); + await _txPlayer!.play().timeout(const Duration(seconds: 3)); + if (i < 2) { + await Future.delayed(const Duration(milliseconds: 300)); + } + } + debugLog('[AUDIO] Played disconnect alert (triple beep)'); + _scheduleFocusRelease(); + } on TimeoutException { + debugWarn('[AUDIO] Alert play() timed out — resetting audio session'); + await _txPlayer!.stop(); + await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to release audio focus: $e'); - // Non-critical - audio still works, just may leave other audio ducked + debugError('[AUDIO] Failed to play alert sound: $e'); } } @@ -259,9 +317,38 @@ class AudioService { await setEnabled(!_enabled); } + /// Enable or disable TX sound notifications + Future setTxEnabled(bool enabled) async { + if (_txEnabled == enabled) return; + _txEnabled = enabled; + debugLog('[AUDIO] TX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_txEnabledKey, enabled); + } + + /// Enable or disable RX sound notifications + Future setRxEnabled(bool enabled) async { + if (_rxEnabled == enabled) return; + _rxEnabled = enabled; + debugLog('[AUDIO] RX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_rxEnabledKey, enabled); + } + + /// Save a single setting to Hive + Future _saveSetting(String key, dynamic value) async { + final box = await _openBoxSafely(_prefsBoxName); + if (box == null) return; + try { + await box.put(key, value); + } catch (e) { + debugError('[AUDIO] Failed to save $key: $e'); + } + } + /// Dispose of audio resources void dispose() { debugLog('[AUDIO] Disposing audio service'); + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = null; _txPlayer?.dispose(); _rxPlayer?.dispose(); _txPlayer = null; diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index e1fe5eb6..47021780 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -86,6 +86,12 @@ abstract class BluetoothService { /// Used for remembered devices to ensure name is available during connect void cacheDeviceInfo(DiscoveredDevice device); + /// Remove BLE bond/pairing for a device + /// On Android: removes the system bond entry + /// On iOS: best-effort — calls cancelPeripheralConnection to nudge CoreBluetooth + /// into clearing stale encryption keys (used after apple-code 14 errors) + Future removeBond(String deviceId); + /// Dispose of resources void dispose(); } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index d279477d..8fb3d629 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -397,13 +397,26 @@ class MobileBluetoothService implements BluetoothService { return; // Success - exit retry loop } catch (e, stackTrace) { + final errorStr = e.toString(); + // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && e.toString().contains('android-code: 133'); - - if (isError133 && attempt < _maxRetries) { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); - await Future.delayed(_retryDelay); + final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + + // Check for iOS apple-code 14 (Peer removed pairing information) or + // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys + final isBondError = Platform.isIOS && + (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + + if ((isError133 || isBondError) && attempt < _maxRetries) { + if (isBondError) { + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + await removeBond(deviceId); + await Future.delayed(const Duration(seconds: 2)); + } else { + debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + await Future.delayed(_retryDelay); + } // Force cleanup before retry try { await _bleDevice?.disconnect(); @@ -470,6 +483,19 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Cached device info: ${device.name} (${device.id})'); } + @override + Future removeBond(String deviceId) async { + try { + final device = fbp.BluetoothDevice.fromId(deviceId); + debugLog('[BLE] Removing bond for $deviceId'); + await device.removeBond(); + debugLog('[BLE] Bond removed for $deviceId'); + } catch (e) { + // removeBond may not be supported on all platforms/devices — log and continue + debugLog('[BLE] removeBond failed (continuing): $e'); + } + } + @override void dispose() { _isDisposed = true; diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index 81054ede..ef5ed4c2 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -262,6 +262,11 @@ class WebBluetoothService implements BluetoothService { // No caching needed - this method is for mobile remembered devices } + @override + Future removeBond(String deviceId) async { + // Web Bluetooth does not support bond management + } + @override void dispose() { _notificationSubscription?.cancel(); diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 753c67c2..6eced5cd 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -358,6 +358,30 @@ class GpsService { return null; // Valid } + /// Request a fresh GPS position from the hardware for auto-ping accuracy. + /// On mobile, this forces a warm-start GPS read (typically < 1 second when + /// GPS is already streaming). Falls back to lastPosition on timeout/error. + Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + // Simulator provides its own positions — use cached + if (_simulatorEnabled) { + return _lastPosition; + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: timeout, + ); + debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + _lastPosition = position; + return position; + } catch (e) { + debugLog('[GPS] Fresh position request failed, using cached: $e'); + return _lastPosition; + } + } + /// Get current position (single request) Future getCurrentPosition() async { if (!await requestPermissions()) { diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 07c08100..5f7513dc 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -65,6 +65,7 @@ class SelfInfo { /// 9. Connected State class MeshCoreConnection { final BluetoothService _bluetooth; + bool _disposed = false; final _stepController = StreamController.broadcast(); final _channelMessageController = StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); @@ -84,6 +85,7 @@ class MeshCoreConnection { Completer? _deviceQueryCompleter; Completer? _selfInfoCompleter; Completer? _sentCompleter; + Completer? _setTimeCompleter; Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; @@ -176,8 +178,8 @@ class MeshCoreConnection { void _updateStep(ConnectionStep step) { _currentStep = step; - if (_stepController.isClosed) { - debugError('[CONN] Cannot update step - controller is closed!'); + if (_disposed || _stepController.isClosed) { + debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -188,8 +190,11 @@ class MeshCoreConnection { /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + if (_disposed) { + throw Exception('Connection instance has been disposed'); + } bool deviceModelMatched = false; - + try { // Step 1: BLE Connect _updateStep(ConnectionStep.bleConnecting); @@ -369,10 +374,23 @@ class MeshCoreConnection { switch (responseCode) { case ResponseCodes.ok: debugLog('[CONN] Received OK response'); + _setTimeCompleter?.complete(); + _setTimeCompleter = null; break; case ResponseCodes.err: final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); + // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success + if (_setTimeCompleter != null) { + if (errorCode == 6) { + debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + } else { + debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } // Complete any pending completers with error final errException = Exception('Command error (code $errorCode)'); _statsCompleter?.completeError(errException); @@ -641,10 +659,16 @@ class MeshCoreConnection { if (statsType == StatsTypes.radio) { final noiseFloor = reader.readInt16LE(); // Skip remaining fields (lastRssi, lastSnr, txAirSecs, rxAirSecs) - _lastNoiseFloor = noiseFloor; - _noiseFloorController.add(noiseFloor); // Emit to stream - debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); - _statsCompleter?.complete(noiseFloor); + if (noiseFloor == 0) { + // MeshCore 1.14.x AGC reset zeroes out noise floor briefly; discard + debugLog('[CONN] Noise floor reading is 0dBm (AGC reset), ignoring'); + _statsCompleter?.complete(0); + } else { + _lastNoiseFloor = noiseFloor; + _noiseFloorController.add(noiseFloor); // Emit to stream + debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); + _statsCompleter?.complete(noiseFloor); + } } else { debugLog('[CONN] Unknown stats type: $statsType'); _statsCompleter?.complete(0); @@ -758,12 +782,23 @@ class MeshCoreConnection { ); } - /// Set device time + /// Set device time and await OK/ERROR response from device Future setDeviceTime(int epochSecs) async { + _setTimeCompleter = Completer(); + final future = _setTimeCompleter!.future; + final data = BufferWriter(); data.writeByte(CommandCodes.setDeviceTime); data.writeUInt32LE(epochSecs); await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _setTimeCompleter = null; + debugWarn('[CONN] Time sync timed out - continuing anyway'); + }, + ); } /// Set TX power @@ -925,9 +960,10 @@ class MeshCoreConnection { } /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON [power] + /// Format: @[MapperBot] LAT, LON + /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon, double powerWatts) async { + Future sendPing(double lat, double lon) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); @@ -935,9 +971,7 @@ class MeshCoreConnection { // Format coordinates to 5 decimal places with comma separator final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - // Format power as "X.Xw" (e.g., "1.0w", "0.3w") - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final message = '@[MapperBot] $coordsStr [$powerStr]'; + final message = '@[MapperBot] $coordsStr'; debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -1156,8 +1190,10 @@ class MeshCoreConnection { /// Dispose of resources void dispose() { + _disposed = true; _stopNoiseFloorPolling(); _stopBatteryPolling(); + _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); _channelMessageController.close(); diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 5796d6f1..23dd9d60 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -45,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index d7822824..50bc46eb 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -44,8 +44,8 @@ class PingService { static const Duration _rxListeningWindow = Duration(seconds: 5); /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); - /// Discovery listening window duration (5 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 7); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) @@ -137,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to get the power level in watts (0.3, 0.6, 1.0, 2.0) from user preferences + double Function()? getPowerLevel; + /// Callback to check if discovery drop is enabled (failed discoveries → API) bool Function()? getDiscDropEnabled; @@ -471,6 +474,13 @@ class PingService { Future sendTxPing({bool manual = true}) async { debugLog('[PING] sendTxPing called (manual=$manual)'); + // Guard: don't send pings if connection is not in connected state + // Handles race where timer callback fires after reconnect started + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + return false; + } + // Early guard: prevent concurrent ping execution (critical for preventing BLE GATT errors) // Reference: state.pingInProgress check in wardrive.js if (_pingInProgress) { @@ -480,6 +490,13 @@ class PingService { _pingInProgress = true; try { + // For auto pings, request a fresh GPS position before validation. + // This ensures the 25m distance check and ping coordinates reflect + // where the device is NOW, not where it was at the last stream event. + if (!manual) { + await _gpsService.getFreshPosition(); + } + // Use different validation and cooldown for manual vs auto pings if (manual) { // Manual ping: 15-second cooldown, no distance check @@ -515,7 +532,11 @@ class PingService { _skipReason = 'too close'; debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); } - _scheduleNextAutoPing(); + if (_hybridModeEnabled) { + _scheduleNextHybridPing(); + } else { + _scheduleNextAutoPing(); + } } _pingInProgress = false; return false; @@ -531,15 +552,12 @@ class PingService { _pingInProgress = false; return false; } - // Use power in watts (0.3, 0.6, 1.0, 2.0) - matches web client buildPayload() - final powerWatts = _connection.deviceModel?.power ?? 0.3; - // Also get txPower in dBm for API queue (for database records) final txPowerDbm = _connection.deviceModel?.txPower ?? 22; // Build ping message (same format used for TxTracker correlation) + // Power is no longer included in the mesh message — sent per-ping in API payload final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final pingMessage = '@[MapperBot] $coordsStr [$powerStr]'; + final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -620,8 +638,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE - uses watts format like "1.0w" - await _connection.sendPing(position.latitude, position.longitude, powerWatts); + // Send ping via BLE (coordinates only — power is in API payload) + await _connection.sendPing(position.latitude, position.longitude); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -725,6 +743,7 @@ class PingService { timestamp: txTimestamp, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); @@ -808,6 +827,11 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[ACTIVE MODE] Auto ping timer fired'); + // Guard: connection may have dropped since timer was scheduled + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[ACTIVE MODE] Not connected, ignoring timer'); + return; + } // Double-check guards before sending ping if (!_autoPingEnabled || _passiveModeEnabled) { debugLog('[ACTIVE MODE] Auto mode no longer running, ignoring timer'); @@ -1081,13 +1105,19 @@ class PingService { /// Send a discovery request and start listening window Future _sendDiscoveryRequest() async { + // Guard: don't send discovery during reconnect (race with timer queue) + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + return; + } + if (!_autoPingEnabled || (!_passiveModeEnabled && !_hybridModeEnabled)) { debugLog('[DISC] Not in Passive/Hybrid Mode, skipping discovery request'); return; } - // Check GPS - final position = _gpsService.lastPosition; + // Request fresh GPS position before discovery (same rationale as TX auto-ping) + final position = await _gpsService.getFreshPosition(); if (position == null) { debugLog('[DISC] No GPS position, skipping discovery request'); _pingInProgress = false; @@ -1205,6 +1235,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); } @@ -1222,6 +1253,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[DISC] Discovery drop queued (no response)'); } @@ -1275,8 +1307,10 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 5000 + // TX uses 5s RX window, discovery uses 7s window + final listenMs = _nextPingIsDiscovery + ? _discoveryListeningWindow.inMilliseconds + : _rxListeningWindow.inMilliseconds; final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1286,6 +1320,10 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: waitMs), () { if (!_autoPingEnabled || !_hybridModeEnabled) return; + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[HYBRID] Not connected, ignoring timer'); + return; + } if (_pingInProgress) { debugLog('[HYBRID] Ping already in progress, skipping'); return; @@ -1471,6 +1509,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); // Update stats @@ -1500,6 +1539,10 @@ class PingService { _targetedTimer?.cancel(); _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[TRACE] Targeted ping timer fired'); + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[TRACE] Not connected, ignoring timer'); + return; + } if (_autoPingEnabled && _targetedModeEnabled) { if (_pingInProgress) { debugLog('[TRACE] Ping already in progress, skipping'); diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart new file mode 100644 index 00000000..6455db0b --- /dev/null +++ b/lib/utils/ping_colors.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../utils/debug_logger_io.dart'; + +/// Color vision deficiency types for accessibility. +/// +/// Users select their CVD type in Settings > General > Color Vision. +/// The app adapts all semantic colors (ping types, signal quality, +/// repeater status, noise floor) to a distinguishable palette. +enum ColorVisionType { + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) +} + +/// Immutable palette holding every semantic color the app uses. +class ColorPalette { + // Ping type colors + final Color txSuccess; + final Color txSuccessLegend; + final Color txFail; + final Color rx; + final Color discSuccess; + final Color discFail; + final Color traceSuccess; + final Color noResponse; + + // Signal quality (SNR/RSSI) traffic-light + final Color signalGood; + final Color signalMedium; + final Color signalBad; + + // Repeater status on map + final Color repeaterActive; + final Color repeaterNew; + final Color repeaterDead; + final Color repeaterDuplicate; + + // Noise floor gradient (good → medium → bad) + final Color noiseFloorGood; + final Color noiseFloorMedium; + final Color noiseFloorBad; + + const ColorPalette({ + required this.txSuccess, + required this.txSuccessLegend, + required this.txFail, + required this.rx, + required this.discSuccess, + required this.discFail, + required this.traceSuccess, + required this.noResponse, + required this.signalGood, + required this.signalMedium, + required this.signalBad, + required this.repeaterActive, + required this.repeaterNew, + required this.repeaterDead, + required this.repeaterDuplicate, + required this.noiseFloorGood, + required this.noiseFloorMedium, + required this.noiseFloorBad, + }); +} + +/// Concrete palette definitions for each CVD type. +/// +/// Color choices for CVD palettes based on Wong (2011) "Points of view: +/// Color blindness" — Nature Methods. All colors within each palette are +/// mutually distinguishable for the target CVD type. +class ColorPalettes { + ColorPalettes._(); + + /// Default palette — matches original app colors and web map squares. + /// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, + /// DEAD=#9E9689, DROP=#E04F5D + static const none = ColorPalette( + txSuccess: Color(0xFF4CAF50), + txSuccessLegend: Color(0xFF22C55E), + txFail: Color(0xFFF44336), + rx: Color(0xFF7D54C7), + discSuccess: Color(0xFF51D4E9), + discFail: Color(0xFF9E9E9E), + traceSuccess: Color(0xFF00BCD4), + noResponse: Color(0xFF9E9E9E), + signalGood: Colors.green, + signalMedium: Colors.orange, + signalBad: Colors.red, + repeaterActive: Color(0xFFD63384), + repeaterNew: Color(0xFFFD7E14), + repeaterDead: Color(0xFF6C757D), + repeaterDuplicate: Color(0xFFDC3545), + noiseFloorGood: Colors.green, + noiseFloorMedium: Colors.orange, + noiseFloorBad: Colors.red, + ); + + /// Protanopia (red-blind) — replaces red/green axis with blue/orange. + /// Also used for deuteranopia since both are red-green CVD. + static const protanopia = ColorPalette( + txSuccess: Color(0xFF0072B2), // Wong blue + txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF0072B2), + noiseFloorMedium: Color(0xFFF0E442), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. + /// Red/green distinction is preserved since tritan users can see those. + static const tritanopia = ColorPalette( + txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF009E73), + noiseFloorMedium: Color(0xFFE69F00), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Achromatopsia (monochrome) — luminance-only palette. + /// Relies on maximum brightness contrast between categories. + /// Secondary indicators (icons, text) are essential with this palette. + static const achromatopsia = ColorPalette( + txSuccess: Color(0xFFE0E0E0), // Light + txSuccessLegend: Color(0xFFE0E0E0), + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark + repeaterDuplicate: Color(0xFF424242), // Very dark + noiseFloorGood: Color(0xFFE0E0E0), + noiseFloorMedium: Color(0xFF9E9E9E), + noiseFloorBad: Color(0xFF424242), + ); + + /// Look up palette for a given CVD type. + static ColorPalette forType(ColorVisionType type) { + return switch (type) { + ColorVisionType.none => none, + ColorVisionType.protanopia => protanopia, + ColorVisionType.deuteranopia => protanopia, // Same as protanopia + ColorVisionType.tritanopia => tritanopia, + ColorVisionType.achromatopsia => achromatopsia, + }; + } +} + +/// Centralized color accessors for ping types, signal quality, repeater +/// status, and noise floor. +/// +/// All getters delegate to the active [ColorPalette], which changes when the +/// user selects a different color vision type in Settings. Existing call +/// sites (`PingColors.txSuccess`, etc.) continue to work unchanged. +class PingColors { + PingColors._(); + + static ColorPalette _activePalette = ColorPalettes.none; + static ColorVisionType _currentType = ColorVisionType.none; + + /// Set the active palette. Called by AppStateProvider when the + /// colorVisionType preference changes or on app startup. + static void setColorVisionType(ColorVisionType type) { + _currentType = type; + _activePalette = ColorPalettes.forType(type); + debugLog('[A11Y] Color palette set to ${type.name}'); + } + + /// Current CVD type (for UI display in settings). + static ColorVisionType get currentType => _currentType; + + // ── Ping type colors (same API as before) ── + static Color get txSuccess => _activePalette.txSuccess; + static Color get txSuccessLegend => _activePalette.txSuccessLegend; + static Color get txFail => _activePalette.txFail; + static Color get rx => _activePalette.rx; + static Color get discSuccess => _activePalette.discSuccess; + static Color get discFail => _activePalette.discFail; + static Color get traceSuccess => _activePalette.traceSuccess; + static Color get noResponse => _activePalette.noResponse; + + // ── Signal quality (SNR/RSSI traffic-light) ── + static Color get signalGood => _activePalette.signalGood; + static Color get signalMedium => _activePalette.signalMedium; + static Color get signalBad => _activePalette.signalBad; + + // ── Repeater status ── + static Color get repeaterActive => _activePalette.repeaterActive; + static Color get repeaterNew => _activePalette.repeaterNew; + static Color get repeaterDead => _activePalette.repeaterDead; + static Color get repeaterDuplicate => _activePalette.repeaterDuplicate; + + // ── Noise floor gradient ── + static Color get noiseFloorGood => _activePalette.noiseFloorGood; + static Color get noiseFloorMedium => _activePalette.noiseFloorMedium; + static Color get noiseFloorBad => _activePalette.noiseFloorBad; + + // ── Convenience: SNR color from value ── + static Color snrColor(double snr) { + if (snr <= -1) return signalBad; + if (snr <= 5) return signalMedium; + return signalGood; + } + + // ── Convenience: RSSI color from value ── + static Color rssiColor(int rssi) { + if (rssi >= -70) return signalGood; + if (rssi >= -100) return signalMedium; + return signalBad; + } + + // ── Convenience: Noise floor color from dBm ── + static Color noiseFloorColor(double dbm) { + if (dbm <= -100) return noiseFloorGood; + if (dbm <= -90) return noiseFloorMedium; + return noiseFloorBad; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7d2e2ef5..caa4b97b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -14,6 +14,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Map style options @@ -653,22 +654,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), children: [ // Tile layer (dynamic based on selected style from preferences) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + // Skipped entirely when map tiles are disabled to save mobile data + if (appState.preferences.mapTilesEnabled) + Builder( + builder: (context) { + final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + return TileLayer( + urlTemplate: mapStyle.urlTemplate, + subdomains: mapStyle.subdomains ?? const [], + userAgentPackageName: 'com.meshmapper.app', + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), + ); + }, + ), - // MeshMapper coverage overlay (only when zone code available and overlay enabled) - if (appState.zoneCode != null && _showMeshMapperOverlay) + // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) + if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', @@ -680,24 +683,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { tileProvider: SilentCancellableNetworkTileProvider(), ), - // TX markers (green) - MarkerLayer( - markers: _buildTxMarkers(appState.txPings), - ), - - // RX markers (colored by repeater) - MarkerLayer( - markers: _buildRxMarkers(appState.rxPings), - ), - - // DISC markers (purple circles for discovery observations) - MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), - ), - - // Trace markers (cyan/red circles for targeted ping results) + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top MarkerLayer( - markers: _buildTraceMarkers(appState.traceLogEntries), + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), ), // Repeater markers (magenta with ID, rotate with map) @@ -709,9 +703,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), - // Current position marker (car icon) + // Current position marker if (appState.currentPosition != null) MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow and walk rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk', markers: [ Marker( point: LatLng( @@ -732,10 +730,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Color for the overlay ping-type dot static Color _overlayTypeColor(OverlayPingType type) { return switch (type) { - OverlayPingType.tx => Colors.green, - OverlayPingType.disc => Colors.purple, - OverlayPingType.trace => Colors.cyan, - OverlayPingType.rx => Colors.blue, + OverlayPingType.tx => PingColors.txSuccess, + OverlayPingType.disc => PingColors.discSuccess, + OverlayPingType.trace => PingColors.traceSuccess, + OverlayPingType.rx => PingColors.rx, }; } @@ -844,12 +842,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// SNR color: green > 5, orange -1..5, red <= -1 - static Color _snrColor(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + /// SNR color (delegates to active palette) + static Color _snrColor(double snr) => PingColors.snrColor(snr); Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; @@ -904,9 +898,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Color _getAccuracyColor(double accuracy) { - if (accuracy <= 10) return Colors.green; - if (accuracy <= 30) return Colors.orange; - return Colors.red; + if (accuracy <= 10) return PingColors.signalGood; + if (accuracy <= 30) return PingColors.signalMedium; + return PingColors.signalBad; } /// Map controls (always vertical, used inside collapsible wrapper) @@ -1229,49 +1223,49 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ _buildLegendItem( context: context, - color: const Color(0xFF22C55E), + color: PingColors.txSuccessLegend, label: 'TX', description: 'Location where you sent a ping and heard a repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.red, + color: PingColors.txFail, label: 'TX', description: 'Location where you sent a ping but no repeater was heard', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF0EA5E9), + color: PingColors.rx, label: 'RX', description: 'Location where you received a message from the mesh', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF7D54C7), + color: PingColors.discSuccess, label: 'DISC', description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.cyan, + color: PingColors.traceSuccess, label: 'TRC', description: 'Location where a trace reached the repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.discFail, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.noResponse, label: 'TRC', description: 'Location where a trace got no response', ), @@ -1660,119 +1654,120 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - List _buildTxMarkers(List pings) { - return pings.map((ping) { - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? Colors.red : Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) + /// Build a coverage marker child widget based on the user's marker style preference. + Widget _buildCoverageMarkerChild(Color color) { + final style = context.read().preferences.markerStyle; + switch (style) { + case 'circle': + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.0), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], ), - ), - ); - }).toList(); + ); + case 'pin': + return CustomPaint( + size: const Size(20, 20), + painter: _PinMarkerPainter(color), + ); + case 'diamond': + return CustomPaint( + size: const Size(20, 20), + painter: _DiamondMarkerPainter(color), + ); + case 'dot': + default: + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ), + ); + } } - List _buildRxMarkers(List pings) { - return pings.map((ping) { - // Use blue to match the RX chip in status bar - const color = Colors.blue; + /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). + /// Newer pings always render on top regardless of type. + List _buildCoverageMarkers({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + final timestamped = <(DateTime, Marker)>[ + for (final ping in txPings) + (ping.timestamp, _buildTxMarker(ping)), + for (final ping in rxPings) + (ping.timestamp, _buildRxMarker(ping)), + for (final entry in discEntries) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + for (final entry in traceEntries) + (entry.timestamp, _buildTraceMarker(entry)), + ]; + + timestamped.sort((a, b) => a.$1.compareTo(b.$1)); + return timestamped.map((e) => e.$2).toList(); + } - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) - ), + Marker _buildTxMarker(TxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: _buildCoverageMarkerChild( + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, ), - ); - }).toList(); + ), + ); } - List _buildDiscMarkers(List entries, bool discDropEnabled) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - ), + Marker _buildRxMarker(RxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: _buildCoverageMarkerChild(PingColors.rx), + ), + ); + } + + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: _buildCoverageMarkerChild( + entry.nodeCount == 0 + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) + : _discMarkerColor, ), - ); - }).toList(); + ), + ); } - List _buildTraceMarkers(List entries) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.success ? Colors.cyan : Colors.grey, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - ), + Marker _buildTraceMarker(TraceLogEntry entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: _buildCoverageMarkerChild( + entry.success ? Colors.cyan : Colors.grey, ), - ); - }).toList(); + ), + ); } void _showTraceDetails(TraceLogEntry entry) { @@ -1948,32 +1943,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final localRssi = entry.localRssi ?? 0; final remoteSnr = entry.remoteSnr ?? 0; - Color rxSnrColor; - if (localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (localRssi >= -70) { - rssiColor = Colors.green; - } else if (localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(localSnr); + final rssiColor = PingColors.rssiColor(localRssi); + final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), @@ -2026,20 +1998,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// DISC marker color (#7B68EE - medium slate blue/purple) - static const Color _discMarkerColor = Color(0xFF7B68EE); + /// DISC marker color (delegates to active palette) + static Color get _discMarkerColor => PingColors.discSuccess; - /// Repeater marker color (#a52163 - magenta/pink) - Active - static const Color _repeaterMarkerColor = Color(0xFFA52163); + /// Repeater marker color - Active (delegates to active palette) + static Color get _repeaterMarkerColor => PingColors.repeaterActive; - /// Duplicate repeater marker color (#a51d2a - red) - static const Color _repeaterDuplicateColor = Color(0xFFA51D2A); + /// Duplicate repeater marker color (delegates to active palette) + static Color get _repeaterDuplicateColor => PingColors.repeaterDuplicate; - /// New repeater marker color (#c05802 - orange) - static const Color _repeaterNewColor = Color(0xFFC05802); + /// New repeater marker color (delegates to active palette) + static Color get _repeaterNewColor => PingColors.repeaterNew; - /// Dead repeater marker color (grey) - static const Color _repeaterDeadColor = Colors.grey; + /// Dead repeater marker color (delegates to active palette) + static Color get _repeaterDeadColor => PingColors.repeaterDead; /// Get set of duplicate repeater IDs Set _getDuplicateRepeaterIds(List repeaters) { @@ -2127,15 +2099,28 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Convert heading from degrees to radians // heading is 0-360 degrees, 0 = North, 90 = East final headingRadians = heading * (math.pi / 180); + final style = context.read().preferences.gpsMarkerStyle; + + // Arrow and walk rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk'; + + final CustomPainter painter; + switch (style) { + case 'car': + painter = const _CarMarkerPainter(); + case 'bike': + painter = const _BikeMarkerPainter(); + case 'boat': + painter = const _BoatMarkerPainter(); + case 'walk': + painter = const _WalkMarkerPainter(); + case 'arrow': + default: + painter = const _ArrowPainter(); + } - // Clean directional arrow - return Transform.rotate( - angle: headingRadians, - child: CustomPaint( - size: const Size(24, 24), - painter: _ArrowPainter(), - ), - ); + final child = CustomPaint(size: const Size(24, 24), painter: painter); + return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; } /// Compute node column width based on hop byte count. @@ -2184,11 +2169,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), + color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2314,29 +2299,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr == null) { - snrColor = Colors.grey; - } else if (repeater.snr! <= -1) { - snrColor = Colors.red; - } else if (repeater.snr! <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi == null) { - rssiColor = Colors.grey; - } else if (repeater.rssi! >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; + final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), @@ -2383,25 +2347,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Show RX ping details popup void _showRxPingDetails(RxPing ping) { - // Calculate SNR severity for chip color - Color snrColor; - if (ping.snr <= -1) { - snrColor = Colors.red; - } else if (ping.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color based on signal strength - Color rssiColor; - if (ping.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (ping.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); showModalBottomSheet( context: context, @@ -2622,7 +2569,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.circular(12), border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2761,33 +2708,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(node.localSnr); + final rssiColor = PingColors.rssiColor(node.localRssi); + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -2803,7 +2726,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: _discMarkerColor, @@ -3057,6 +2980,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Paints a crisp directional arrow pointing up class _ArrowPainter extends CustomPainter { + const _ArrowPainter(); + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); @@ -3094,6 +3019,320 @@ class _ArrowPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a car silhouette for GPS position marker +class _CarMarkerPainter extends CustomPainter { + const _CarMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + // Car body outline (rounded rect, slightly larger) + final outlineRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 14, height: 20), + const Radius.circular(4), + ); + canvas.drawRRect(outlineRect, outlinePaint); + + // Blue car body + final bodyPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + final bodyRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 11, height: 17), + const Radius.circular(3), + ); + canvas.drawRRect(bodyRect, bodyPaint); + + // Windshield (darker blue rectangle near top) + final windshieldPaint = Paint() + ..color = const Color(0xFF1565C0) + ..style = PaintingStyle.fill; + final windshieldRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy - 3), width: 7, height: 4), + const Radius.circular(1), + ); + canvas.drawRRect(windshieldRect, windshieldPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a bicycle silhouette for GPS position marker +class _BikeMarkerPainter extends CustomPainter { + const _BikeMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + + final bikePaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Two wheels + const wheelR = 4.0; + final leftWheel = Offset(cx - 5, cy + 3); + final rightWheel = Offset(cx + 5, cy + 3); + + // White outlines for wheels + canvas.drawCircle(leftWheel, wheelR + 1, outlinePaint); + canvas.drawCircle(rightWheel, wheelR + 1, outlinePaint); + + // Frame outline + final framePath = ui.Path() + ..moveTo(leftWheel.dx, leftWheel.dy) + ..lineTo(cx, cy - 5) // Up to handlebars + ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear + ..moveTo(cx, cy - 5) + ..lineTo(cx + 2, cy - 7); // Handlebar + canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + + // Blue wheels + canvas.drawCircle(leftWheel, wheelR, bikePaint); + canvas.drawCircle(rightWheel, wheelR, bikePaint); + + // Blue frame + canvas.drawPath(framePath, bikePaint); + + // Seat dot + canvas.drawCircle(Offset(cx - 1, cy - 4), 1.5, fillPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a boat silhouette for GPS position marker +class _BoatMarkerPainter extends CustomPainter { + const _BoatMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Hull outline (wider) + final hullOutline = ui.Path() + ..moveTo(cx - 9, cy + 1) + ..lineTo(cx - 6, cy + 8) + ..lineTo(cx + 6, cy + 8) + ..lineTo(cx + 9, cy + 1) + ..close(); + canvas.drawPath(hullOutline, outlinePaint); + + // Hull fill + final hull = ui.Path() + ..moveTo(cx - 7, cy + 2) + ..lineTo(cx - 5, cy + 7) + ..lineTo(cx + 5, cy + 7) + ..lineTo(cx + 7, cy + 2) + ..close(); + canvas.drawPath(hull, fillPaint); + + // Mast outline + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + // Mast + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + + // Sail outline + final sailOutline = ui.Path() + ..moveTo(cx + 1, cy - 8) + ..lineTo(cx + 7, cy) + ..lineTo(cx + 1, cy) + ..close(); + canvas.drawPath(sailOutline, outlinePaint); + + // Sail + final sail = ui.Path() + ..moveTo(cx + 1, cy - 7) + ..lineTo(cx + 6, cy - 0.5) + ..lineTo(cx + 1, cy - 0.5) + ..close(); + canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a walking person silhouette for GPS position marker +class _WalkMarkerPainter extends CustomPainter { + const _WalkMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round; + + final personPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Head outline + fill + canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); + + // Body outline + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), outlinePaint); + // Body + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); + + // Arms outline + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + // Arms + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + + // Left leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); + // Right leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), outlinePaint); + // Left leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), personPaint); + // Right leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), personPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a teardrop/pin marker for coverage dots +class _PinMarkerPainter extends CustomPainter { + final Color color; + const _PinMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.15) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + const headRadius = 6.0; + final headCenter = Offset(cx, cy - 2); + final tipY = cy + 9; + + // Combined shadow + final pinPath = ui.Path() + ..addOval(Rect.fromCircle(center: headCenter, radius: headRadius)) + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(pinPath, shadowPaint); + + // Triangle point + final triPath = ui.Path() + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(triPath, fillPaint); + + // Circle head + canvas.drawCircle(headCenter, headRadius, fillPaint); + canvas.drawCircle(headCenter, headRadius, outlinePaint); + + // Inner dot + canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + } + + @override + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; +} + +/// Paints a diamond marker for coverage dots +class _DiamondMarkerPainter extends CustomPainter { + final Color color; + const _DiamondMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.12) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + final path = ui.Path() + ..moveTo(cx, cy - 8) // Top + ..lineTo(cx + 8, cy) // Right + ..lineTo(cx, cy + 8) // Bottom + ..lineTo(cx - 8, cy) // Left + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, outlinePaint); + } + + @override + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index aa760b46..568807cf 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan @@ -482,25 +483,8 @@ class InteractiveNoiseFloorChartState extends State /// Build a table row for a repeater (matching TX log style) Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { - // SNR color: good (>5), fair (-1 to 5), poor (<-1) - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = PingColors.snrColor(repeater.snr); + final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), @@ -702,20 +686,20 @@ class InteractiveNoiseFloorChartState extends State return ((dbm - minY) / yRange).clamp(0.0, 1.0); } - // Smooth gradient with faded transitions + // Smooth gradient with faded transitions (palette-aware) final lineColors = [ - Colors.green, - Colors.green, - Colors.orange, - Colors.red, - Colors.red, + PingColors.noiseFloorGood, + PingColors.noiseFloorGood, + PingColors.noiseFloorMedium, + PingColors.noiseFloorBad, + PingColors.noiseFloorBad, ]; final fillColors = [ - Colors.green.withValues(alpha: 0.2), - Colors.green.withValues(alpha: 0.15), - Colors.orange.withValues(alpha: 0.12), - Colors.red.withValues(alpha: 0.1), - Colors.red.withValues(alpha: 0.08), + PingColors.noiseFloorGood.withValues(alpha: 0.2), + PingColors.noiseFloorGood.withValues(alpha: 0.15), + PingColors.noiseFloorMedium.withValues(alpha: 0.12), + PingColors.noiseFloorBad.withValues(alpha: 0.1), + PingColors.noiseFloorBad.withValues(alpha: 0.08), ]; final stops = [ 0.0, @@ -848,12 +832,12 @@ class InteractiveNoiseFloorChartState extends State runSpacing: 8, alignment: WrapAlignment.center, children: [ - _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX Fail'), - _legendItem(context, Colors.blue, 'RX'), - _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.cyan, 'Trace Success'), - _legendItem(context, Colors.grey, 'No Response'), + _legendItem(context, PingColors.txSuccess, 'TX Success'), + _legendItem(context, PingColors.txFail, 'TX Fail'), + _legendItem(context, PingColors.rx, 'RX'), + _legendItem(context, PingColors.discSuccess, 'DISC Success'), + _legendItem(context, PingColors.traceSuccess, 'Trace Success'), + _legendItem(context, PingColors.noResponse, 'No Response'), ], ); } diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 805417de..c05b96f0 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -86,6 +86,7 @@ class PingControls extends StatelessWidget { // Action buttons row Row( children: [ + if (!txNotAllowed) ...[ // Send Ping button // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" // Manual pings use 15-second cooldown, no distance requirement @@ -169,6 +170,7 @@ class PingControls extends StatelessWidget { ), ), const SizedBox(width: 10), + ], // Passive Mode button (toggle) // When ON: shows "Listening..." → "Next Disc Xs" cycle @@ -934,6 +936,7 @@ class _CompactPingControlsState extends State { // - Grey non-expanded buttons are icon-only return Row( children: [ + if (!txNotAllowed) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -951,6 +954,7 @@ class _CompactPingControlsState extends State { else activeModeButton, const SizedBox(width: 6), + ], // Passive Mode if (passiveModeExpanded) @@ -1167,6 +1171,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ + if (!txNotAllowed) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( @@ -1219,6 +1224,7 @@ class LandscapePingControls extends StatelessWidget { ), ), const SizedBox(width: 8), + ], // Passive Mode button Expanded( diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 0088da57..d2ae8e24 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -7,6 +7,7 @@ import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// @@ -201,8 +202,9 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Calculate distance string if GPS is available String? distanceText; @@ -273,7 +275,7 @@ class RepeaterIdChip extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -281,13 +283,20 @@ class RepeaterIdChip extends StatelessWidget { borderRadius: BorderRadius.circular(10), border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - statusText, - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index a10fa2da..f78950b5 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -6,6 +6,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Show a bottom sheet repeater picker and return the selected repeater. Future showRepeaterPicker(BuildContext context) { @@ -226,7 +227,8 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; @@ -315,7 +317,7 @@ class _RepeaterTile extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -324,13 +326,20 @@ class _RepeaterTile extends StatelessWidget { border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - isActive ? 'Active' : 'Stale', - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 563d28f6..403b6d99 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Status bar showing GPS, connection, and queue status class StatusBar extends StatefulWidget { @@ -150,16 +151,16 @@ class _StatusBarState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -239,7 +240,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_upward, value: appState.pingStats.txCount, - color: Colors.green, + color: PingColors.txSuccess, onTap: () => _showInfoPopup(context, 'tx'), ), @@ -249,7 +250,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_downward, value: appState.pingStats.rxCount, - color: Colors.blue, + color: PingColors.rx, onTap: () => _showInfoPopup(context, 'rx'), ), @@ -259,7 +260,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.radar, value: appState.pingStats.discCount, - color: const Color(0xFF7B68EE), // DISC purple + color: PingColors.discSuccess, onTap: () => _showInfoPopup(context, 'disc'), ), @@ -269,7 +270,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.route, value: appState.pingStats.traceCount, - color: Colors.cyan, + color: PingColors.traceSuccess, onTap: () => _showInfoPopup(context, 'trace'), ), From f40a48d5082f87c5f4c5807a80b5dff19f933bc8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 31 Mar 2026 22:29:59 -0400 Subject: [PATCH 041/360] - Fixed phantom "MeshMapper - Active Mode" notifications appearing hours after the app was closed (e.g., 3am). Startup cleanup was inadvertently resurrecting a dead foreground service. Cleanup now cancels the notification directly without triggering the service, and service initialization is deferred until the user actually starts wardriving. --- lib/services/background_service.dart | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 3ff0d174..1e76464f 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -29,9 +29,11 @@ class BackgroundServiceManager { static bool _isRunning = false; /// Initialize the background service. - /// Called lazily on first startService() call. - /// On Android, configure() may start the foreground service as a side effect, - /// so this should NOT be called eagerly at app startup. + /// Called lazily on first startService() call — never at app startup. + /// On Android, configure() may start the foreground service as a side effect + /// (service resurrection), so this MUST only be called when we actually + /// intend to start the service. Startup orphan cleanup uses + /// cleanupOrphanedService() which cancels the notification directly. static Future initialize() async { // Skip on web - no background service support if (kIsWeb) { @@ -207,14 +209,24 @@ class BackgroundServiceManager { /// Clean up any orphaned foreground service from a previous app session. /// On Android, the foreground service can survive app process death. /// Call this at app startup to ensure no stale notification persists. + /// + /// This intentionally does NOT call initialize()/configure(), because + /// configure() with isForegroundMode:true can cause Android to resurrect + /// a killed foreground service as a side effect — producing a phantom + /// notification hours later (the "3am notification" bug). + /// + /// Instead, we cancel the notification directly by ID. Without its + /// required notification, Android will terminate any orphaned foreground + /// service within seconds. static Future cleanupOrphanedService() async { if (kIsWeb) return; try { - debugLog('[BACKGROUND] Checking for orphaned service from previous session'); - await initialize(); - // initialize() has a defense check that stops any running service + debugLog('[BACKGROUND] Dismissing any orphaned notification from previous session'); + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.cancel(_notificationId); + debugLog('[BACKGROUND] Orphaned notification cleanup complete'); } catch (e) { - debugError('[BACKGROUND] Failed to cleanup orphaned service: $e'); + debugError('[BACKGROUND] Failed to cleanup orphaned notification: $e'); } } From c634e12f51226407616e965c2de0d67682138f7e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 14:24:12 -0400 Subject: [PATCH 042/360] - Zone grace period for region transitions: driving between zones no longer disconnects you. The app pauses pinging and shows an "Out of Zone" overlay with the nearest zone name, distance, and a 5-minute countdown. Once you enter the next zone, pinging resumes automatically without reconnecting. If no zone is reached within 5 minutes, the app disconnects normally. - Color vision deficiency support now extends to map coverage tiles. When a color vision type is selected in Settings, the tile server returns adjusted coverage squares to match your palette. Previously only app UI colors (ping markers, signal indicators, etc.) adapted to CVD settings. Supports protanopia, deuteranopia, tritanopia, and achromatopsia. --- .build_version | 2 +- lib/providers/app_state_provider.dart | 246 +++++++++++++++++++++++++- lib/screens/connection_screen.dart | 80 +++++++++ lib/screens/home_screen.dart | 107 +++++++++++ lib/services/api_service.dart | 23 ++- lib/services/meshcore/connection.dart | 7 + lib/utils/ping_colors.dart | 46 +++++ lib/widgets/map_widget.dart | 14 +- 8 files changed, 513 insertions(+), 12 deletions(-) diff --git a/.build_version b/.build_version index 6085e946..23aa8390 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.2.1 +1.2.2 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 712f8cf7..82eeed20 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -268,6 +268,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Timer? _idleDisconnectTimer; static const Duration _idleDisconnectTimeout = Duration(minutes: 15); + // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry + bool _isInZoneGracePeriod = false; + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + int _zoneGraceSecondsRemaining = 0; + bool _autoPingWasEnabledBeforeGrace = false; + AutoMode _autoModeBeforeGrace = AutoMode.active; + static const Duration _zoneGraceTimeout = Duration(minutes: 5); + // Geofence zone check log throttle (while disconnected) DateTime? _lastZoneCheckLogTime; int _zoneCheckSuppressedCount = 0; @@ -482,6 +492,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; + // Zone grace period getters + bool get isInZoneGracePeriod => _isInZoneGracePeriod; + int get zoneGraceSecondsRemaining => _zoneGraceSecondsRemaining; + String get zoneGraceCountdownFormatted => + '${(_zoneGraceSecondsRemaining ~/ 60).toString().padLeft(2, '0')}:' + '${(_zoneGraceSecondsRemaining % 60).toString().padLeft(2, '0')}'; + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); @@ -689,7 +706,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final hasRemembered = _rememberedDevice != null; final isUnexpected = !_userRequestedDisconnect && !_isAutoReconnecting; - if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { + if (_isInZoneGracePeriod) { + // BLE disconnected during zone grace period — abandon grace, full cleanup + debugLog('[CONN] BLE disconnect during zone grace period — full cleanup'); + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + if (_autoPingWasEnabledBeforeGrace) _playDisconnectAlert(); + _autoPingWasEnabledBeforeGrace = false; + await _fullDisconnectCleanup(); + } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { @@ -2221,6 +2247,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; + // Cancel any active zone grace period + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); @@ -2290,6 +2322,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { + // Defensive: cancel zone grace period if active + if (_isInZoneGracePeriod) { + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + } _cancelPendingAutoPingRestore(); _cancelIdleDisconnectTimer(); _isAutoReconnecting = true; @@ -2543,6 +2582,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; + // Cancel any active zone grace period + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); @@ -4001,6 +4046,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } + // Zone grace period: intercept outside_zone during active session + if (reason == 'outside_zone' && _isInZoneGracePeriod) { + debugLog('[ZONE GRACE] outside_zone during grace period — already handling'); + return; + } + if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { + debugLog('[ZONE GRACE] outside_zone — entering grace period'); + await _startZoneGracePeriod(); + return; + } + // Log error debugError('[API] Session error: $reason - $userMessage'); logError(userMessage, severity: ErrorSeverity.error); @@ -4431,6 +4487,193 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneRefreshTimer = null; } + // ============================================ + // Zone Grace Period + // ============================================ + + /// Cancel all zone grace period timers. + void _cancelZoneGraceTimers() { + _zoneGraceTimer?.cancel(); + _zoneGraceTimer = null; + _zoneGracePollingTimer?.cancel(); + _zoneGracePollingTimer = null; + _zoneGraceCountdownTimer?.cancel(); + _zoneGraceCountdownTimer = null; + } + + /// Enter zone grace period when outside_zone is detected during an active session. + /// Pauses wardriving but keeps BLE and API session alive. + /// Polls for zone re-entry every 5s; auto-disconnects after 5 minutes. + Future _startZoneGracePeriod() async { + if (_isInZoneGracePeriod) return; + _isInZoneGracePeriod = true; + debugLog('[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', severity: ErrorSeverity.warning, autoSwitch: false); + + // Save auto-ping state for restoration on zone re-entry + _autoPingWasEnabledBeforeGrace = _autoPingEnabled; + _autoModeBeforeGrace = _autoMode; + + // Stop auto-ping timers and disable + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + _cooldownTimer.stop(); + if (_autoPingEnabled) { + _autoPingEnabled = false; + _idleAutoStopReference = null; + debugLog('[ZONE GRACE] Auto-ping paused'); + } + + // Disable heartbeat (no point while outside zone) + _apiService.disableHeartbeat(); + + // Stop RX logger (no session context for RX data) + _rxLogger?.stopWardriving(trigger: 'zone_grace'); + + // Stop zone refresh timer (replaced by 5s grace polling) + _stopZoneRefreshTimer(); + + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + + // Stop background service + await BackgroundServiceManager.stopService(); + + // Clear API queue — items have gap-GPS coords that would be rejected again + await _apiQueueService.clearOnDisconnect(); + + // Keep alive: BLE, _meshCoreConnection, _pingService, _unifiedRxHandler, + // noise floor, and API session (backend auto-transfers on zone re-entry) + + // Start 5-minute countdown + _zoneGraceSecondsRemaining = _zoneGraceTimeout.inSeconds; + + // Overall timeout — abandon grace period after 5 minutes + _zoneGraceTimer = Timer(_zoneGraceTimeout, () { + debugLog('[ZONE GRACE] Timeout expired — abandoning'); + _abandonZoneGracePeriod(); + }); + + // 1-second countdown tick for UI + _zoneGraceCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_zoneGraceSecondsRemaining > 0) { + _zoneGraceSecondsRemaining--; + notifyListeners(); + } + }); + + // Trigger immediate zone check, then start 5-second polling + _pollZoneDuringGracePeriod(); + _zoneGracePollingTimer = Timer.periodic(const Duration(seconds: 5), (_) { + _pollZoneDuringGracePeriod(); + }); + + notifyListeners(); + } + + /// Poll zone status during grace period (called every 5s). + Future _pollZoneDuringGracePeriod() async { + if (!_isInZoneGracePeriod) { + _zoneGracePollingTimer?.cancel(); + _zoneGracePollingTimer = null; + return; + } + + debugLog('[ZONE GRACE] Polling zone status...'); + try { + await checkZoneStatus(); + } catch (e) { + debugWarn('[ZONE GRACE] Zone check failed: $e'); + return; // Retry on next tick + } + + // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) + if (_inZone == true) { + debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']}'); + await _onZoneGraceReEntry(); + } + } + + /// Zone re-entered during grace period — resume wardriving. + /// Session is preserved; backend auto-transfers to the new zone. + Future _onZoneGraceReEntry() async { + _cancelZoneGraceTimers(); + + final wasAutoPing = _autoPingWasEnabledBeforeGrace; + final previousMode = _autoModeBeforeGrace; + + // Clear grace state + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + + debugLog('[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', severity: ErrorSeverity.info, autoSwitch: false); + + // Re-enable heartbeat + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + + // Restart zone refresh timer (60s) + _startZoneRefreshTimer(); + + // Prepare API queue for fresh data + await _apiQueueService.clearBeforeConnect(); + + // Restore auto-ping if it was active + if (wasAutoPing) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + return; + } + if (!_autoPingEnabled) { + toggleAutoPing(previousMode); + debugLog('[ZONE GRACE] Auto-ping restored (mode=$previousMode)'); + } + }); + } else { + _startIdleDisconnectTimer(); + } + + notifyListeners(); + } + + /// Abandon zone grace period — timeout, failure, or BLE disconnect. + Future _abandonZoneGracePeriod() async { + _cancelZoneGraceTimers(); + + if (_autoPingWasEnabledBeforeGrace) { + _playDisconnectAlert(); + } + + // Clear grace state + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + + debugLog('[ZONE GRACE] Abandoned — performing full disconnect'); + + // Full disconnect cleanup + await disconnect(); + } + + /// Cancel zone grace period (user-triggered from UI cancel button). + Future cancelZoneGracePeriod() async { + debugLog('[ZONE GRACE] Cancelled by user'); + await _abandonZoneGracePeriod(); + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -5268,6 +5511,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleDisconnectTimer?.cancel(); _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); + _cancelZoneGraceTimers(); _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 993466e3..ae749bbe 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -109,6 +109,11 @@ class _ConnectionScreenState extends State with WidgetsBinding /// Routes to the correct sub-view (zone bar is already rendered above) Widget _buildStateContent(BuildContext context, AppStateProvider appState) { + // Show zone grace period state + if (appState.isInZoneGracePeriod) { + return _buildZoneGraceView(context, appState); + } + // Show connected state if (appState.isConnected) { final pathWarning = appState.pendingPathHashWarning; @@ -261,6 +266,81 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } + Widget _buildZoneGraceView(BuildContext context, AppStateProvider appState) { + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final nearestName = appState.nearestZoneName; + final nearestDistance = appState.nearestZoneDistanceKm; + final hasNearestInfo = nearestName != null && nearestDistance != null; + + return SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(isLandscape ? 16 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_off, + color: Colors.orange.shade400, + size: isLandscape ? 28 : 36, + ), + SizedBox(height: isLandscape ? 12 : 20), + Text( + 'Out of Zone', + style: Theme.of(context).textTheme.titleMedium, + ), + if (hasNearestInfo) ...[ + const SizedBox(height: 8), + Text( + 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + ], + SizedBox(height: isLandscape ? 12 : 16), + Text( + appState.zoneGraceCountdownFormatted, + style: TextStyle( + color: Colors.orange.shade400, + fontSize: isLandscape ? 22 : 28, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: isLandscape ? 8 : 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + 'Searching for zone...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + SizedBox(height: isLandscape ? 16 : 24), + OutlinedButton( + onPressed: () => appState.cancelZoneGracePeriod(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + } + Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0064326f..5b8906d2 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -396,6 +396,17 @@ class _HomeScreenState extends State { ), ), + // Zone grace period overlay (both orientations) + if (appState.isInZoneGracePeriod) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildZoneGraceOverlay(appState), + ), + ), + ), + // Portrait: bottom control panel if (!isLandscape) Positioned( @@ -726,6 +737,102 @@ class _HomeScreenState extends State { ); } + /// Zone grace period overlay shown centered over the map when outside zone + Widget _buildZoneGraceOverlay(AppStateProvider appState) { + final nearestName = appState.nearestZoneName; + final nearestDistance = appState.nearestZoneDistanceKm; + final hasNearestInfo = nearestName != null && nearestDistance != null; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_off, + color: Colors.orange.shade400, + size: 36, + ), + const SizedBox(height: 16), + Text( + 'Out of Zone', + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + if (hasNearestInfo) ...[ + const SizedBox(height: 8), + Text( + 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 16), + Text( + appState.zoneGraceCountdownFormatted, + style: TextStyle( + color: Colors.orange.shade400, + fontSize: 28, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade500, + ), + ), + const SizedBox(width: 8), + Text( + 'Searching for zone...', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: () => appState.cancelZoneGracePeriod(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade400, + side: BorderSide(color: Colors.orange.shade400), + ), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } + Widget _buildControlPanel() { return Card( margin: const EdgeInsets.all(8), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 2da5d0ce..69c1fd2b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -575,13 +575,18 @@ class ApiService { const criticalErrors = { 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'zone_full', 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); onSessionError?.call(reason, message); } + // outside_zone: notify listener but preserve session (backend auto-transfers on zone re-entry) + if (reason == 'outside_zone') { + onSessionError?.call(reason, message); + } + return (isValid: false, reason: reason, message: message); } @@ -676,12 +681,15 @@ class ApiService { const criticalErrors = { 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'zone_full', 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); onSessionError?.call(reason, message); + } else if (reason == 'outside_zone') { + // Preserve session — backend auto-transfers on zone re-entry + onSessionError?.call(reason, message); } else { _onSessionExpiring?.call(); } @@ -752,7 +760,7 @@ class ApiService { // Auth errors 'invalid_key', 'unauthorized', 'bad_key', // Zone errors - 'outside_zone', 'zone_full', 'zone_disabled', + 'zone_full', 'zone_disabled', }; if (criticalErrors.contains(reason)) { @@ -765,6 +773,15 @@ class ApiService { return UploadResult.nonRetryable; } + // outside_zone: preserve session (backend auto-transfers on zone re-entry), + // but discard this batch (gap-GPS coords would be rejected again) + if (reason == 'outside_zone') { + debugWarn('[API] Upload batch outside_zone — discarding batch, preserving session'); + final message = result['message'] as String?; + onSessionError?.call(reason, message); + return UploadResult.nonRetryable; + } + // Errors where the batch data itself is invalid — retrying won't help const nonRetryableErrors = { 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 5f7513dc..66bc30c7 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -654,6 +654,13 @@ class MeshCoreConnection { void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // + // Valid stats payload is 13 bytes. Some firmware versions send peer info + // frames on the same response code (0x18) at 82+ bytes — reject those. + if (reader.remainingBytesCount > 30) { + _statsCompleter?.complete(0); + _statsCompleter = null; + return; + } try { final statsType = reader.readByte(); if (statsType == StatsTypes.radio) { diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart index 6455db0b..ec8f0d91 100644 --- a/lib/utils/ping_colors.dart +++ b/lib/utils/ping_colors.dart @@ -42,6 +42,14 @@ class ColorPalette { final Color noiseFloorMedium; final Color noiseFloorBad; + // Coverage layer legend (must match tile server output for each CVD type) + final Color coverageBidir; + final Color coverageDisc; + final Color coverageTx; + final Color coverageRx; + final Color coverageDead; + final Color coverageDrop; + const ColorPalette({ required this.txSuccess, required this.txSuccessLegend, @@ -61,6 +69,12 @@ class ColorPalette { required this.noiseFloorGood, required this.noiseFloorMedium, required this.noiseFloorBad, + required this.coverageBidir, + required this.coverageDisc, + required this.coverageTx, + required this.coverageRx, + required this.coverageDead, + required this.coverageDrop, }); } @@ -94,6 +108,12 @@ class ColorPalettes { noiseFloorGood: Colors.green, noiseFloorMedium: Colors.orange, noiseFloorBad: Colors.red, + coverageBidir: Color(0xFF7EE094), + coverageDisc: Color(0xFF51D4E9), + coverageTx: Color(0xFFFD8928), + coverageRx: Color(0xFF7D54C7), + coverageDead: Color(0xFF9E9689), + coverageDrop: Color(0xFFE04F5D), ); /// Protanopia (red-blind) — replaces red/green axis with blue/orange. @@ -117,6 +137,12 @@ class ColorPalettes { noiseFloorGood: Color(0xFF0072B2), noiseFloorMedium: Color(0xFFF0E442), noiseFloorBad: Color(0xFFD55E00), + coverageBidir: Color(0xFF0072B2), + coverageDisc: Color(0xFF56B4E9), + coverageTx: Color(0xFFE69F00), + coverageRx: Color(0xFFCC79A7), + coverageDead: Color(0xFF9E9E9E), + coverageDrop: Color(0xFFD55E00), ); /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. @@ -140,6 +166,12 @@ class ColorPalettes { noiseFloorGood: Color(0xFF009E73), noiseFloorMedium: Color(0xFFE69F00), noiseFloorBad: Color(0xFFD55E00), + coverageBidir: Color(0xFF009E73), + coverageDisc: Color(0xFFE69F00), + coverageTx: Color(0xFFCC79A7), + coverageRx: Color(0xFFCC79A7), + coverageDead: Color(0xFF9E9E9E), + coverageDrop: Color(0xFFD55E00), ); /// Achromatopsia (monochrome) — luminance-only palette. @@ -164,6 +196,12 @@ class ColorPalettes { noiseFloorGood: Color(0xFFE0E0E0), noiseFloorMedium: Color(0xFF9E9E9E), noiseFloorBad: Color(0xFF424242), + coverageBidir: Color(0xFFE0E0E0), + coverageDisc: Color(0xFFBDBDBD), + coverageTx: Color(0xFF9E9E9E), + coverageRx: Color(0xFF757575), + coverageDead: Color(0xFF616161), + coverageDrop: Color(0xFF424242), ); /// Look up palette for a given CVD type. @@ -227,6 +265,14 @@ class PingColors { static Color get noiseFloorMedium => _activePalette.noiseFloorMedium; static Color get noiseFloorBad => _activePalette.noiseFloorBad; + // ── Coverage layer legend (matches tile server output) ── + static Color get coverageBidir => _activePalette.coverageBidir; + static Color get coverageDisc => _activePalette.coverageDisc; + static Color get coverageTx => _activePalette.coverageTx; + static Color get coverageRx => _activePalette.coverageRx; + static Color get coverageDead => _activePalette.coverageDead; + static Color get coverageDrop => _activePalette.coverageDrop; + // ── Convenience: SNR color from value ── static Color snrColor(double snr) { if (snr <= -1) return signalBad; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index caa4b97b..569958ed 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -673,7 +673,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', + urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', userAgentPackageName: 'com.meshmapper.app', minZoom: 3, maxZoom: 17, @@ -1294,42 +1294,42 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ _buildLayerItem( context: context, - color: const Color(0xFF7EE094), + color: PingColors.coverageBidir, label: 'BIDIR', description: 'Heard repeats from the mesh AND successfully routed through it', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLayerItem( context: context, - color: const Color(0xFF51D4E9), + color: PingColors.coverageDisc, label: 'DISC', description: 'Wardriving app sent a discovery packet and heard a reply', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLayerItem( context: context, - color: const Color(0xFFFD8928), + color: PingColors.coverageTx, label: 'TX', description: 'Successfully routed through, but no repeats heard back', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLayerItem( context: context, - color: const Color(0xFF7D54C7), + color: PingColors.coverageRx, label: 'RX', description: 'Heard mesh traffic but did not transmit', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLayerItem( context: context, - color: const Color(0xFF9E9689), + color: PingColors.coverageDead, label: 'DEAD', description: 'Repeater heard it, but no other radio received the repeat', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLayerItem( context: context, - color: const Color(0xFFE04F5D), + color: PingColors.coverageDrop, label: 'DROP', description: 'No repeats heard AND no successful route', ), From 6d49cc65300f58d607621ed013f1e18f3bb77641 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 16:50:25 -0400 Subject: [PATCH 043/360] =?UTF-8?q?=E2=8F=BA=20Custom=20API=20Endpoint=20F?= =?UTF-8?q?orwarding=20=E2=80=94=20You=20can=20now=20optionally=20forward?= =?UTF-8?q?=20your=20wardrive=20data=20to=20a=20third-party=20API=20endpoi?= =?UTF-8?q?nt=20in=20addition=20to=20MeshMapper.=20Enable=20it=20in=20Sett?= =?UTF-8?q?ings=20>=20=20=20API=20Endpoints,=20enter=20your=20HTTPS=20URL?= =?UTF-8?q?=20and=20API=20key,=20and=20every=20successful=20batch=20upload?= =?UTF-8?q?=20is=20automatically=20forwarded.=20MeshMapper=20submissions?= =?UTF-8?q?=20are=20unaffected=20=E2=80=94=20the=20custom=20=20=20endpoint?= =?UTF-8?q?=20is=20best-effort=20and=20never=20blocks=20wardriving.=20A=20?= =?UTF-8?q?disclaimer=20warns=20about=20third-party=20data=20sharing=20on?= =?UTF-8?q?=20first=20enable.=20Errors=20from=20the=20custom=20endpoint=20?= =?UTF-8?q?are=20=20=20throttled=20to=20avoid=20flooding=20your=20error=20?= =?UTF-8?q?log.=20A=20developer-facing=20API=20contract=20document=20(docs?= =?UTF-8?q?/CUSTOM=5FAPI=5FENDPOINT.md)=20is=20included=20with=20full=20pa?= =?UTF-8?q?yload=20schemas=20and=20=20=20examples=20for=20all=20ping=20typ?= =?UTF-8?q?es=20(TX,=20RX,=20DISC,=20TRACE).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CUSTOM_API_ENDPOINT.md | 207 ++++++++++++++++++++++++++ lib/models/user_preferences.dart | 42 +++++- lib/providers/app_state_provider.dart | 10 ++ lib/screens/settings_screen.dart | 197 ++++++++++++++++++++++++ lib/services/api_queue_service.dart | 6 + lib/services/custom_api_service.dart | 105 +++++++++++++ 6 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 docs/CUSTOM_API_ENDPOINT.md create mode 100644 lib/services/custom_api_service.dart diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md new file mode 100644 index 00000000..88064fe8 --- /dev/null +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -0,0 +1,207 @@ +# Custom API Endpoint - Third-Party Developer Guide + +This document describes the API contract for receiving forwarded wardrive data from MeshMapper. When a user enables "Custom API Endpoint" in MeshMapper settings, every successful wardrive batch upload is also forwarded to your endpoint. + +## Request Format + +| Field | Value | +|-------|-------| +| **Method** | `POST` | +| **Content-Type** | `application/json` | +| **Authentication** | `X-API-Key` header (value configured by the user in MeshMapper settings) | + +### Request Body + +```json +{ + "data": [ + { /* ping object */ }, + { /* ping object */ }, + ... + ] +} +``` + +The `data` array contains 1-50 ping objects per batch. Batches arrive approximately every 15-30 seconds during an active wardriving session. + +## Ping Object Schema + +Every ping object contains a `type` field that determines which additional fields are present. + +### Common Fields (all types) + +| Field | Type | Description | +|-------|------|-------------| +| `type` | `string` | Ping type: `"TX"`, `"RX"`, `"DISC"`, or `"TRACE"` | +| `lat` | `number` | Latitude (WGS84, decimal degrees) | +| `lon` | `number` | Longitude (WGS84, decimal degrees) | +| `timestamp` | `integer` | Unix timestamp in seconds | +| `external_antenna` | `boolean` | Whether an external antenna is connected to the device | +| `noisefloor` | `integer\|null` | Ambient noise floor in dBm (e.g., -103). Null if unavailable. | +| `power` | `string\|null` | Radio TX power formatted as `"X.Xw"` (e.g., `"0.3w"`, `"1.0w"`, `"2.0w"`). Null if unavailable. | + +### TX Ping (type: "TX") + +A transmitted ping broadcast on the wardriving channel, with repeater echo results. + +| Field | Type | Description | +|-------|------|-------------| +| `heard_repeats` | `string` | Comma-separated repeater echoes: `"id(snr),id(snr)"` or `"None"` if no repeaters heard. IDs are hex strings (1-6 chars). SNR is in dB. | + +**Example:** + +```json +{ + "type": "TX", + "lat": 45.26974, + "lon": -75.77746, + "noisefloor": -103, + "heard_repeats": "4e(12.25),77(8.50)", + "timestamp": 1768762843, + "external_antenna": false, + "power": "0.3w" +} +``` + +**Example (no repeaters heard):** + +```json +{ + "type": "TX", + "lat": 45.27001, + "lon": -75.77802, + "noisefloor": -101, + "heard_repeats": "None", + "timestamp": 1768762873, + "external_antenna": false, + "power": "0.3w" +} +``` + +### RX Ping (type: "RX") + +A passively observed mesh packet from a nearby repeater. + +| Field | Type | Description | +|-------|------|-------------| +| `heard_repeats` | `string` | Single repeater with SNR: `"id(snr)"` (e.g., `"4e(12.0)"`). | + +**Example:** + +```json +{ + "type": "RX", + "lat": 45.26950, + "lon": -75.77700, + "noisefloor": -105, + "heard_repeats": "4e(12.00)", + "timestamp": 1768762900, + "external_antenna": false, + "power": "0.3w" +} +``` + +### DISC Ping (type: "DISC") - Discovery Response + +A response from a discovered repeater or room node. + +| Field | Type | Description | +|-------|------|-------------| +| `repeater_id` | `string` | Hex ID of the discovered node (e.g., `"a3b2c1"`), or `"None"` for failed discovery. | +| `node_type` | `string` | Node type: `"REPEATER"` or `"ROOM"`. Absent on failed discovery. | +| `local_snr` | `number` | SNR measured locally (dB). | +| `local_rssi` | `integer` | RSSI measured locally (dBm, negative). | +| `remote_snr` | `number` | SNR reported by the remote node (dB). | +| `public_key` | `string` | Full 32-byte public key of the node (64 hex chars). | + +**Example (successful discovery):** + +```json +{ + "type": "DISC", + "lat": 45.26980, + "lon": -75.77750, + "noisefloor": -102, + "repeater_id": "a3b2c1", + "node_type": "REPEATER", + "local_snr": 11.75, + "local_rssi": -85, + "remote_snr": 9.50, + "public_key": "a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "timestamp": 1768762950, + "external_antenna": true, + "power": "1.0w" +} +``` + +**Example (failed discovery - no nodes responded):** + +```json +{ + "type": "DISC", + "lat": 45.27010, + "lon": -75.77800, + "noisefloor": -100, + "repeater_id": "None", + "timestamp": 1768762980, + "external_antenna": true, + "power": "1.0w" +} +``` + +### TRACE Ping (type: "TRACE") + +A targeted zero-hop trace to a specific repeater. + +| Field | Type | Description | +|-------|------|-------------| +| `repeater_id` | `string` | Hex ID of the targeted repeater. | +| `local_snr` | `number` | SNR measured locally (dB). | +| `local_rssi` | `integer` | RSSI measured locally (dBm, negative). | +| `remote_snr` | `number` | SNR reported by the repeater (dB). | + +**Example:** + +```json +{ + "type": "TRACE", + "lat": 45.26990, + "lon": -75.77730, + "noisefloor": -104, + "repeater_id": "4e2f", + "local_snr": 10.50, + "local_rssi": -88, + "remote_snr": 8.25, + "timestamp": 1768763010, + "external_antenna": false, + "power": "0.3w" +} +``` + +## Expected Response + +Your endpoint should return any `2xx` HTTP status code on success. The response body is ignored by MeshMapper. + +| Status | MeshMapper Behavior | +|--------|-------------------| +| `200-299` | Logged as success. No further action. | +| `4xx` | Error logged to user's error tab (throttled to once per minute per status code). | +| `5xx` | Error logged to user's error tab (throttled to once per minute per status code). | +| Timeout (>10s) | Error logged as timeout. | +| Network error | Error logged as network error. | + +MeshMapper does **not** retry failed custom API requests. Each batch is sent exactly once. + +## Security Notes + +- **HTTPS required**: MeshMapper validates that the configured URL uses HTTPS. HTTP endpoints are rejected at the settings level. +- **API key in header**: The user-configured API key is sent as `X-API-Key` header, not in the request body. +- **No MeshMapper credentials**: The MeshMapper API key and session ID are never included in forwarded requests. You receive only the raw ping data. +- **Fire-and-forget**: Custom API errors never affect MeshMapper's primary data submission. A broken custom endpoint cannot disrupt wardriving. + +## Rate and Volume + +- **Batch frequency**: Every 15-30 seconds during active wardriving (depends on auto-ping interval and batch timer). +- **Batch size**: 1-50 ping objects per request (typically 1-10). +- **Session duration**: Wardriving sessions commonly last 30 minutes to several hours. +- **Concurrent users**: Plan for multiple users if distributing your endpoint URL. Each user sends independently. diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 3275a1fd..dd5591d4 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -100,6 +100,18 @@ class UserPreferences { /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) final bool disconnectAlertEnabled; + /// Custom API endpoint enabled (forwards wardrive payload to third-party URL) + final bool customApiEnabled; + + /// Custom API endpoint URL (must be HTTPS) + final String? customApiUrl; + + /// Custom API endpoint key (sent as X-API-Key header) + final String? customApiKey; + + /// Whether the user has accepted the third-party data sharing disclaimer + final bool customApiDisclaimerAccepted; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -134,6 +146,10 @@ class UserPreferences { this.colorVisionType = 'none', this.mapTilesEnabled = true, this.disconnectAlertEnabled = false, + this.customApiEnabled = false, + this.customApiUrl, + this.customApiKey, + this.customApiDisclaimerAccepted = false, }); /// Create from JSON (for persistence) @@ -172,6 +188,10 @@ class UserPreferences { colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, + customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, + customApiUrl: json['customApiUrl'] as String?, + customApiKey: json['customApiKey'] as String?, + customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, ); } @@ -211,6 +231,10 @@ class UserPreferences { 'colorVisionType': colorVisionType, 'mapTilesEnabled': mapTilesEnabled, 'disconnectAlertEnabled': disconnectAlertEnabled, + 'customApiEnabled': customApiEnabled, + 'customApiUrl': customApiUrl, + 'customApiKey': customApiKey, + 'customApiDisclaimerAccepted': customApiDisclaimerAccepted, }; } @@ -249,6 +273,10 @@ class UserPreferences { String? colorVisionType, bool? mapTilesEnabled, bool? disconnectAlertEnabled, + bool? customApiEnabled, + String? customApiUrl, + String? customApiKey, + bool? customApiDisclaimerAccepted, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -284,6 +312,10 @@ class UserPreferences { colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, + customApiEnabled: customApiEnabled ?? this.customApiEnabled, + customApiUrl: customApiUrl ?? this.customApiUrl, + customApiKey: customApiKey ?? this.customApiKey, + customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, ); } @@ -347,7 +379,11 @@ class UserPreferences { other.gpsMarkerStyle == gpsMarkerStyle && other.colorVisionType == colorVisionType && other.mapTilesEnabled == mapTilesEnabled && - other.disconnectAlertEnabled == disconnectAlertEnabled; + other.disconnectAlertEnabled == disconnectAlertEnabled && + other.customApiEnabled == customApiEnabled && + other.customApiUrl == customApiUrl && + other.customApiKey == customApiKey && + other.customApiDisclaimerAccepted == customApiDisclaimerAccepted; } @override @@ -385,6 +421,10 @@ class UserPreferences { colorVisionType, mapTilesEnabled, disconnectAlertEnabled, + customApiEnabled, + customApiUrl, + customApiKey, + customApiDisclaimerAccepted, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 82eeed20..601ba7ef 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -36,6 +36,7 @@ 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/custom_api_service.dart'; import '../utils/constants.dart'; import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; @@ -85,6 +86,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final ApiQueueService _apiQueueService; late final OfflineSessionService _offlineSessionService; late final DeviceModelService _deviceModelService; + late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) @@ -578,6 +580,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService = ApiService(); _apiQueueService = ApiQueueService(apiService: _apiService); + // Initialize custom API forwarding service + _customApiService = CustomApiService(prefsGetter: () => _preferences); + _customApiService.onError = (message) { + logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); + }; + _apiQueueService.customApiService = _customApiService; + // Set up session error callback for auto-disconnect _apiService.onSessionError = (reason, message) async { debugError('[APP] Session error from API: $reason - $message'); @@ -5518,6 +5527,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService?.dispose(); _gpsService.dispose(); _apiQueueService.dispose(); + _customApiService.dispose(); _offlineSessionService.dispose(); _apiService.dispose(); _bluetoothService.dispose(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b0013667..81d0b2e2 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -553,6 +553,46 @@ class _SettingsScreenState extends State { ), ]), + // API Endpoints + _buildSection(context, 'API Endpoints', [ + const ListTile( + leading: Icon(Icons.cloud_done, color: Colors.green), + title: Text('MeshMapper API'), + subtitle: Text('Always active'), + ), + SwitchListTile( + secondary: const Icon(Icons.api), + title: const Text('Custom API Endpoint'), + subtitle: Text(prefs.customApiEnabled + ? (prefs.customApiUrl ?? 'Not configured') + : 'Forward pings to a third-party server'), + value: prefs.customApiEnabled, + onChanged: isAutoMode ? null : (value) { + if (value && !prefs.customApiDisclaimerAccepted) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(customApiEnabled: value)); + } + }, + ), + if (prefs.customApiEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Endpoint URL'), + subtitle: Text(prefs.customApiUrl ?? 'Not set'), + trailing: const Icon(Icons.chevron_right), + onTap: isAutoMode ? null : () => _showCustomApiUrlDialog(context, appState), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('API Key'), + subtitle: Text(prefs.customApiKey != null ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Not set'), + trailing: const Icon(Icons.chevron_right), + onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), + ), + ], + ]), + // Offline Sessions _buildSection(context, 'Offline Sessions', [ if (appState.offlineSessions.isEmpty) @@ -2102,6 +2142,163 @@ class _SettingsScreenState extends State { ); } + void _showCustomApiDisclaimer(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber, size: 24, color: Colors.amber), + SizedBox(width: 8), + Flexible(child: Text('Third-Party Data Sharing')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Enabling this feature will forward your wardriving data to a ' + 'third-party server that is not operated by MeshMapper.', + ), + SizedBox(height: 12), + Text( + 'The following data will be shared:\n' + '\u2022 GPS coordinates (latitude/longitude)\n' + '\u2022 Repeater IDs and signal data\n' + '\u2022 Device antenna and power settings\n' + '\u2022 Timestamps', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + 'MeshMapper is not responsible for how third parties handle ' + 'your data. Only enable this if you trust the endpoint operator.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith( + customApiDisclaimerAccepted: true, + customApiEnabled: true, + ), + ); + }, + child: const Text('I Understand, Enable'), + ), + ], + ), + ); + } + + void _showCustomApiUrlDialog(BuildContext context, AppStateProvider appState) { + final controller = TextEditingController( + text: appState.preferences.customApiUrl ?? '', + ); + String? errorText; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Custom API URL'), + content: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'Endpoint URL', + hintText: 'https://api.example.com/wardrive', + helperText: 'HTTPS required', + errorText: errorText, + border: const OutlineInputBorder(), + ), + onChanged: (_) { + if (errorText != null) setState(() => errorText = null); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final url = controller.text.trim(); + if (url.isEmpty) { + setState(() => errorText = 'URL is required'); + return; + } + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + setState(() => errorText = 'Invalid URL'); + return; + } + if (uri.scheme != 'https') { + setState(() => errorText = 'HTTPS is required'); + return; + } + Navigator.pop(context); + appState.updatePreferences( + appState.preferences.copyWith(customApiUrl: url), + ); + }, + child: const Text('Save'), + ), + ], + ), + ), + ); + } + + void _showCustomApiKeyDialog(BuildContext context, AppStateProvider appState) { + final controller = TextEditingController( + text: appState.preferences.customApiKey ?? '', + ); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Custom API Key'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'API Key', + hintText: 'Enter your API key', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final key = controller.text.trim(); + if (key.isEmpty) return; + Navigator.pop(context); + appState.updatePreferences( + appState.preferences.copyWith(customApiKey: key), + ); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index eb8b70fc..932f06ed 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -5,6 +5,7 @@ import 'package:hive/hive.dart'; import '../models/api_queue_item.dart'; import '../utils/debug_logger_io.dart'; import 'api_service.dart'; +import 'custom_api_service.dart'; /// API queue service with batch upload and retry logic /// Ported from apiQueue and batchUpload() in wardrive.js @@ -51,6 +52,9 @@ class ApiQueueService { /// Callback when storage was cleaned up (for user-visible info logging) void Function(String infoMessage)? onStorageCleanup; + /// Custom API service for forwarding pings to third-party endpoint + CustomApiService? customApiService; + /// Number of pings accumulated in current offline session int get offlinePingCount => _offlinePings.length; @@ -576,6 +580,8 @@ class ApiQueueService { } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); onUploadSuccess?.call(uploadedCount); + // Fire-and-forget: forward to custom API endpoint + customApiService?.forwardPings(pings); } else if (result == UploadResult.nonRetryable) { // Data is permanently invalid — discard for (final item in hiveItems) { diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart new file mode 100644 index 00000000..afeda7fa --- /dev/null +++ b/lib/services/custom_api_service.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/user_preferences.dart'; +import '../utils/debug_logger_io.dart'; + +/// Service for forwarding wardrive ping payloads to a user-configured +/// third-party API endpoint. Fire-and-forget: never blocks MeshMapper uploads. +/// +/// Payload sent to custom endpoint: +/// ```json +/// { "data": [ ...same ping objects as MeshMapper... ] } +/// ``` +/// +/// MeshMapper API key and session_id are NOT included. +/// Custom API key is sent as X-API-Key header. +class CustomApiService { + final http.Client _client; + final UserPreferences Function() _prefsGetter; + + /// Throttle map: error type → last time logged to user-facing error tab + final Map _errorThrottle = {}; + static const Duration _throttleWindow = Duration(minutes: 1); + static const Duration _requestTimeout = Duration(seconds: 10); + + /// Callback for user-facing error logging (wired to AppStateProvider.logError) + void Function(String message)? onError; + + CustomApiService({ + required UserPreferences Function() prefsGetter, + http.Client? client, + }) : _prefsGetter = prefsGetter, + _client = client ?? http.Client(); + + /// Fire-and-forget: forward pings to the custom endpoint. + /// Called after successful MeshMapper upload. Never throws. + void forwardPings(List> pings) { + final prefs = _prefsGetter(); + if (!prefs.customApiEnabled) return; + if (prefs.customApiUrl == null || prefs.customApiUrl!.isEmpty) return; + if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) return; + + // Fire and forget — do not await + _doForward(prefs.customApiUrl!, prefs.customApiKey!, pings); + } + + Future _doForward( + String url, + String apiKey, + List> pings, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = json.encode({'data': pings}); + + final response = await _client + .post( + Uri.parse(url), + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + }, + body: payload, + ) + .timeout(_requestTimeout); + + stopwatch.stop(); + + if (response.statusCode >= 200 && response.statusCode < 300) { + debugLog('[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); + } else { + final errorType = 'http_${response.statusCode}'; + debugError('[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); + debugError('[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + _throttledError(errorType, 'Custom API returned HTTP ${response.statusCode}'); + } + } on TimeoutException { + stopwatch.stop(); + debugError('[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); + _throttledError('timeout', 'Custom API request timed out'); + } catch (e) { + stopwatch.stop(); + debugError('[CUSTOM API] Forward exception: $e'); + _throttledError('network_error', 'Custom API network error: ${e.runtimeType}'); + } + } + + /// Log error to user-facing error tab, throttled to one per error type per minute. + /// Debug logs (debugError) are always emitted unthrottled. + void _throttledError(String errorType, String message) { + final now = DateTime.now(); + final lastLog = _errorThrottle[errorType]; + if (lastLog != null && now.difference(lastLog) < _throttleWindow) { + return; // Suppressed — same error type logged within 1 minute + } + _errorThrottle[errorType] = now; + onError?.call(message); + } + + void dispose() { + _errorThrottle.clear(); + } +} From dbb86c232902ddbba5c485d962484b40075f6aac Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 16:51:19 -0400 Subject: [PATCH 044/360] Fixed a Dart analysis warning in the color vision selector where a non-nullable tuple element was unnecessarily checked for null. --- lib/screens/settings_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 81d0b2e2..12fab34a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1134,7 +1134,7 @@ class _SettingsScreenState extends State { RadioListTile( secondary: const Icon(Icons.visibility), title: Text(label), - subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null, + subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), value: value, ), ], From 51a160f2d153a7e57c51a0a0d37060ccf6a28dce Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 16:52:47 -0400 Subject: [PATCH 045/360] Updated documentation --- docs/CUSTOM_API_ENDPOINT.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md index 88064fe8..9b379d23 100644 --- a/docs/CUSTOM_API_ENDPOINT.md +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -196,7 +196,6 @@ MeshMapper does **not** retry failed custom API requests. Each batch is sent exa - **HTTPS required**: MeshMapper validates that the configured URL uses HTTPS. HTTP endpoints are rejected at the settings level. - **API key in header**: The user-configured API key is sent as `X-API-Key` header, not in the request body. -- **No MeshMapper credentials**: The MeshMapper API key and session ID are never included in forwarded requests. You receive only the raw ping data. - **Fire-and-forget**: Custom API errors never affect MeshMapper's primary data submission. A broken custom endpoint cannot disrupt wardriving. ## Rate and Volume From 3b789721a2894f4edca96fcb7308d504d39adf94 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 16:56:32 -0400 Subject: [PATCH 046/360] the disclaimer shows every time the toggle is switched on, regardless of prior acceptance --- lib/screens/settings_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 12fab34a..d93dcef0 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -568,10 +568,10 @@ class _SettingsScreenState extends State { : 'Forward pings to a third-party server'), value: prefs.customApiEnabled, onChanged: isAutoMode ? null : (value) { - if (value && !prefs.customApiDisclaimerAccepted) { + if (value) { _showCustomApiDisclaimer(context, appState); } else { - appState.updatePreferences(prefs.copyWith(customApiEnabled: value)); + appState.updatePreferences(prefs.copyWith(customApiEnabled: false)); } }, ), From bcc61bf5e65dad06a61aa2f6f499ee1a147d512e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 17:40:41 -0400 Subject: [PATCH 047/360] The user-facing error for that DNS failure will now show Custom API: Failed host lookup: 'blah.blah' instead of the opaque Custom API network error: ClientException. --- lib/services/custom_api_service.dart | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index afeda7fa..19a2f0c6 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -83,7 +83,9 @@ class CustomApiService { } catch (e) { stopwatch.stop(); debugError('[CUSTOM API] Forward exception: $e'); - _throttledError('network_error', 'Custom API network error: ${e.runtimeType}'); + // Extract a concise, user-friendly message from the exception chain + final description = _describeError(e); + _throttledError('network_error', 'Custom API: $description'); } } @@ -99,6 +101,21 @@ class CustomApiService { onError?.call(message); } + /// Extract a concise error description from an exception. + /// The http package wraps SocketException inside ClientException — + /// drill into the chain to surface the actionable detail (e.g. host lookup failure). + String _describeError(Object e) { + final full = e.toString(); + // Look for SocketException detail (e.g. "Failed host lookup: 'blah.blah'") + final socketMatch = RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); + if (socketMatch != null) return socketMatch.group(1)!.trim(); + // Look for OS-level message + final osMatch = RegExp(r'OS Error: (.+?)(?:,|\))').firstMatch(full); + if (osMatch != null) return osMatch.group(1)!.trim(); + // Fallback: use the exception type + return e.runtimeType.toString(); + } + void dispose() { _errorThrottle.clear(); } From 5ca94305ffae1ec12b6e2b27b179d1fa6e2c8063 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 18:02:03 -0400 Subject: [PATCH 048/360] =?UTF-8?q?=20Pac-Man=20GPS=20Marker=20=E2=80=94?= =?UTF-8?q?=20A=20new=20Pac-Man=20GPS=20marker=20style=20is=20now=20availa?= =?UTF-8?q?ble=20in=20Settings=20>=20GPS=20Marker.=20The=20mouth=20faces?= =?UTF-8?q?=20your=20direction=20of=20travel=20and=20rotates=20with=20your?= =?UTF-8?q?=20=20heading,=20just=20like=20the=20Arrow=20and=20Walk=20marke?= =?UTF-8?q?rs.=20Waka=20waka.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/screens/settings_screen.dart | 73 +++++++++++++++++++++++++++++--- lib/widgets/map_widget.dart | 71 +++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d93dcef0..04d20284 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io' show File; +import 'dart:math' as math; import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; @@ -1043,12 +1044,13 @@ class _SettingsScreenState extends State { } void _showGpsMarkerSelector(BuildContext context, AppStateProvider appState) { - final options = [ - ('arrow', 'Arrow', Icons.navigation), - ('car', 'Car', Icons.directions_car), - ('bike', 'Bike', Icons.directions_bike), - ('boat', 'Boat', Icons.directions_boat), - ('walk', 'Walk', Icons.directions_walk), + final options = <(String, String, Widget)>[ + ('arrow', 'Arrow', const Icon(Icons.navigation)), + ('car', 'Car', const Icon(Icons.directions_car)), + ('bike', 'Bike', const Icon(Icons.directions_bike)), + ('boat', 'Boat', const Icon(Icons.directions_boat)), + ('walk', 'Walk', const Icon(Icons.directions_walk)), + ('pacman', 'Pac-Man', const _PacmanIcon()), ]; showModalBottomSheet( context: context, @@ -1076,7 +1078,7 @@ class _SettingsScreenState extends State { children: [ for (final (value, label, icon) in options) RadioListTile( - secondary: Icon(icon), + secondary: icon, title: Text(label), value: value, ), @@ -2554,3 +2556,60 @@ class _OfflineSessionTile extends StatelessWidget { ); } } + +/// Pac-Man icon widget for the GPS marker selector +class _PacmanIcon extends StatelessWidget { + const _PacmanIcon(); + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: const Size(24, 24), + painter: _PacmanIconPainter( + color: IconTheme.of(context).color ?? Colors.grey, + ), + ); + } +} + +class _PacmanIconPainter extends CustomPainter { + final Color color; + const _PacmanIconPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + const radius = 10.0; + + const mouthAngle = 45.0 * (math.pi / 180); + // Mouth faces right for the settings icon (natural reading direction) + const startAngle = mouthAngle / 2; + const sweepAngle = 2 * math.pi - mouthAngle; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final path = Path() + ..moveTo(cx, cy) + ..arcTo( + Rect.fromCircle(center: Offset(cx, cy), radius: radius), + startAngle, + sweepAngle, + false, + ) + ..close(); + canvas.drawPath(path, paint); + + // Eye + final eyePaint = Paint() + ..color = color == Colors.grey ? Colors.white : Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(cx + 2, cy - 3.5), 1.5, eyePaint); + } + + @override + bool shouldRepaint(covariant _PacmanIconPainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 569958ed..25aa27b8 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -707,9 +707,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (appState.currentPosition != null) MarkerLayer( // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow and walk rotate with heading (handled by Transform.rotate in the painter) + // arrow, walk, and pacman rotate with heading (handled by Transform.rotate in the painter) rotate: appState.preferences.gpsMarkerStyle != 'arrow' && - appState.preferences.gpsMarkerStyle != 'walk', + appState.preferences.gpsMarkerStyle != 'walk' && + appState.preferences.gpsMarkerStyle != 'pacman', markers: [ Marker( point: LatLng( @@ -2101,8 +2102,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final headingRadians = heading * (math.pi / 180); final style = context.read().preferences.gpsMarkerStyle; - // Arrow and walk rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk'; + // Arrow, walk, and pacman rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk' || style == 'pacman'; final CustomPainter painter; switch (style) { @@ -2114,6 +2115,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { painter = const _BoatMarkerPainter(); case 'walk': painter = const _WalkMarkerPainter(); + case 'pacman': + painter = const _PacmanMarkerPainter(); case 'arrow': default: painter = const _ArrowPainter(); @@ -3240,6 +3243,66 @@ class _WalkMarkerPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a Pac-Man for GPS position marker — mouth faces up (direction of travel) +class _PacmanMarkerPainter extends CustomPainter { + const _PacmanMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + const radius = 10.0; + + // Mouth opening angle: 45° total (22.5° each side of the top) + // In canvas coordinates, 0° = 3 o'clock, so "up" = -90° = -π/2. + // Arc sweeps clockwise. We start at (-90 - 22.5)° and sweep (360 - 45)°. + const mouthAngle = 45.0 * (math.pi / 180); + const startAngle = -math.pi / 2 + mouthAngle / 2; // right edge of mouth + const sweepAngle = 2 * math.pi - mouthAngle; + + // White outline for visibility + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final outlinePath = ui.Path() + ..moveTo(cx, cy) + ..arcTo( + Rect.fromCircle(center: Offset(cx, cy), radius: radius + 1.5), + startAngle, + sweepAngle, + false, + ) + ..close(); + canvas.drawPath(outlinePath, outlinePaint); + + // Yellow Pac-Man body + final bodyPaint = Paint() + ..color = const Color(0xFFFFEB3B) // Material yellow + ..style = PaintingStyle.fill; + + final bodyPath = ui.Path() + ..moveTo(cx, cy) + ..arcTo( + Rect.fromCircle(center: Offset(cx, cy), radius: radius), + startAngle, + sweepAngle, + false, + ) + ..close(); + canvas.drawPath(bodyPath, bodyPaint); + + // Eye — small black circle, offset toward the mouth side (upper-left of center) + final eyePaint = Paint() + ..color = Colors.black + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(cx - 2.5, cy - 4.5), 1.5, eyePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + /// Paints a teardrop/pin marker for coverage dots class _PinMarkerPainter extends CustomPainter { final Color color; From e28242642af7cae8dcab15ae32db66c0e3558694 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Apr 2026 22:10:41 -0400 Subject: [PATCH 049/360] - Ping Focus Mode: tap any ping marker to see which repeaters heard it. Draws dotted lines color-coded by signal quality with distance tags, fades unrelated markers, and zooms to fit. Works for all ping types. Ambiguous repeater matches get a white border outline. Dismiss to snap back to your previous view. --- lib/widgets/map_widget.dart | 396 ++++++++++++++++++++++++++---- lib/widgets/repeater_id_chip.dart | 35 +-- 2 files changed, 374 insertions(+), 57 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 25aa27b8..9dbb21b2 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -12,6 +12,7 @@ import '../models/log_entry.dart'; import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; @@ -107,6 +108,17 @@ final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileP ); } +/// Resolved repeater with SNR and ambiguity info for ping focus mode. +/// Line color is based on [snr] (green/yellow/red). When a short hex ID +/// matches multiple repeaters, [ambiguous] is true and the line gets a +/// distinct border to indicate uncertainty. +class _ResolvedRepeater { + final Repeater repeater; + final double? snr; + final bool ambiguous; + const _ResolvedRepeater(this.repeater, this.snr, this.ambiguous); +} + /// Map widget with TX/RX markers /// Uses flutter_map with OpenStreetMap tiles class MapWidget extends StatefulWidget { @@ -164,6 +176,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map navigation trigger tracking (from log screen) int _lastNavigationTrigger = 0; + // Ping focus mode — highlight connected repeaters when a marker is tapped + LatLng? _focusedPingLocation; + DateTime? _focusedPingTimestamp; + List<_ResolvedRepeater> _focusedRepeaters = []; + LatLng? _preFocusCenter; + double? _preFocusZoom; + bool _wasAutoFollowBeforeFocus = false; + bool _wasRotatingBeforeFocus = false; // true if heading mode was active + // Smooth animation for map movement AnimationController? _animationController; Animation? _animation; @@ -304,6 +325,22 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _animationController!.forward(); } + /// Zoom to fit a focused ping and its connected repeaters on screen + void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + if (!_isMapReady || !mounted) return; + + final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; + if (points.length < 2) return; + + final fitted = CameraFit.coordinates( + coordinates: points, + padding: EdgeInsets.fromLTRB(60, 60, 60, MediaQuery.of(context).size.height * 0.4), + maxZoom: 15, + ).fit(_mapController.camera); + + _animateToPositionWithZoom(fitted.center, fitted.zoom); + } + /// Smoothly animate the map rotation to match heading void _animateToRotation(double targetHeading) { if (!_isMapReady || !mounted || _alwaysNorth) return; @@ -694,14 +731,60 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), + // Focus mode: polylines from focused ping to each connected repeater + // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) + PolylineLayer( + polylines: _focusedRepeaters.map((r) { + final lineColor = r.snr != null + ? PingColors.snrColor(r.snr!) + : Colors.grey; + return Polyline( + points: [_focusedPingLocation!, LatLng(r.repeater.lat, r.repeater.lon)], + color: lineColor.withValues(alpha: 0.9), + strokeWidth: 3.5, + isDotted: true, + borderStrokeWidth: r.ambiguous ? 1.5 : 0, + borderColor: r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, + ); + }).toList(), + ), + // Repeater markers (magenta with ID, rotate with map) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, + // During focus mode, split into two layers: faded repeaters below, connected on top + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ + // Faded non-connected repeaters (below) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyFaded: true, + ), + ), + // Distance labels (middle) + MarkerLayer( + rotate: true, + markers: _buildFocusDistanceLabels(appState), + ), + // Connected repeaters (on top) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyConnected: true, + ), + ), + ] else + // Normal mode: single layer with all repeaters + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), ), - ), // Current position marker if (appState.currentPosition != null) @@ -1715,63 +1798,94 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return timestamped.map((e) => e.$2).toList(); } + /// Check if a ping at given lat/lon/timestamp is the currently focused ping. + bool _isFocusedPing(double lat, double lon, DateTime timestamp) { + return _focusedPingLocation != null && + _focusedPingTimestamp == timestamp && + _focusedPingLocation!.latitude == lat && + _focusedPingLocation!.longitude == lon; + } + + /// Apply focus fade to a marker color. Returns dimmed color if focus is active + /// and this marker is not the focused one. + Color _applyFocusFade(Color color, bool isFocused) { + if (_focusedPingLocation == null || isFocused) return color; + return color.withValues(alpha: 0.15); + } + Marker _buildTxMarker(TxPing ping) { + final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final color = ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; + final size = isFocused ? 24.0 : 20.0; return Marker( point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, + width: size, + height: size, child: GestureDetector( onTap: () => _showTxPingDetails(ping), - child: _buildCoverageMarkerChild( - ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, - ), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), ), ); } Marker _buildRxMarker(RxPing ping) { + final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final size = isFocused ? 24.0 : 20.0; return Marker( point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, + width: size, + height: size, child: GestureDetector( onTap: () => _showRxPingDetails(ping), - child: _buildCoverageMarkerChild(PingColors.rx), + child: _buildCoverageMarkerChild(_applyFocusFade(PingColors.rx, isFocused)), ), ); } Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final color = entry.nodeCount == 0 + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) + : _discMarkerColor; + final size = isFocused ? 24.0 : 20.0; return Marker( point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, + width: size, + height: size, child: GestureDetector( onTap: () => _showDiscPingDetails(entry), - child: _buildCoverageMarkerChild( - entry.nodeCount == 0 - ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) - : _discMarkerColor, - ), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), ), ); } Marker _buildTraceMarker(TraceLogEntry entry) { + final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final color = entry.success ? Colors.cyan : Colors.grey; + final size = isFocused ? 24.0 : 20.0; return Marker( point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, + width: size, + height: size, child: GestureDetector( onTap: () => _showTraceDetails(entry), - child: _buildCoverageMarkerChild( - entry.success ? Colors.cyan : Colors.grey, - ), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), ), ); } void _showTraceDetails(TraceLogEntry entry) { + // Activate focus mode for successful traces with a known repeater + if (entry.success) { + final resolved = _resolveRepeatersByHexIds( + [entry.targetRepeaterId], + snrValues: [entry.localSnr], + ); + if (resolved.isNotEmpty) { + _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + } + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -1949,7 +2063,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( @@ -1996,7 +2110,51 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), ), - ); + ).whenComplete(() => _dismissPingFocus()); + } + + /// Build distance label markers at the midpoint of each focus line. + List _buildFocusDistanceLabels(AppStateProvider appState) { + if (_focusedPingLocation == null) return []; + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + + return _focusedRepeaters.map((r) { + final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); + // Midpoint of the line + final midLat = (ping.latitude + repeaterPos.latitude) / 2; + final midLon = (ping.longitude + repeaterPos.longitude) / 2; + // Distance in meters — use GpsService for consistency with repeater popup + final meters = GpsService.distanceBetween( + ping.latitude, ping.longitude, repeaterPos.latitude, repeaterPos.longitude, + ); + final label = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + return Marker( + point: LatLng(midLat, midLon), + width: 70, + height: 22, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + ); + }).toList(); } /// DISC marker color (delegates to active palette) @@ -2015,6 +2173,106 @@ class _MapWidgetState extends State with TickerProviderStateMixin { static Color get _repeaterDeadColor => PingColors.repeaterDead; /// Get set of duplicate repeater IDs + /// Resolve heard repeater hex IDs to Repeater objects with GPS coordinates. + /// Marks matches as ambiguous when a single hex ID matches multiple repeaters. + /// [snrValues] provides the SNR for each hex ID (same index) for line coloring. + List<_ResolvedRepeater> _resolveRepeatersByHexIds( + List hexIds, { + List fullHexIds = const [], + List snrValues = const [], + }) { + final allRepeaters = context.read().repeaters; + final resolved = <_ResolvedRepeater>[]; + for (int i = 0; i < hexIds.length; i++) { + final fullHex = i < fullHexIds.length ? fullHexIds[i] : null; + final snr = i < snrValues.length ? snrValues[i] : null; + final matchKey = (fullHex != null && fullHex.length >= 8) + ? fullHex.substring(0, 8) + : hexIds[i]; + final matches = allRepeaters + .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .toList(); + final ambiguous = matches.length > 1; + resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); + } + return resolved; + } + + /// Activate ping focus mode — draw lines, fade markers, zoom to fit. + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { + _preFocusCenter = _mapController.camera.center; + _preFocusZoom = _mapController.camera.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + + if (_autoFollow) { + _autoFollow = false; + } + + // Lock to north-up during focus so the zoom-to-fit view is stable + if (!_alwaysNorth) { + _alwaysNorth = true; + _animateToRotation(0); // Won't fire because _alwaysNorth is now true + // Snap rotation to 0 directly + if (_isMapReady) { + _mapController.rotate(0); + } + } + + setState(() { + _focusedPingLocation = pingLocation; + _focusedPingTimestamp = timestamp; + _focusedRepeaters = repeaters; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _focusedPingLocation != null) { + _zoomToFocusBounds(pingLocation, repeaters); + } + }); + } + + /// Dismiss ping focus mode — restore map state. + void _dismissPingFocus() { + if (_focusedPingLocation == null || !mounted) return; + + final center = _preFocusCenter; + final zoom = _preFocusZoom; + final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; + final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; + + // Clear focus state but do NOT restore auto-follow or rotation yet — + // they would immediately trigger animations in the build method that + // override our zoom-back animation (both share _animationController). + setState(() { + _focusedPingLocation = null; + _focusedPingTimestamp = null; + _focusedRepeaters = []; + }); + + if (center != null && zoom != null) { + _animateToPositionWithZoom(center, zoom); + + // Restore auto-follow and heading rotation after the zoom-back + // animation completes (500ms) so they don't clobber it mid-flight. + if (shouldRestoreAutoFollow || shouldRestoreRotation) { + Future.delayed(const Duration(milliseconds: 550), () { + if (mounted) { + setState(() { + if (shouldRestoreAutoFollow) _autoFollow = true; + if (shouldRestoreRotation) _alwaysNorth = false; + }); + } + }); + } + } else { + setState(() { + if (shouldRestoreAutoFollow) _autoFollow = true; + if (shouldRestoreRotation) _alwaysNorth = false; + }); + } + } + Set _getDuplicateRepeaterIds(List repeaters) { final idCounts = {}; for (final repeater in repeaters) { @@ -2038,13 +2296,37 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers(List repeaters, int? regionHopBytesOverride) { + List _buildRepeaterMarkers( + List repeaters, + int? regionHopBytesOverride, { + bool onlyFaded = false, + bool onlyConnected = false, + }) { final duplicateIds = _getDuplicateRepeaterIds(repeaters); - - return repeaters.map((repeater) { + final hasFocus = _focusedPingLocation != null; + + return repeaters.where((repeater) { + if (!hasFocus) return true; // No focus — include all + final isConnected = _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + if (onlyConnected) return isConnected; + if (onlyFaded) return !isConnected; + return true; + }).map((repeater) { final isDuplicate = duplicateIds.contains(repeater.id); final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + // During focus mode, fade repeaters not connected to the focused ping + final isConnected = hasFocus && _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + final effectiveColor = (hasFocus && !isConnected) + ? markerColor.withValues(alpha: 0.15) + : markerColor; + final effectiveBorderColor = (hasFocus && !isConnected) + ? Colors.white.withValues(alpha: 0.15) + : Colors.white; + final effectiveTextColor = (hasFocus && !isConnected) + ? Colors.white.withValues(alpha: 0.15) + : Colors.white; + // Display hex ID based on per-repeater hop_bytes (or regional admin override) final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; @@ -2069,10 +2351,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ? const EdgeInsets.symmetric(horizontal: 4) : EdgeInsets.zero, decoration: BoxDecoration( - color: markerColor, + color: effectiveColor, borderRadius: borderRadius, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ + border: Border.all(color: effectiveBorderColor, width: 2), + boxShadow: (hasFocus && !isConnected) ? null : const [ BoxShadow( color: Colors.black26, blurRadius: 4, @@ -2086,7 +2368,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, fontWeight: FontWeight.bold, - color: Colors.white, + color: effectiveTextColor, fontFamily: 'monospace', ), ), @@ -2146,6 +2428,17 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; + // Activate focus mode if the ping was heard by known repeaters + if (heardRepeaters.isNotEmpty) { + final resolved = _resolveRepeatersByHexIds( + heardRepeaters.map((r) => r.repeaterId).toList(), + snrValues: heardRepeaters.map((r) => r.snr).toList(), + ); + if (resolved.isNotEmpty) { + _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + } + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -2306,7 +2599,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( @@ -2345,7 +2638,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), ), - ); + ).whenComplete(() => _dismissPingFocus()); } /// Show RX ping details popup @@ -2353,6 +2646,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final snrColor = PingColors.snrColor(ping.snr); final rssiColor = PingColors.rssiColor(ping.rssi); + // Activate focus mode for the RX ping's repeater + final resolved = _resolveRepeatersByHexIds( + [ping.repeaterId], + snrValues: [ping.snr], + ); + if (resolved.isNotEmpty) { + _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -2502,7 +2804,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data row InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( @@ -2537,11 +2839,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ], ), ), - ); + ).whenComplete(() => _dismissPingFocus()); } /// Show DISC ping details popup void _showDiscPingDetails(DiscLogEntry entry) { + // Activate focus mode for discovered nodes with known repeater positions + if (entry.discoveredNodes.isNotEmpty) { + final resolved = _resolveRepeatersByHexIds( + entry.discoveredNodes.map((n) => n.repeaterId).toList(), + fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + ); + if (resolved.isNotEmpty) { + _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + } + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -2716,7 +3030,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( @@ -2779,7 +3093,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), ), - ); + ).whenComplete(() => _dismissPingFocus()); } /// Build a status chip for the repeater popup diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index d2ae8e24..43a4ca42 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../models/repeater.dart'; @@ -77,7 +76,11 @@ class RepeaterIdChip extends StatelessWidget { /// For DISC pings, pass [fullHexId] (the full public key hex) to enable /// exact 4-byte matching against the repeater database instead of the /// ambiguous 1-byte prefix match used for TX/RX pings. - static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId}) { + /// Show a dialog with matching repeater names for the given [repeaterId]. + /// + /// When [fromLatLng] is provided, distances are measured from that point + /// (e.g. the ping's GPS location) instead of the user's current position. + static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId, ({double lat, double lon})? fromLatLng}) { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; @@ -119,17 +122,16 @@ class RepeaterIdChip extends StatelessWidget { ), ); } else { + // Use explicit origin point if provided, otherwise fall back to current GPS final position = appState.currentPosition; + final refLat = fromLatLng?.lat ?? position?.latitude; + final refLon = fromLatLng?.lon ?? position?.longitude; - // Sort by distance (closest first) when GPS is available - if (position != null) { + // Sort by distance (closest first) when a reference point is available + if (refLat != null && refLon != null) { matches.sort((a, b) { - final distA = GpsService.distanceBetween( - position.latitude, position.longitude, a.lat, a.lon, - ); - final distB = GpsService.distanceBetween( - position.latitude, position.longitude, b.lat, b.lon, - ); + final distA = GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); return distA.compareTo(distB); }); } @@ -138,7 +140,7 @@ class RepeaterIdChip extends StatelessWidget { content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, position: position, regionHopBytesOverride: regionOverride)) + .map((r) => _buildRepeaterRow(context, r, refLat: refLat, refLon: refLon, regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -198,7 +200,8 @@ class RepeaterIdChip extends StatelessWidget { static Widget _buildRepeaterRow( BuildContext context, Repeater repeater, { - Position? position, + double? refLat, + double? refLon, int? regionHopBytesOverride, }) { final isActive = repeater.isActive; @@ -206,14 +209,14 @@ class RepeaterIdChip extends StatelessWidget { final statusText = isActive ? 'Active' : 'Stale'; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; - // Calculate distance string if GPS is available + // Calculate distance string if a reference point is available String? distanceText; - if (position != null) { + if (refLat != null && refLon != null) { final meters = GpsService.distanceBetween( - position.latitude, position.longitude, repeater.lat, repeater.lon, + refLat, refLon, repeater.lat, repeater.lon, ); debugLog('[UI] Distance to ${repeater.name}: ' - 'from (${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}) ' + 'from (${refLat.toStringAsFixed(5)}, ${refLon.toStringAsFixed(5)}) ' 'to (${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}) ' '= ${meters.toStringAsFixed(0)}m'); final isImperial = Provider.of(context, listen: false) From bda369170dc60cfefd3cb604e8e8eaffafb0ad4e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 6 Apr 2026 13:25:08 -0400 Subject: [PATCH 050/360] Add support for importing custom API configuration from clipboard and enhance offline sessions display --- docs/CUSTOM_API_ENDPOINT.md | 21 ++++++ lib/screens/settings_screen.dart | 112 ++++++++++++++++++++++++------- lib/widgets/map_widget.dart | 63 +++++++++++++++-- 3 files changed, 167 insertions(+), 29 deletions(-) diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md index 9b379d23..fadc6cc3 100644 --- a/docs/CUSTOM_API_ENDPOINT.md +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -192,6 +192,27 @@ Your endpoint should return any `2xx` HTTP status code on success. The response MeshMapper does **not** retry failed custom API requests. Each batch is sent exactly once. +## Configuration Link for End Users + +As an endpoint operator, you can generate a configuration link that your users paste into their clipboard. In MeshMapper Settings > API Endpoints, they tap **Import from Clipboard** to auto-fill the URL and API key. + +**Format:** + +``` +meshmapper://custom-api?url=your-server.com/api/wardrive&key=your-api-key-here +``` + +- `url` — Your endpoint host and path, **without** `https://` (MeshMapper prepends it automatically). +- `key` — The API key the user should send. This will be set as their `X-API-Key` header value. + +**Example:** + +``` +meshmapper://custom-api?url=data.myproject.org/ingest/wardrive&key=sk_live_abc123def456 +``` + +The user copies this link, opens MeshMapper Settings > API Endpoints, and taps "Import from Clipboard." Both fields are populated instantly. + ## Security Notes - **HTTPS required**: MeshMapper validates that the configured URL uses HTTPS. HTTP endpoints are rejected at the settings level. diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 04d20284..9703e46c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -554,6 +554,30 @@ class _SettingsScreenState extends State { ), ]), + // Offline Sessions + _buildSection(context, 'Offline Sessions', [ + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession(context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), + onDownload: () => _downloadOfflineSession(context, appState, session.filename), + )), + ]), + // API Endpoints _buildSection(context, 'API Endpoints', [ const ListTile( @@ -591,31 +615,13 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), ), - ], - ]), - - // Offline Sessions - _buildSection(context, 'Offline Sessions', [ - if (appState.offlineSessions.isEmpty) ListTile( - leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), - title: Text( - 'No offline sessions stored', - style: TextStyle(color: Colors.grey.shade500), - ), - subtitle: Text( - 'Sessions recorded in offline mode will appear here', - style: TextStyle(fontSize: 12, color: Colors.grey.shade400), - ), - ) - else - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), + leading: const Icon(Icons.content_paste), + title: const Text('Import from Clipboard'), + subtitle: const Text('Paste a meshmapper:// config link'), + onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState), + ), + ], ]), // About @@ -991,6 +997,7 @@ class _SettingsScreenState extends State { case 'bike': return 'Bike'; case 'boat': return 'Boat'; case 'walk': return 'Walk'; + case 'pacman': return 'Pac-Man'; case 'arrow': default: return 'Arrow'; } @@ -2301,6 +2308,63 @@ class _SettingsScreenState extends State { ); } + Future _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async { + final clipData = await Clipboard.getData('text/plain'); + final text = clipData?.text?.trim(); + + if (text == null || text.isEmpty) { + if (context.mounted) AppToast.error(context, 'Clipboard is empty'); + return; + } + + // Expected format: meshmapper://custom-api?url=example.com/api.php&key=xxxxxxxxxx + if (!text.startsWith('meshmapper://')) { + if (context.mounted) { + AppToast.error(context, 'No meshmapper:// link found in clipboard'); + } + return; + } + + try { + final uri = Uri.parse(text); + final rawUrl = uri.queryParameters['url']; + final key = uri.queryParameters['key']; + + if (rawUrl == null || rawUrl.isEmpty) { + if (context.mounted) AppToast.error(context, 'Link is missing the url parameter'); + return; + } + if (key == null || key.isEmpty) { + if (context.mounted) AppToast.error(context, 'Link is missing the key parameter'); + return; + } + + final fullUrl = 'https://$rawUrl'; + + // Validate the constructed URL + final parsed = Uri.tryParse(fullUrl); + if (parsed == null || !parsed.hasAuthority) { + if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl'); + return; + } + + appState.updatePreferences( + appState.preferences.copyWith( + customApiUrl: fullUrl, + customApiKey: key, + ), + ); + + if (context.mounted) { + AppToast.success(context, 'Imported endpoint: $rawUrl'); + } + debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); + } catch (e) { + debugError('[CUSTOM API] Failed to parse clipboard link: $e'); + if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link'); + } + } + void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 9dbb21b2..e8c21f92 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -702,6 +702,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { userAgentPackageName: 'com.meshmapper.app', maxZoom: 17, retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, + ), tileProvider: SilentCancellableNetworkTileProvider(), ); }, @@ -715,12 +718,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { minZoom: 3, maxZoom: 17, tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, // Keep old tile visible until new one loads + reloadStartOpacity: 1.0, ), tileProvider: SilentCancellableNetworkTileProvider(), ), // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top + // During focus mode, the focused marker is excluded and rendered in its own top layer MarkerLayer( markers: _buildCoverageMarkers( txPings: appState.txPings, @@ -728,6 +732,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { discEntries: appState.discLogEntries, discDropEnabled: appState.discDropEnabled, traceEntries: appState.traceLogEntries, + excludeFocused: _focusedPingLocation != null, ), ), @@ -776,6 +781,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onlyConnected: true, ), ), + // Focused ping marker (above everything except GPS) + MarkerLayer( + markers: _buildFocusedPingMarker( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), + ), ] else // Normal mode: single layer with all repeaters MarkerLayer( @@ -1782,22 +1797,60 @@ class _MapWidgetState extends State with TickerProviderStateMixin { required List discEntries, required bool discDropEnabled, required List traceEntries, + bool excludeFocused = false, }) { final timestamped = <(DateTime, Marker)>[ for (final ping in txPings) - (ping.timestamp, _buildTxMarker(ping)), + if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + (ping.timestamp, _buildTxMarker(ping)), for (final ping in rxPings) - (ping.timestamp, _buildRxMarker(ping)), + if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + (ping.timestamp, _buildRxMarker(ping)), for (final entry in discEntries) - (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), for (final entry in traceEntries) - (entry.timestamp, _buildTraceMarker(entry)), + if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + (entry.timestamp, _buildTraceMarker(entry)), ]; timestamped.sort((a, b) => a.$1.compareTo(b.$1)); return timestamped.map((e) => e.$2).toList(); } + /// Build just the focused ping marker for rendering in its own top layer. + List _buildFocusedPingMarker({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + if (_focusedPingLocation == null) return []; + + for (final ping in txPings) { + if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { + return [_buildTxMarker(ping)]; + } + } + for (final ping in rxPings) { + if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { + return [_buildRxMarker(ping)]; + } + } + for (final entry in discEntries) { + if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { + return [_buildDiscMarker(entry, discDropEnabled)]; + } + } + for (final entry in traceEntries) { + if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { + return [_buildTraceMarker(entry)]; + } + } + return []; + } + /// Check if a ping at given lat/lon/timestamp is the currently focused ping. bool _isFocusedPing(double lat, double lon, DateTime timestamp) { return _focusedPingLocation != null && From df7a29c077b5e61509a79b33fcd3dcc7cc0b7e90 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 6 Apr 2026 17:33:54 -0400 Subject: [PATCH 051/360] Enhance Custom API integration by adding options to include device public key prefix and IATA zone code in payloads --- docs/CUSTOM_API_ENDPOINT.md | 10 ++++++++-- lib/models/user_preferences.dart | 12 +++++++++++- lib/providers/app_state_provider.dart | 5 +++++ lib/screens/settings_screen.dart | 9 +++++++++ lib/services/custom_api_service.dart | 19 ++++++++++++++++++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md index fadc6cc3..d480a9fa 100644 --- a/docs/CUSTOM_API_ENDPOINT.md +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -39,6 +39,8 @@ Every ping object contains a `type` field that determines which additional field | `external_antenna` | `boolean` | Whether an external antenna is connected to the device | | `noisefloor` | `integer\|null` | Ambient noise floor in dBm (e.g., -103). Null if unavailable. | | `power` | `string\|null` | Radio TX power formatted as `"X.Xw"` (e.g., `"0.3w"`, `"1.0w"`, `"2.0w"`). Null if unavailable. | +| `contact` | `string\|absent` | First 8 hex chars of the wardriver's MeshCore device public key (e.g., `"D873B1F2"`). Only present when the user enables "Include Contact Key" in settings. Useful for cross-referencing with MQTT observer data. | +| `iata` | `string\|absent` | MeshMapper zone code (e.g., `"RDU"`, `"MSP"`, `"YOW"`). Present when the wardriver is in a zone. | ### TX Ping (type: "TX") @@ -59,7 +61,9 @@ A transmitted ping broadcast on the wardriving channel, with repeater echo resul "heard_repeats": "4e(12.25),77(8.50)", "timestamp": 1768762843, "external_antenna": false, - "power": "0.3w" + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" } ``` @@ -74,7 +78,9 @@ A transmitted ping broadcast on the wardriving channel, with repeater echo resul "heard_repeats": "None", "timestamp": 1768762873, "external_antenna": false, - "power": "0.3w" + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" } ``` diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index dd5591d4..5f25a07a 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -112,6 +112,9 @@ class UserPreferences { /// Whether the user has accepted the third-party data sharing disclaimer final bool customApiDisclaimerAccepted; + /// Include device public key prefix in custom API payload (contact field) + final bool customApiIncludeContact; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -150,6 +153,7 @@ class UserPreferences { this.customApiUrl, this.customApiKey, this.customApiDisclaimerAccepted = false, + this.customApiIncludeContact = true, }); /// Create from JSON (for persistence) @@ -192,6 +196,7 @@ class UserPreferences { customApiUrl: json['customApiUrl'] as String?, customApiKey: json['customApiKey'] as String?, customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: (json['customApiIncludeContact'] as bool?) ?? true, ); } @@ -235,6 +240,7 @@ class UserPreferences { 'customApiUrl': customApiUrl, 'customApiKey': customApiKey, 'customApiDisclaimerAccepted': customApiDisclaimerAccepted, + 'customApiIncludeContact': customApiIncludeContact, }; } @@ -277,6 +283,7 @@ class UserPreferences { String? customApiUrl, String? customApiKey, bool? customApiDisclaimerAccepted, + bool? customApiIncludeContact, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -316,6 +323,7 @@ class UserPreferences { customApiUrl: customApiUrl ?? this.customApiUrl, customApiKey: customApiKey ?? this.customApiKey, customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: customApiIncludeContact ?? this.customApiIncludeContact, ); } @@ -383,7 +391,8 @@ class UserPreferences { other.customApiEnabled == customApiEnabled && other.customApiUrl == customApiUrl && other.customApiKey == customApiKey && - other.customApiDisclaimerAccepted == customApiDisclaimerAccepted; + other.customApiDisclaimerAccepted == customApiDisclaimerAccepted && + other.customApiIncludeContact == customApiIncludeContact; } @override @@ -425,6 +434,7 @@ class UserPreferences { customApiUrl, customApiKey, customApiDisclaimerAccepted, + customApiIncludeContact, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 601ba7ef..1e8e6a89 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -585,6 +585,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _customApiService.onError = (message) { logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); }; + _customApiService.contactGetter = () { + final pk = _devicePublicKey; + return (pk != null && pk.length >= 8) ? pk.substring(0, 8).toUpperCase() : null; + }; + _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; // Set up session error callback for auto-disconnect diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9703e46c..89e323a3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -615,6 +615,15 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Include Contact Key'), + subtitle: const Text('Share device public key prefix with endpoint'), + value: prefs.customApiIncludeContact, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(customApiIncludeContact: value)); + }, + ), ListTile( leading: const Icon(Icons.content_paste), title: const Text('Import from Clipboard'), diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index 19a2f0c6..b839622b 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -28,6 +28,12 @@ class CustomApiService { /// Callback for user-facing error logging (wired to AppStateProvider.logError) void Function(String message)? onError; + /// Returns the 8-char public key prefix of the connected device, or null + String? Function()? contactGetter; + + /// Returns the current IATA zone code, or null if not in a zone + String? Function()? iataGetter; + CustomApiService({ required UserPreferences Function() prefsGetter, http.Client? client, @@ -42,8 +48,19 @@ class CustomApiService { if (prefs.customApiUrl == null || prefs.customApiUrl!.isEmpty) return; if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) return; + // Enrich with contact and iata (custom API only — never sent to MeshMapper) + final contact = prefs.customApiIncludeContact ? contactGetter?.call() : null; + final iata = iataGetter?.call(); + + final enriched = pings.map((ping) { + final enrichedPing = Map.from(ping); + if (contact != null) enrichedPing['contact'] = contact; + if (iata != null) enrichedPing['iata'] = iata; + return enrichedPing; + }).toList(); + // Fire and forget — do not await - _doForward(prefs.customApiUrl!, prefs.customApiKey!, pings); + _doForward(prefs.customApiUrl!, prefs.customApiKey!, enriched); } Future _doForward( From 0ebb603665181c84a68081c1f181b5e14fb40d2b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 6 Apr 2026 21:38:13 -0400 Subject: [PATCH 052/360] Updated doc --- docs/CUSTOM_API_ENDPOINT.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md index d480a9fa..48bb4043 100644 --- a/docs/CUSTOM_API_ENDPOINT.md +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -103,7 +103,9 @@ A passively observed mesh packet from a nearby repeater. "heard_repeats": "4e(12.00)", "timestamp": 1768762900, "external_antenna": false, - "power": "0.3w" + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" } ``` @@ -136,7 +138,9 @@ A response from a discovered repeater or room node. "public_key": "a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "timestamp": 1768762950, "external_antenna": true, - "power": "1.0w" + "power": "1.0w", + "contact": "D873B1F2", + "iata": "YOW" } ``` @@ -151,7 +155,9 @@ A response from a discovered repeater or room node. "repeater_id": "None", "timestamp": 1768762980, "external_antenna": true, - "power": "1.0w" + "power": "1.0w", + "contact": "D873B1F2", + "iata": "YOW" } ``` @@ -180,7 +186,9 @@ A targeted zero-hop trace to a specific repeater. "remote_snr": 8.25, "timestamp": 1768763010, "external_antenna": false, - "power": "0.3w" + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" } ``` @@ -223,6 +231,8 @@ The user copies this link, opens MeshMapper Settings > API Endpoints, and taps " - **HTTPS required**: MeshMapper validates that the configured URL uses HTTPS. HTTP endpoints are rejected at the settings level. - **API key in header**: The user-configured API key is sent as `X-API-Key` header, not in the request body. +- **No MeshMapper credentials**: The MeshMapper API key and session ID are never included in forwarded requests. You receive only the raw ping data. +- **Contact key is opt-in**: The `contact` field (device public key prefix) is controlled by the user via "Include Contact Key" toggle. It defaults to ON but can be disabled. - **Fire-and-forget**: Custom API errors never affect MeshMapper's primary data submission. A broken custom endpoint cannot disrupt wardriving. ## Rate and Volume From 07196974664ff3d9af3c3ebe5a5ba17b2f9b1dfb Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Apr 2026 14:48:26 -0400 Subject: [PATCH 053/360] inital working LibreMap --- DEVELOPMENT.md | 2 +- android/app/build.gradle.kts | 2 +- .../plugins/GeneratedPluginRegistrant.java | 5 + ios/Podfile.lock | 111 - ios/Runner.xcodeproj/project.pbxproj | 22 + .../xcshareddata/swiftpm/Package.resolved | 68 + .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 68 + lib/models/user_preferences.dart | 16 +- lib/providers/app_state_provider.dart | 30 +- lib/screens/settings_screen.dart | 14 + lib/widgets/map_widget.dart | 3152 ++++++++++++----- pubspec.lock | 96 +- pubspec.yaml | 4 +- web/index.html | 4 + 15 files changed, 2574 insertions(+), 1038 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7b1ad2d..50aab685 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -337,7 +337,7 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) - `geolocator`: GPS/Location -- `flutter_map`: Map rendering +- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) - `hive`: Local storage - `provider`: State management - `http`: API requests diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 34689049..f0529bac 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "net.meshmapper.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 // MapLibre GL requires 23+ targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index a11d769a..aa77ae3c 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -55,6 +55,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e); } + try { + flutterEngine.getPlugins().add(new org.maplibre.maplibregl.MapLibreMapsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin maplibre_gl, org.maplibre.maplibregl.MapLibreMapsPlugin", e); + } try { flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); } catch (Exception e) { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a4c7131..ebb426d3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,144 +1,33 @@ PODS: - - audio_session (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter - - geolocator_apple (1.2.0): - - Flutter - - FlutterMacOS - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - package_info_plus (0.4.5): - - Flutter - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftyGif (5.4.5) - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - SDWebImage - - SwiftyGif EXTERNAL SOURCES: - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" - geolocator_apple: - :path: ".symlinks/plugins/geolocator_apple/darwin" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 60334cff..d045a55f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ 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 */ @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -120,6 +123,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -187,6 +191,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -752,6 +762,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..a28342ff --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,68 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "1051e9dfa3546bece1a6eaf33a5ac85ac35d6bda", + "version" : "6.19.1" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d42..c3fedb29 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + { appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.opacity), + title: const Text('Coverage Overlay Opacity'), + subtitle: Slider( + value: prefs.coverageOverlayOpacity.clamp(0.3, 1.0), + min: 0.3, + max: 1.0, + divisions: 7, + label: '${(prefs.coverageOverlayOpacity * 100).round()}%', + onChanged: (value) => appState.setCoverageOverlayOpacity(value), + ), + trailing: Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), + ), ListTile( leading: const Icon(Icons.visibility), title: const Text('Color Vision'), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e8c21f92..4c0aa414 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,11 +1,11 @@ +import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; -import 'package:latlong2/latlong.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; @@ -18,24 +18,196 @@ import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; -/// Map style options +/// Satellite style as inline MapLibre style JSON (ArcGIS raster source). +/// The `glyphs` URL is required because our native symbol layers +/// (repeater cluster count, individual repeater hex IDs, distance labels) +/// use `textField`, and MapLibre iOS wedges its resource loader with +/// NSURLError -1002 if it tries to resolve glyphs against a style that +/// doesn't declare a glyphs URL. +const _satelliteStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; + +/// Blank style with dark background — used when mapTilesEnabled is false +/// (saves mobile data while still showing markers and overlays). +/// Includes a `glyphs` URL so native annotations using textField (repeater +/// hex IDs, distance labels) can render their text even when tiles are off. +const _blankStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; + +/// Default font stack used for all native text labels (textField property). +/// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). +const _defaultFontStack = ['Noto Sans Regular']; + +/// Image-name constants for the marker bitmaps registered via +/// `controller.addImage()` and referenced by `SymbolOptions.iconImage`. +/// +/// Repeater shapes have one bitmap per (status color × hop_byte shape) — 12 +/// total. Coverage markers have one bitmap per (ping type × success state) for +/// the user's currently-selected style — 8 total per style preference. GPS +/// marker has one bitmap per style — 6 total. +class _MapImages { + _MapImages._(); + + // Repeater shape bitmaps: status × hop_bytes + // Names: rep_active_1, rep_dead_2, rep_dup_3, etc. + static String repeater(String status, int hopBytes) => + 'rep_${status}_$hopBytes'; + + static const repeaterStatuses = ['active', 'dead', 'new', 'dup']; + static const repeaterHopBytes = [1, 2, 3]; + + // Coverage marker bitmaps: type × success state + // Names: cov_tx_ok, cov_disc_fail, etc. + static String coverage(String type, bool success) => + 'cov_${type}_${success ? "ok" : "fail"}'; + + static const coverageTypes = ['tx', 'rx', 'disc', 'trace']; + + // GPS marker bitmaps: one per style + // Names: gps_arrow, gps_car, etc. The list of styles lives in + // _registerMapImages where we map each style key to its CustomPainter. + static String gps(String style) => 'gps_$style'; +} + +/// Renders a [CustomPainter] into a PNG byte buffer using `dart:ui`. +/// +/// This is the bridge between our existing Flutter `CustomPainter` marker +/// rendering code and MapLibre's native annotation system. The bytes returned +/// here can be passed to `controller.addImage(name, bytes)` and then referenced +/// by `SymbolOptions.iconImage: name`. The native engine renders the symbol +/// in the same pass as the map tiles, eliminating the Flutter platform-view +/// sync lag that affects widget overlays. +/// +/// [size] is the logical size in pixels — the output bitmap is upscaled by +/// [devicePixelRatio] for crispness on high-DPI screens. Default 3.0 covers +/// Renders a distance-label pill: white text on a semi-transparent rounded +/// rectangle background. Returns the PNG bytes and the logical size (width/ +/// height in logical pixels, NOT device pixels) so the caller can use it for +/// screen-space collision tests. +/// +/// Sized dynamically to the text — the pill grows with longer labels. Uses +/// devicePixelRatio=3.0 to match the other bitmap markers on this map. +Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( + String text, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 11.0; + const horizontalPad = 6.0; + const verticalPad = 3.0; + const cornerRadius = 6.0; + + // Measure the text first so we can size the pill to fit. + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final logicalWidth = textPainter.width + horizontalPad * 2; + final logicalHeight = textPainter.height + verticalPad * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + // Background pill. + final bgPaint = Paint()..color = Colors.black.withValues(alpha: 0.72); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, logicalWidth, logicalHeight), + const Radius.circular(cornerRadius), + ), + bgPaint, + ); + + // Subtle light border for separation from dark map backgrounds. + final borderPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0.5, 0.5, logicalWidth - 1, logicalHeight - 1), + const Radius.circular(cornerRadius), + ), + borderPaint, + ); + + textPainter.paint(canvas, const Offset(horizontalPad, verticalPad)); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode distance label to PNG bytes'); + } + return ( + bytes: byteData.buffer.asUint8List(), + size: Size(logicalWidth, logicalHeight), + ); +} + +/// most modern phones (typical DPR is 2.0–3.5). +Future _renderPainterToPng( + CustomPainter painter, + Size size, { + double devicePixelRatio = 3.0, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Scale the canvas so the painter still draws at logical size, but the + // resulting bitmap has more actual pixels. + canvas.scale(devicePixelRatio); + painter.paint(canvas, size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + (size.width * devicePixelRatio).round(), + (size.height * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode CustomPainter to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} + +/// Map style options. +/// +/// Declaration order matters: it determines the cycle order when the user +/// taps the "switch style" button (see `_cycleMapStyle`). Liberty is first +/// because it's the default for new users. enum MapStyle { + liberty, dark, light, satellite, } extension MapStyleExtension on MapStyle { - /// Convert from stored string preference to MapStyle enum + /// Convert from stored string preference to MapStyle enum. + /// Defaults to Liberty for unknown / unset preferences. static MapStyle fromString(String value) { switch (value) { + case 'dark': + return MapStyle.dark; case 'light': return MapStyle.light; case 'satellite': return MapStyle.satellite; - case 'dark': + case 'liberty': default: - return MapStyle.dark; + return MapStyle.liberty; } } @@ -45,6 +217,8 @@ extension MapStyleExtension on MapStyle { return 'Dark'; case MapStyle.light: return 'Light'; + case MapStyle.liberty: + return 'Liberty'; case MapStyle.satellite: return 'Satellite'; } @@ -56,57 +230,28 @@ extension MapStyleExtension on MapStyle { return Icons.dark_mode; case MapStyle.light: return Icons.light_mode; + case MapStyle.liberty: + return Icons.map; case MapStyle.satellite: return Icons.satellite_alt; } } - String get urlTemplate { + /// MapLibre style URL (or inline JSON for satellite) + String get styleUrl { switch (this) { case MapStyle.dark: - return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + return 'https://tiles.openfreemap.org/styles/dark'; case MapStyle.light: - return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + return 'https://tiles.openfreemap.org/styles/bright'; + case MapStyle.liberty: + return 'https://tiles.openfreemap.org/styles/liberty'; case MapStyle.satellite: - return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; - } - } - - List? get subdomains { - switch (this) { - case MapStyle.dark: - return ['a', 'b', 'c', 'd']; - case MapStyle.light: - return null; // OSM doesn't use subdomains anymore - case MapStyle.satellite: - return null; // ArcGIS doesn't use subdomains - } - } - - /// Whether this style supports retina tiles via {r} placeholder - bool get supportsRetina { - switch (this) { - case MapStyle.dark: - return true; // Carto supports @2x via {r} - case MapStyle.light: - return false; // OSM has no retina support - case MapStyle.satellite: - return false; // ArcGIS has no retina support + return _satelliteStyleJson; } } } -/// Custom tile provider that silently handles HTTP errors (404, 503, etc.) -/// instead of flooding the console with exceptions -final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileProvider { - SilentCancellableNetworkTileProvider() : super( - dioClient: Dio( - BaseOptions( - validateStatus: (status) => true, // Accept all status codes - ), - ), - ); -} /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID @@ -120,7 +265,7 @@ class _ResolvedRepeater { } /// Map widget with TX/RX markers -/// Uses flutter_map with OpenStreetMap tiles +/// Uses MapLibre GL with OpenFreeMap vector tiles class MapWidget extends StatefulWidget { /// Bottom padding in pixels to account for overlays (e.g., control panel in portrait) /// The map will offset its center point upward by half this value @@ -149,8 +294,8 @@ class MapWidget extends StatefulWidget { State createState() => _MapWidgetState(); } -class _MapWidgetState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); +class _MapWidgetState extends State { + MapLibreMapController? _mapController; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -164,6 +309,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { bool _alwaysNorth = true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation + // Desired camera zoom while auto-follow is active. Set when the user taps + // "center on position" and updated when the user pinch-zooms. Each auto- + // follow GPS tick uses this as the animation target zoom — otherwise a tick + // that arrives during the initial zoom animation cancels it (animateCamera + // replaces in-flight animations), leaving the camera stuck at an + // intermediate zoom and the marker off-center. + double? _autoFollowDesiredZoom; + + // Bearing derivation state. geolocator's Position.heading is only reliable + // at speed — on both Android (Location.getBearing() requires hasBearing() + // and speed > 0) and iOS (CLLocation.course == -1 when invalid) it's + // effectively 0 or -1 when stationary or walking slowly. We keep our own + // anchor-to-current bearing as a fallback so the arrow/walk marker and + // heading-mode map rotation behave correctly at low speeds. + LatLng? _bearingAnchor; // last fix used as the bearing origin + double? _computedHeading; // last known-good bearing in degrees 0..360 + // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; @@ -185,17 +347,79 @@ class _MapWidgetState extends State with TickerProviderStateMixin { bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active - // Smooth animation for map movement - AnimationController? _animationController; - Animation? _animation; - LatLng? _animationStartPosition; - LatLng? _animationEndPosition; - - // Smooth animation for map rotation - AnimationController? _rotationAnimationController; - Animation? _rotationAnimation; - double? _rotationStartAngle; - double? _rotationEndAngle; + // MapLibre style and overlay tracking + int _lastCacheBust = 0; + // Tracks the zone code we last rendered the coverage overlay for. When the + // zone check succeeds after the style has already loaded (e.g. first check + // failed with gps_inaccurate and a later retry succeeded), _addCoverageOverlay + // would otherwise never re-run and the raster layer would stay missing. + String? _lastOverlayZoneCode; + // Last coverage overlay opacity we pushed into MapLibre. Compared against + // the current preference in _buildMap to detect slider changes and apply + // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). + double? _lastAppliedCoverageOpacity; + bool _styleLoaded = false; + bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) + + // Tracks the last marker data version we synced to native annotations. + // The build() method computes a version hash from app state and only triggers + // _syncAllAnnotations when the hash changes (avoiding unnecessary diff work). + int _lastMarkerDataVersion = -1; + + // Tile load failure detection — shows a banner if map tiles haven't loaded + // within a timeout after style load. Cleared when onMapIdle fires. + bool _tileLoadFailed = false; + Timer? _tileLoadTimeoutTimer; + static const _tileLoadTimeoutSeconds = 8; + + // Re-entrance guard for _onStyleLoaded. The iOS plugin can fire + // onStyleLoadedCallback multiple times during a single style switch, + // which causes the sync logic to race against itself. This flag bails + // any nested call. + bool _styleLoadInProgress = false; + + // True only after _setupRepeaterClusterLayers has finished creating the + // cluster GeoJSON source AND all 3 layers. Set to false at the start of + // each style load. Used as an additional guard for build()-triggered post- + // frame syncs so they don't race ahead of source creation and try to call + // setGeoJsonSource on a source that doesn't exist yet (which produces the + // "Failed to update repeater source: sourceNotFound" error at startup). + bool _clusterLayersReady = false; + + // Native annotation tracking — populated by sync methods. + // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff + // (add new, update existing, remove deleted) on each data version change. + // NOTE: repeaters do NOT use the annotation manager — they live in a custom + // cluster-enabled GeoJSON source so MapLibre can group nearby markers into + // count bubbles at low zoom. See _setupRepeaterClusterLayers(). + final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + final Map _distanceLabelSymbols = {}; // key: focused repeater id + // Per focused-repeater metadata used by the collision-avoidance reflow: + // the image size (for hit-box overlap tests) and the repeater lat/lon (so + // we can slide the label along the ping→repeater line at a new parameter t). + final Map _distanceLabelImageSize = {}; + final Map _distanceLabelRepeaterPos = {}; + // Tracks distance-label image names we've registered via addImage, so the + // style-reload path can drop stale names from the map's image cache if ever + // needed. Right now we just re-addImage on each sync (idempotent). + final Set _registeredDistanceLabelImages = {}; + Symbol? _gpsSymbol; // single GPS marker + + // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) + static const _repeaterSourceId = 'repeaters-source'; + static const _repeaterIndividualLayerId = 'repeaters-individual'; + static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; + static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + + // Tracks which marker style preference the coverage images are currently + // registered for. When the user changes their preference, we re-register. + String? _registeredCoverageStyle; + + // True after _registerMapImages() finishes — gates symbol creation. + bool _imagesRegistered = false; + + // Last bearing seen by camera listener (for non-rotating GPS counter-rotation) + double _lastBearing = 0; // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); @@ -203,11 +427,29 @@ class _MapWidgetState extends State with TickerProviderStateMixin { @override void dispose() { - _animationController?.dispose(); - _rotationAnimationController?.dispose(); + _tileLoadTimeoutTimer?.cancel(); + _mapController?.removeListener(_onCameraChanged); super.dispose(); } + /// Camera change listener — fires every frame during pan/zoom (because + /// trackCameraPosition: true is set on MapLibreMap). With native annotations, + /// the markers themselves don't need a per-frame rebuild — they're rendered + /// by the native map engine and stay in sync automatically. The only thing + /// we still need to do here is update the GPS marker's iconRotate when the + /// camera bearing changes, because for rotating styles (arrow/walk/pacman) + /// iconRotate = heading - bearing and the bearing animates continuously in + /// heading mode. Throttled by a small bearing delta to avoid spamming + /// updateSymbol. + void _onCameraChanged() { + if (!mounted || _mapController == null) return; + final pos = _mapController!.cameraPosition; + if (pos == null) return; + if ((pos.bearing - _lastBearing).abs() < 0.5) return; + _lastBearing = pos.bearing; + _updateGpsSymbolRotation(); + } + @override void didUpdateWidget(MapWidget oldWidget) { super.didUpdateWidget(oldWidget); @@ -219,231 +461,211 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _lastGpsPosition != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow && _lastGpsPosition != null) { + final double targetBearing = (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; final adjustedPosition = _offsetPositionForPadding( _lastGpsPosition!, widget.bottomPaddingPixels, widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, ); - _animateToPosition(adjustedPosition); } }); } } - /// Smoothly animate the map to a new position - void _animateToPosition(LatLng target) { - if (!_isMapReady || !mounted) return; - - // Get current position - final currentCenter = _mapController.camera.center; - - // Skip if already at target (within small threshold) - final distance = const Distance().as(LengthUnit.Meter, currentCenter, target); - if (distance < 1) return; // Less than 1 meter, don't animate - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - // Duration based on distance - shorter for small movements, longer for big jumps - final duration = Duration(milliseconds: distance < 100 ? 200 : 300); - - _animationController = AnimationController( - duration: duration, - vsync: this, - ); - - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeOutCubic, // Smooth deceleration - ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); - - _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); - }); - - _animationController!.forward(); - } - /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { - if (!_isMapReady || !mounted) return; - - // Get current position and zoom - final currentCenter = _mapController.camera.center; - final currentZoom = _mapController.camera.zoom; - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - const duration = Duration(milliseconds: 500); // Smooth zoom + pan - - _animationController = AnimationController( - duration: duration, - vsync: this, + if (_mapController == null || !_isMapReady || !mounted) return; + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(target, targetZoom), + duration: const Duration(milliseconds: 500), ); + } - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + /// Atomic auto-follow camera update: animates target, zoom, and bearing + /// together in a single animateCamera call. + /// + /// Using separate animateCamera calls for position and rotation races — + /// the second call cancels the first, so each GPS tick in heading mode + /// lost either the pan or the rotation. Bundling everything into one + /// newCameraPosition update avoids the race entirely and also keeps the + /// initial zoom animation from being cancelled by the first auto-follow + /// tick. + void _animateAutoFollowCamera({ + required LatLng target, + required double zoom, + required double bearing, + int durationMs = 300, + }) { + if (_mapController == null || !_isMapReady || !mounted) return; + _mapController!.animateCamera( + CameraUpdate.newCameraPosition(CameraPosition( + target: target, + zoom: zoom, + bearing: bearing, + )), + duration: Duration(milliseconds: durationMs), ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); - - // Interpolate zoom - final zoom = currentZoom + ((targetZoom - currentZoom) * t); - - _mapController.move(LatLng(lat, lng), zoom); - }); - - _animationController!.forward(); } /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { - if (!_isMapReady || !mounted) return; + if (_mapController == null || !_isMapReady || !mounted) return; final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; if (points.length < 2) return; - final fitted = CameraFit.coordinates( - coordinates: points, - padding: EdgeInsets.fromLTRB(60, 60, 60, MediaQuery.of(context).size.height * 0.4), - maxZoom: 15, - ).fit(_mapController.camera); + // Build bounding box from all points + double minLat = points[0].latitude, maxLat = points[0].latitude; + double minLon = points[0].longitude, maxLon = points[0].longitude; + for (final p in points) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLon) minLon = p.longitude; + if (p.longitude > maxLon) maxLon = p.longitude; + } + final bounds = LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ); - _animateToPositionWithZoom(fitted.center, fitted.zoom); + final bottomPad = MediaQuery.of(context).size.height * 0.4; + _mapController!.animateCamera( + CameraUpdate.newLatLngBounds(bounds, left: 60, top: 60, right: 60, bottom: bottomPad), + duration: const Duration(milliseconds: 500), + ); } /// Smoothly animate the map rotation to match heading + /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (!_isMapReady || !mounted || _alwaysNorth) return; + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) return; - // Get current rotation (in degrees) - final currentRotation = _mapController.camera.rotation; - - // Normalize target heading to -180 to 180 range for smooth rotation - // Map heading is counter-clockwise from north, GPS heading is clockwise - // So we need to negate it: -targetHeading - double targetRotation = -targetHeading; - - // Normalize angles to -180 to 180 range - while (targetRotation > 180) { - targetRotation -= 360; - } - while (targetRotation < -180) { - targetRotation += 360; - } + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; // Calculate shortest rotation path - double delta = targetRotation - currentRotation; - while (delta > 180) { - delta -= 360; - } - while (delta < -180) { - delta += 360; - } + double delta = targetHeading - currentBearing; + while (delta > 180) { delta -= 360; } + while (delta < -180) { delta += 360; } // Skip if rotation change is very small (less than 2 degrees) if (delta.abs() < 2) return; - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create new rotation animation controller - // Faster rotation for small changes, slower for large changes - final duration = Duration(milliseconds: delta.abs() < 45 ? 300 : 500); - - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, + _mapController!.animateCamera( + CameraUpdate.bearingTo(targetHeading), + duration: Duration(milliseconds: delta.abs() < 45 ? 300 : 500), ); + } - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration - ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = currentRotation + delta; - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - // Interpolate between start and end angles - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); + /// Produce a reliable heading in degrees (0..360) from successive GPS fixes. + /// + /// Prefers `Position.heading` when the device is moving fast enough for the + /// hardware bearing to be trustworthy; otherwise derives the bearing from + /// the delta between the last anchor fix and the current one. Returns the + /// last known-good value (possibly null) when we don't have enough motion + /// yet. This exists because geolocator reports heading=0 (Android) or + /// -1 (iOS) at rest and during slow/stop-and-go movement, which would + /// otherwise leave the arrow/walk marker stuck pointing north. + double? _computeHeading(Position p) { + final here = LatLng(p.latitude, p.longitude); + + // Fast path: trust the GPS chip when it's actually moving. + // geolocator reports speed in m/s. 1 m/s ≈ 3.6 km/h — slower than that, + // the hardware bearing is either stale or not computed. + final gpsHeading = p.heading; + if (p.speed >= 1.0 && gpsHeading >= 0 && gpsHeading <= 360) { + _bearingAnchor = here; + _computedHeading = gpsHeading; + return _computedHeading; + } - _mapController.rotate(rotation); - }); + // Slow/stationary path: compute our own bearing once we have enough travel. + if (_bearingAnchor == null) { + _bearingAnchor = here; + } else { + final moved = Geolocator.distanceBetween( + _bearingAnchor!.latitude, _bearingAnchor!.longitude, + here.latitude, here.longitude, + ); + if (moved >= 5.0) { + final bearing = Geolocator.bearingBetween( + _bearingAnchor!.latitude, _bearingAnchor!.longitude, + here.latitude, here.longitude, + ); + // bearingBetween returns -180..180; normalize to 0..360. + _computedHeading = (bearing + 360) % 360; + _bearingAnchor = here; + } + } - _rotationAnimationController!.forward(); + return _computedHeading; // may be null until first meaningful motion } - /// Offset a lat/lon position by screen pixels (to account for UI overlays) - /// Shifts the map center to keep the GPS marker centered in the visible map area - /// - bottomPadding: shifts center down (portrait mode with bottom panel) - /// - rightPadding: shifts center left (landscape mode with side panel) - LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, [double rightPadding = 0, double? atZoom]) { - if (!_isMapReady) return position; + /// Offset a lat/lon position by screen pixels (to account for UI overlays). + /// Shifts the camera target so the GPS marker sits in the visible (unpadded) + /// part of the map: + /// - bottomPadding > 0: camera shifts "screen-down" so marker appears toward + /// the top half (portrait with bottom panel open). + /// - rightPadding > 0: camera shifts "screen-right" so marker appears toward + /// the left half (landscape with side panel open on the right). + /// + /// [atZoom] and [atBearing] override the current camera values. Callers that + /// are *about* to animate the camera to a new zoom/bearing must pass the + /// target values — otherwise the offset gets computed at an interpolated + /// mid-animation value and the marker settles off-center. + LatLng _offsetPositionForPadding( + LatLng position, + double bottomPadding, [ + double rightPadding = 0, + double? atZoom, + double? atBearing, + ]) { + if (_mapController == null || !_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; - // Get meters per pixel at current zoom (or at a specific zoom if provided) + // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) - final zoom = atZoom ?? _mapController.camera.zoom; + final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); + // Start with the offset expressed as if the map were north-up + // (bearing = 0): bottom padding shifts the target geographic-south, + // right padding shifts the target geographic-west. double latOffset = 0; double lonOffset = 0; - - // Bottom padding: shift center south (map moves up, marker appears centered) if (bottomPadding > 0) { final meterOffset = (bottomPadding / 2) * metersPerPixel; latOffset = -(meterOffset / 111000); // ~111km per degree latitude } - - // Right padding: shift center west (map moves right, marker appears centered) if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; - // Longitude degrees per meter varies with latitude lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); } - // When the map is rotated (heading mode), geographic "south" no longer maps - // to "screen down". Rotate the offset vector by the camera rotation so the - // shift always points in the correct screen direction. - final rotationDeg = _mapController.camera.rotation; - if (rotationDeg.abs() > 0.1) { - final rotationRad = -rotationDeg * math.pi / 180; + // When the map is rotated, "screen-down" no longer points geographic + // south — it points wherever bearing + 180° aims. Rotate the offset + // vector so the shift still lands in the correct screen direction. + // + // MapLibre bearing is clockwise from north (heading east => bearing 90, + // screen-down => world-west). To send a south-pointing input vector to + // the world direction that corresponds to screen-down at the given + // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not + // -bearing as the previous implementation did. + final bearingDeg = atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; + if (bearingDeg.abs() > 0.1) { + final rotationRad = bearingDeg * math.pi / 180; final cosR = math.cos(rotationRad); final sinR = math.sin(rotationRad); final rotatedLat = latOffset * cosR - lonOffset * sinR; @@ -501,6 +723,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } if (appState.currentPosition != null) { + // Recompute our derived heading for this frame. _computedHeading is + // updated as a side effect; use it below instead of reading + // currentPosition.heading directly (which is unreliable at low speeds). + _computeHeading(appState.currentPosition!); + // One-time initial zoom to GPS when we first get a position // This happens even with auto-follow disabled so user sees their location // Don't apply panel offset - center directly on GPS so pin is in middle of screen @@ -524,28 +751,54 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }); } - // Auto-follow GPS position when enabled - use smooth animation + // Auto-follow GPS position when enabled. When auto-follow is on we + // bundle pan, zoom, and bearing into a single animateCamera call so + // the three don't race each other. _autoFollowDesiredZoom is the + // zoom the camera is animating toward — using it instead of the + // (potentially interpolated) current zoom prevents drift during the + // initial zoom animation after tapping center-on-position. if (_autoFollow && _isMapReady) { final newPosition = center; - // Only animate if position has actually changed if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || _lastGpsPosition!.longitude != newPosition.longitude) { _lastGpsPosition = newPosition; - // Use post frame callback to avoid build-during-build issues + final double targetBearing = (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; + // Track _lastHeading here too so the separate rotation block + // below (which runs when auto-follow is off) doesn't fire a + // redundant rotation animation on the next frame. + if (!_alwaysNorth && _computedHeading != null) { + _lastHeading = _computedHeading; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(newPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPosition(adjustedPosition); // Smooth animation instead of jump + final adjustedPosition = _offsetPositionForPadding( + newPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + ); } }); } } - // Handle map rotation based on heading (when not in Always North mode) - if (!_alwaysNorth && _isMapReady) { - final heading = appState.currentPosition!.heading; + // Handle map rotation based on heading when NOT auto-following. + // When auto-follow is on, rotation is bundled into the combined + // camera update above so we don't race two animateCamera calls. + if (!_autoFollow && !_alwaysNorth && _isMapReady && _computedHeading != null) { + final heading = _computedHeading!; if (_lastHeading == null) { // First heading after startup — store without rotating so the // initial zoom animation can settle at rotation 0 (where the @@ -555,14 +808,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; - // Use post frame callback to avoid build-during-build issues WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_alwaysNorth) { + if (mounted && !_alwaysNorth && !_autoFollow) { _animateToRotation(heading); } }); } } + } else { + // GPS lock lost — clear bearing state so reacquisition starts fresh + // instead of snapping the marker/map to a stale direction. + _bearingAnchor = null; + _computedHeading = null; } // Handle navigation trigger from log screen or graph @@ -573,20 +830,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (target != null) { // Reset map controls to default state _autoFollow = false; // Disable center on GPS + _autoFollowDesiredZoom = null; _alwaysNorth = true; // Set to north-up mode _rotationLocked = false; // Unlock rotation _lastHeading = null; // Reset heading tracking + _bearingAnchor = null; // Reset derived-heading anchor + _computedHeading = null; // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && _mapController != null) { final targetPosition = LatLng(target.lat, target.lon); // Rotate map back to north (0 degrees) first - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - _mapController.rotate(0); + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera(CameraUpdate.bearingTo(0)); } // Animate to the exact target position (no offset) @@ -596,6 +856,27 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } } + // Sync native annotations whenever marker data changes (provider triggers + // a rebuild). The version hash detects changes to ping/repeater counts, + // GPS position, focus state, prefs, etc. Native annotations stay in sync + // with the camera automatically — we only need to push data updates. + // + // _clusterLayersReady is the critical guard here: it ensures the cluster + // GeoJSON source actually exists before any sync attempts to push data + // into it. Without this, a Provider data update arriving in the brief + // window between _registerMapImages and _setupRepeaterClusterLayers + // (inside _onStyleLoaded) would race ahead and call setGeoJsonSource on + // a not-yet-created source, throwing "sourceNotFound". + if (_isMapReady && _styleLoaded && _imagesRegistered && _clusterLayersReady) { + final dataVersion = _computeMarkerDataVersion(appState); + if (dataVersion != _lastMarkerDataVersion) { + _lastMarkerDataVersion = dataVersion; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _syncAllAnnotations(appState); + }); + } + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; @@ -604,8 +885,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ - // Map - _buildMap(appState, center), + // Map — wait for Hive-loaded preferences before constructing + // MapLibreMap, otherwise the default mapStyle ('liberty') would + // render first and then swap to the user's saved style. + if (appState.preferencesLoaded) + _buildMap(appState, center) + else + const ColoredBox( + color: Color(0xFF1A1A1A), + child: SizedBox.expand(), + ), // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( @@ -623,206 +912,1586 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), - // Map controls - top-right in both orientations, collapsible - Positioned( - top: topPadding, - right: 8, - child: _buildCollapsibleMapControls(appState), - ), - ], - ); + // Map controls - top-right in both orientations, collapsible + Positioned( + top: topPadding, + right: 8, + child: _buildCollapsibleMapControls(appState), + ), + + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + ], + ); + } + + /// Banner shown when map tiles fail to load within the timeout window. + Widget _buildTileLoadFailedBanner() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 80), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.shade900.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade700, width: 1), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, color: Colors.white, size: 16), + SizedBox(width: 8), + Flexible( + child: Text( + 'Map tiles unavailable — check connection', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + /// Collapsible map controls (toggle at top, expands downward) + Widget _buildCollapsibleMapControls(AppStateProvider appState) { + // Use external state if provided, otherwise use internal state + final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; + final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Toggle button (always visible) - at top + GestureDetector( + onTap: onToggle, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: isExpanded + ? const BorderRadius.vertical(top: Radius.circular(8)) + : BorderRadius.circular(8), + ), + child: Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: Colors.white, + size: 22, + ), + ), + ), + // Map controls (only when expanded) - below the toggle button + if (isExpanded) + _buildMapControls(appState), + ], + ); + } + + Widget _buildMap(AppStateProvider appState, LatLng center) { + final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + // When mapTilesEnabled is false, use a blank style (just background) to save mobile data + final newStyleUrl = appState.preferences.mapTilesEnabled + ? mapStyle.styleUrl + : _blankStyleJson; + + // Style changes flow through MapLibreMap.styleString — the plugin's + // didUpdateWidget detects the new value and fires a native setStyle. + // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds + // cluster layers, re-adds the coverage overlay, and re-syncs annotations. + + // Detect cache bust change and refresh overlay + if (appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded) { + _lastCacheBust = appState.overlayCacheBust; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshCoverageOverlay(appState); + }); + } + + // Detect zoneCode transition (null → value, or zone change) and add/refresh + // the overlay. Needed because _addCoverageOverlay only runs during + // _onStyleLoaded — if the first zone check failed with gps_inaccurate, the + // style loads with zoneCode=null and the overlay is skipped. When a later + // retry sets the zone, nothing would otherwise trigger the raster layer. + if (appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded) { + _lastOverlayZoneCode = appState.zoneCode; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshCoverageOverlay(appState); + }); + } + + // Detect coverage overlay opacity change (user dragged the slider in + // Settings → General) and push it to the live raster layer without + // rebuilding the whole overlay. Skipped while ping focus mode is active — + // focus forces opacity to 0 and _dismissPingFocus restores the preference + // value directly. + final wantedOpacity = appState.preferences.coverageOverlayOpacity; + if (_isMapReady && + _styleLoaded && + _focusedPingLocation == null && + _lastAppliedCoverageOpacity != null && + _lastAppliedCoverageOpacity != wantedOpacity) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _applyCoverageOverlayOpacity(wantedOpacity); + }); + } + + return Stack( + children: [ + // MapLibre GL map (base tiles via style; coverage overlay added programmatically) + MapLibreMap( + styleString: newStyleUrl, + initialCameraPosition: CameraPosition( + target: center, + zoom: _defaultZoom, + ), + minMaxZoomPreference: const MinMaxZoomPreference(3, 17), + rotateGesturesEnabled: !_rotationLocked, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + tiltGesturesEnabled: false, // 2D wardriving map + compassEnabled: false, // We have our own controls + // CRITICAL: must be true so the controller's `cameraPosition` getter + // stays synced with the platform side. Without this, the Dart-side + // _cameraPosition is set once at construction and never updated, which + // breaks our sync projection (markers project to stale positions and + // get filtered out by viewport bounds). Also enables camera-move events + // during gestures so _onCameraChanged fires every frame for live + // marker overlay updates. + trackCameraPosition: true, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: () => _onStyleLoaded(appState), + onMapIdle: _onMapIdle, + onCameraIdle: _onCameraIdle, + // NOTE: we do NOT pass onMapClick here. The iOS plugin's + // handleMapTap fires `feature#onTap` when a tap hits any + // interactive layer (including our cluster source layers) and + // does NOT fire `map#onMapClick` in that case. We register a + // listener on `controller.onFeatureTapped` in _onMapCreated + // instead — that fires for taps on custom layer features. + ), + // No widget marker overlay — markers are now native MapLibre + // annotations rendered by the platform view itself. + ], + ); + } + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + // Wire up native annotation tap callbacks. These streams fire when the + // user taps on a symbol/line that the platform-side hit-test matches. + // Since the controller is created exactly once, this listener registration + // happens exactly once too — no need to remove and re-add on style switch. + controller.onSymbolTapped.add(_handleSymbolTap); + // Generic feature tap handler — fires for ANY interactive style layer, + // including our custom repeater cluster + individual layers (which are + // NOT managed by the annotation manager). We dispatch in _handleFeatureTap + // based on the layerId. + controller.onFeatureTapped.add(_handleFeatureTap); + } + + /// Routes a native symbol tap to the appropriate detail sheet. + /// The tap event carries the [Symbol] object, which has the metadata Map we + /// attached when calling addSymbol() in the various sync methods. We use the + /// `kind` and `id` keys to look up the original ping/repeater object from + /// app state and call the existing `_show*Details()` method (which expects + /// the full object, not just an ID). + void _handleSymbolTap(Symbol symbol) { + final data = symbol.data; + if (data == null) return; + final kind = data['kind'] as String?; + final id = data['id']; + final appState = context.read(); + + switch (kind) { + // 'repeater' is no longer handled here — repeaters are in a custom + // cluster GeoJSON layer (not the annotation manager) and dispatch + // through _handleMapClick + queryRenderedFeatures instead. + case 'tx': + final ping = appState.txPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showTxPingDetails(ping); + break; + case 'rx': + final ping = appState.rxPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showRxPingDetails(ping); + break; + case 'disc': + final entry = appState.discLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showDiscPingDetails(entry); + break; + case 'trace': + final entry = appState.traceLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showTraceDetails(entry); + break; + // gps, distance-label: not tappable in original — no action + } + } + + /// Handles taps on custom layer features (repeater cluster bubbles and + /// individual repeaters). Wired in [_onMapCreated] via + /// `controller.onFeatureTapped.add(_handleFeatureTap)`. + /// + /// The iOS/Android tap dispatcher calls this for ANY tap that hits an + /// interactive style layer, BEFORE falling back to `onMapClick`. Since our + /// cluster source layers are interactive, taps on repeaters/clusters always + /// route here (not through onMapClick). + /// + /// We dispatch by [layerId]: + /// - cluster bubble layer → zoom in 2 levels around the tap point + /// - individual repeater layer → look up the Repeater by id and open the + /// existing detail sheet + /// + /// [id] is the GeoJSON Feature `id` (which we set to `repeater.id` for + /// individual repeaters; MapLibre auto-generates one for cluster features). + /// [annotation] is always null here since these layers aren't managed by + /// the annotation manager. + void _handleFeatureTap( + math.Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, + ) { + if (!mounted) return; + + // Cluster tap: just zoom in. We accept hits on EITHER the bubble circle + // layer OR the count-text symbol layer that sits on top of it. The + // platform-side hit-test iterates layers top-down and returns the first + // feature it finds; for cluster taps, the centered count text usually + // gets hit before the underlying bubble, so we have to recognise both + // layer IDs as "user tapped a cluster". Either way the action is the + // same: animate-zoom in 2 levels around the tap point. + // + // The explicit 200ms duration is important for perceived responsiveness. + // Without it, iOS uses setCamera(animated: true) which has a slow ease-in + // start (~150ms before any noticeable motion). Passing a duration switches + // the native code path to fly(to:withDuration:) which ramps in faster and + // finishes in 200ms, making the tap feel "instant" rather than delayed. + if (layerId == _repeaterClusterBubbleLayerId || + layerId == _repeaterClusterCountLayerId) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + return; + } + + // Individual repeater: look up by id (which is repeater.id) and open the + // existing detail sheet. We recompute isDuplicate and hopOverride from + // app state rather than carrying them in feature properties — the values + // are cheap to derive and always reflect the latest data. + if (layerId == _repeaterIndividualLayerId) { + _showRepeaterDetailsById(id); + return; + } + + // GPS marker tap: the GPS marker is a non-interactive symbol on the + // annotation manager layer (which sits ON TOP of all custom layers in + // paint order). Without intervention, taps on the GPS marker hit the + // annotation layer first and stop the iOS dispatcher from checking the + // cluster layers underneath. Detect that case here and re-query the + // cluster layers at the same screen point so the user can still tap + // a cluster/repeater that the GPS marker happens to be sitting on top of. + if (annotation is Symbol) { + final kind = annotation.data?['kind'] as String?; + if (kind == 'gps') { + _fallThroughToRepeaterAt(point, coordinates); + return; + } + } + } + + /// When a tap hits the GPS marker (which has no detail sheet), try to find + /// any repeater cluster or individual repeater under the same point and + /// dispatch THAT instead. We use [queryRenderedFeatures] explicitly scoped + /// to the cluster source's layers, since the iOS native tap dispatcher + /// already short-circuited at the GPS marker layer above. + Future _fallThroughToRepeaterAt( + math.Point point, + LatLng coordinates, + ) async { + if (_mapController == null) return; + try { + final features = await _mapController!.queryRenderedFeatures( + point, + const [ + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ], + null, + ); + if (features.isEmpty || !mounted) return; + + // The Dart-side wrapper jsonDecodes each feature into a Map for us + // (see method_channel_maplibre_gl.dart::queryRenderedFeatures). So we + // can read properties directly without parsing JSON. + final feature = features.first as Map; + final properties = (feature['properties'] as Map?) ?? {}; + + // Cluster (auto-tagged by MapLibre when cluster: true is set on source). + // Same explicit 200ms duration as the direct cluster path in + // _handleFeatureTap so both tap routes feel identical. + if (properties['cluster'] == true) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + return; + } + + // Individual repeater. The feature `id` field is the repeater.id we set + // in _buildRepeaterFeatureCollection (or fall back to the property). + final repeaterId = + (feature['id'] ?? properties['repeaterId'])?.toString(); + if (repeaterId != null) { + _showRepeaterDetailsById(repeaterId); + } + } catch (e) { + debugError('[MAP] queryRenderedFeatures fall-through failed: $e'); + } + } + + /// Open the repeater detail sheet for a given [repeaterId]. Looks up the + /// Repeater object from app state and recomputes the duplicate/hopOverride + /// flags. Used by both direct tap dispatch and the GPS fall-through path. + void _showRepeaterDetailsById(String repeaterId) { + if (!mounted) return; + final appState = context.read(); + final repeater = + appState.repeaters.where((r) => r.id == repeaterId).firstOrNull; + if (repeater == null) return; + + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final isDuplicate = duplicates.contains(repeater.id); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + _showRepeaterDetails( + repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: hopOverride, + ); + } + + Future _onStyleLoaded(AppStateProvider appState) async { + // Re-entrance guard. iOS plugin sometimes fires onStyleLoadedCallback + // multiple times during a single setStyle. The race causes "Layer not + // found" errors during the symbol manager's _rebuildLayers and + // double-registers images. Bail any nested call so the first invocation + // runs to completion uninterrupted. + if (_styleLoadInProgress) { + debugLog('[MAP] _onStyleLoaded re-entered while already running, skipping'); + return; + } + _styleLoadInProgress = true; + try { + _styleLoaded = true; + _isMapReady = true; + + // CRITICAL: clear stale Symbol references from any previous style load. + // Style reloads cause maplibre_gl to construct a brand-new SymbolManager + // with empty internal _idToAnnotation maps. Our _gpsSymbol / + // _coverageSymbols / _distanceLabelSymbols still reference the OLD + // Symbol objects whose IDs are not in the new manager — calling + // updateSymbol on them throws "you can only set existing annotations". + // Clearing them now means the next sync will call addSymbol (which + // creates fresh symbols in the new manager) instead of updateSymbol. + _gpsSymbol = null; + _coverageSymbols.clear(); + _distanceLabelSymbols.clear(); + // Mark cluster layers as not-ready until _setupRepeaterClusterLayers + // creates them on the new style. This gates build()-driven post-frame + // syncs from racing ahead of source creation. + _clusterLayersReady = false; + + // Disable symbol decluttering on the annotation manager. By default, + // MapLibre symbol layers hide overlapping icons/labels at lower zoom to + // reduce visual clutter — but for wardriving we want every coverage + // marker visible regardless of density. (Repeaters are now in their own + // cluster-enabled GeoJSON layer with its own per-layer overlap settings.) + await _configureSymbolDecluttering(); + + // Pre-render and register all marker bitmaps for native annotations. + // Style reloads (e.g., user switches dark→liberty) wipe registered images, + // so we always re-register on every style load. Awaited so the cluster + // layer (which references icon image names) sees them when it's created. + _imagesRegistered = false; + await _registerMapImages(appState); + + // Set up the repeater cluster source + 3 layers. Must run AFTER images + // are registered, since the individual symbol layer's iconImage expression + // looks up names registered by _registerMapImages. + await _setupRepeaterClusterLayers(); + + // Re-add coverage overlay AFTER cluster layers exist so _addCoverageOverlay + // can target the bottom repeater layer as its belowLayerId reference. This + // keeps the insertion point consistent with the zoneCode watcher path — + // both end up with raster at the bottom of the repeater stack, not above it. + await _refreshCoverageOverlay(appState); + _lastOverlayZoneCode = appState.zoneCode; + + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, + // we assume tiles are failing to load (network down, server error, etc.) + // and surface a banner. Cleared as soon as onMapIdle fires. + _tileLoadTimeoutTimer?.cancel(); + if (appState.preferences.mapTilesEnabled) { + _tileLoadFailed = false; + _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { + if (mounted && !_tileLoadFailed) { + debugWarn('[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); + setState(() => _tileLoadFailed = true); + } + }); + } else { + // Blank style — never show the warning + _tileLoadFailed = false; + } + + // First-load-only setup: center on GPS and register camera listener. + // On subsequent style switches, preserve the user's pan position. + if (!_hasStyleLoadedOnce) { + _hasStyleLoadedOnce = true; + + // Center on GPS if available (initial centering) + if (appState.currentPosition != null) { + final center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(center, _defaultZoom), + ); + } + + // Register camera listener ONCE so marker overlay positions update on pan/zoom + _mapController!.addListener(_onCameraChanged); + } + + // Force an initial annotation sync now that images are registered AND the + // cluster source/layers exist. This pushes the current app state into the + // newly-created native annotations on first style load (and again whenever + // the style is reloaded, since style reloads wipe everything). + if (mounted) { + await _syncAllAnnotations(appState); + // Update the data version to match what we just synced. Without this, + // the build()-driven post-frame sync would fire AGAIN with the same + // data because _lastMarkerDataVersion still holds the previous value + // — that double-sync was racing the first sync's symbol refs and + // throwing "you can only set existing annotations" errors twice. + _lastMarkerDataVersion = _computeMarkerDataVersion(appState); + if (mounted) setState(() {}); + } + } finally { + _styleLoadInProgress = false; + } + } + + /// Fires when the map finishes loading visible tiles and the camera is idle. + /// We use this as the "tiles loaded successfully" signal — clears the failure + /// timer and hides any tile-load warning banner. + void _onMapIdle() { + _tileLoadTimeoutTimer?.cancel(); + if (_tileLoadFailed && mounted) { + debugLog('[MAP] Tiles recovered after earlier load failure'); + setState(() => _tileLoadFailed = false); + } + } + + /// Fires when the camera stops moving — after both gestures and + /// programmatic animations. While auto-follow is on, we use this as the + /// point to sync our tracked target zoom with whatever zoom the camera + /// actually settled at (e.g. after the user pinch-zoomed). That keeps the + /// next auto-follow GPS tick from snapping the camera back to a stale + /// target zoom. + void _onCameraIdle() { + if (!_autoFollow || _mapController == null) return; + final currentZoom = _mapController!.cameraPosition?.zoom; + if (currentZoom != null) { + _autoFollowDesiredZoom = currentZoom; + } + } + + /// Add MeshMapper coverage raster overlay as a MapLibre source+layer + Future _addCoverageOverlay(AppStateProvider appState) async { + if (_mapController == null || !_showMeshMapperOverlay) return; + if (!appState.preferences.mapTilesEnabled) return; + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + + final cvdParam = appState.preferences.colorVisionType != 'none' + ? '&cvd=${appState.preferences.colorVisionType}' + : ''; + final url = 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + + try { + await _mapController!.addSource( + 'meshmapper-overlay', + RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), + ); + // Target the bottom of the repeater cluster stack when it exists, so the + // raster lands beneath ALL marker layers (repeater clusters + symbol + // annotations). During the initial style load, _setupRepeaterClusterLayers + // runs before this — so _clusterLayersReady is true and we use the + // individual repeater layer as the reference. The zoneCode watcher also + // fires after cluster setup, so both paths converge to the same stack. + // Fallback to the symbol annotation layer only if cluster layers haven't + // been created yet (shouldn't happen in practice, but keeps the raster + // underneath markers either way). + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + // While ping focus mode is active, force the newly added raster layer + // to opacity 0 so a cache-bust tile refresh (fires 5s after every API + // upload success — see AppStateProvider._tileRefreshTimer) doesn't + // make the overlay pop back into view in the middle of focus mode. + // Dismissing focus restores the preference value via + // _applyCoverageOverlayOpacity in _dismissPingFocus. + final opacity = _focusedPingLocation != null + ? 0.0 + : appState.preferences.coverageOverlayOpacity; + await _mapController!.addRasterLayer( + 'meshmapper-overlay', + 'meshmapper-overlay-layer', + RasterLayerProperties(rasterOpacity: opacity), + belowLayerId: belowLayer, + ); + _lastAppliedCoverageOpacity = opacity; + debugLog('[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + } catch (e) { + debugLog('[MAP] Failed to add coverage overlay: $e'); + } + } + + /// Apply a new coverage overlay opacity to the live raster layer without + /// removing/re-adding it. No-op if the layer doesn't exist yet. + Future _applyCoverageOverlayOpacity(double opacity) async { + if (_mapController == null) return; + try { + await _mapController!.setLayerProperties( + 'meshmapper-overlay-layer', + RasterLayerProperties(rasterOpacity: opacity), + ); + _lastAppliedCoverageOpacity = opacity; + debugLog('[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); + } catch (e) { + // Layer may not exist yet (e.g. before first style load or when the + // overlay is hidden). Safe to ignore — next _addCoverageOverlay call + // will pick up the current preference value. + debugLog('[MAP] Coverage overlay opacity update skipped: $e'); + } + } + + /// Returns the layer ID of the symbol annotation manager's first (and only) + /// layer, or `null` if the manager isn't initialized yet. Used as a + /// `belowLayerId` reference to insert other layers (coverage overlay, focus + /// lines) BENEATH the marker symbols so markers always render on top. + String? _symbolAnnotationLayerId() { + final manager = _mapController?.symbolManager; + if (manager == null) return null; + return '${manager.id}_0'; + } + + /// Disables MapLibre's default symbol-collision behavior for our marker + /// annotations. Without this, repeater markers fade out as you zoom out + /// because the symbol layer hides overlapping icons + labels to reduce + /// visual clutter — undesirable for a wardriving app where every marker + /// matters. Called once per style load, before any symbols are added. + Future _configureSymbolDecluttering() async { + if (_mapController == null) return; + try { + await _mapController!.setSymbolIconAllowOverlap(true); + await _mapController!.setSymbolIconIgnorePlacement(true); + await _mapController!.setSymbolTextAllowOverlap(true); + await _mapController!.setSymbolTextIgnorePlacement(true); + } catch (e) { + debugError('[MAP] Failed to configure symbol decluttering: $e'); + } + } + + /// Remove coverage overlay source and layer + Future _removeCoverageOverlay() async { + if (_mapController == null) return; + try { + await _mapController!.removeLayer('meshmapper-overlay-layer'); + await _mapController!.removeSource('meshmapper-overlay'); + } catch (_) {} + } + + /// Refresh coverage overlay (remove and re-add with new URL) + Future _refreshCoverageOverlay(AppStateProvider appState) async { + await _removeCoverageOverlay(); + await _addCoverageOverlay(appState); + } + + /// Returns the fill color for a repeater status keyword. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]. + Color _repeaterStatusColor(String status) { + switch (status) { + case 'dup': + return PingColors.repeaterDuplicate; + case 'dead': + return PingColors.repeaterDead; + case 'new': + return PingColors.repeaterNew; + case 'active': + default: + return PingColors.repeaterActive; + } + } + + /// Returns the color for a coverage marker (TX/RX/DISC/Trace × success/fail). + Color _coverageStatusColor(String type, bool success) { + switch (type) { + case 'tx': + return success ? PingColors.txSuccess : PingColors.txFail; + case 'rx': + return PingColors.rx; + case 'disc': + return success ? PingColors.discSuccess : PingColors.discFail; + case 'trace': + return success ? Colors.cyan : Colors.grey; + default: + return Colors.grey; + } + } + + /// Returns the borderRadius value for a repeater shape based on hop_bytes. + /// Mirrors the values in the original `_buildRepeaterMarkers` (lines ~2390). + double _repeaterBorderRadius(int hopBytes) { + if (hopBytes >= 3) return 8; + if (hopBytes == 2) return 6; + return 4; + } + + /// Pre-renders and registers all marker bitmaps that the native MapLibre + /// symbols reference via `iconImage`. Called from [_onStyleLoaded] after the + /// style is ready (so addImage can succeed). Idempotent — safe to call again + /// if a style reload happens; addImage replaces existing entries by name. + /// + /// Generates: + /// - 12 repeater shape bitmaps (4 status colors × 3 hop_byte radii) — fixed + /// width 48px, the widest case (6-char hex IDs); shorter text is centered + /// by MapLibre's textField rendering. + /// - 8 coverage marker bitmaps for the user's currently-selected style. + /// - 6 GPS marker bitmaps (one per style). + /// + /// Marker style preference changes are handled separately by + /// [_reregisterCoverageImages] which only re-runs the coverage section. + Future _registerMapImages(AppStateProvider appState) async { + if (_mapController == null) return; + + try { + // 1. Repeater shapes — 12 variants + const repeaterSize = Size(48, 28); + for (final status in _MapImages.repeaterStatuses) { + final color = _repeaterStatusColor(status); + for (final hopBytes in _MapImages.repeaterHopBytes) { + final painter = _RepeaterShapePainter( + fillColor: color, + borderRadius: _repeaterBorderRadius(hopBytes), + ); + final bytes = await _renderPainterToPng(painter, repeaterSize); + await _mapController!.addImage( + _MapImages.repeater(status, hopBytes), + bytes, + ); + } + } + + // 2. Coverage markers — 8 variants for current style + await _registerCoverageImages(appState.preferences.markerStyle); + + // 3. GPS marker variants — 6 styles + const gpsSize = Size(48, 48); + final gpsPainters = { + 'arrow': const _ArrowPainter(), + 'car': const _CarMarkerPainter(), + 'bike': const _BikeMarkerPainter(), + 'boat': const _BoatMarkerPainter(), + 'walk': const _WalkMarkerPainter(), + 'pacman': const _PacmanMarkerPainter(), + }; + for (final entry in gpsPainters.entries) { + final bytes = await _renderPainterToPng(entry.value, gpsSize); + await _mapController!.addImage(_MapImages.gps(entry.key), bytes); + } + + _imagesRegistered = true; + debugLog('[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); + // NOTE: do NOT trigger _syncAllAnnotations here. The repeater cluster + // source/layers haven't been created yet — _onStyleLoaded calls + // _setupRepeaterClusterLayers AFTER us, then triggers the initial sync + // once everything is in place. + } catch (e) { + debugError('[MAP] Failed to register marker images: $e'); + } + } + + /// Generates and registers the 8 coverage marker bitmaps for [styleName]. + /// Called from [_registerMapImages] on initial setup, and from the + /// preference-change handler when the user picks a different marker shape. + Future _registerCoverageImages(String styleName) async { + if (_mapController == null) return; + // 40×40 canvas with the 24×24 glyph centered inside it — the transparent + // padding enlarges the native symbol hit target (~40×40 px) without + // changing the visual marker size. Fixes finicky taps on small markers. + const coverageSize = Size(40, 40); + for (final type in _MapImages.coverageTypes) { + for (final success in [true, false]) { + final painter = _CoverageMarkerPainter( + style: styleName, + color: _coverageStatusColor(type, success), + ); + final bytes = await _renderPainterToPng(painter, coverageSize); + await _mapController!.addImage( + _MapImages.coverage(type, success), + bytes, + ); + } + } + _registeredCoverageStyle = styleName; + } + + /// Returns the status keyword used as the iconImage suffix for a repeater. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]: duplicate > dead + /// > new > active. + String _repeaterStatusKey(Repeater repeater, bool isDuplicate) { + if (isDuplicate) return 'dup'; + if (repeater.isDead) return 'dead'; + if (repeater.isNew) return 'new'; + return 'active'; + } + + /// Converts a Flutter [Color] to a `#RRGGBB` (or `#RRGGBBAA`) hex string + /// for MapLibre symbol/line properties (which take CSS-style color strings). + String _colorToHex(Color color, {bool includeAlpha = false}) { + final argb = color.toARGB32() & 0xFFFFFFFF; + final rr = ((argb >> 16) & 0xFF).toRadixString(16).padLeft(2, '0'); + final gg = ((argb >> 8) & 0xFF).toRadixString(16).padLeft(2, '0'); + final bb = (argb & 0xFF).toRadixString(16).padLeft(2, '0'); + if (includeAlpha) { + final aa = ((argb >> 24) & 0xFF).toRadixString(16).padLeft(2, '0'); + return '#$rr$gg$bb$aa'; + } + return '#$rr$gg$bb'; + } + + /// Builds a GeoJSON FeatureCollection of all repeaters in app state, with + /// per-feature properties used by the data-driven symbol layer expressions + /// (iconImage, color, opacity, hex). Re-pushed to the cluster source whenever + /// the marker data version changes — MapLibre handles re-clustering natively. + Map _buildRepeaterFeatureCollection(AppStateProvider appState) { + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final focusActive = _focusedPingLocation != null; + + final features = >[]; + for (final repeater in appState.repeaters) { + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final isConnected = focusActive && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + // In focus mode, hide repeaters not involved in the focused ping entirely + // (skip the feature) rather than dimming — cleaner focus view and prevents + // them from contributing to clusters. + if (focusActive && !isConnected) continue; + final effectiveBytes = hopOverride ?? repeater.hopBytes; + // Clamp to the 1/2/3 hop_byte image variants we registered + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final iconImage = _MapImages.repeater(statusKey, shapeBytes); + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [repeater.lon, repeater.lat], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Creates the cluster-enabled GeoJSON source and three rendering layers + /// (individual symbols, cluster bubble circles, cluster count text). Called + /// once per style load AFTER images are registered (the individual symbol + /// layer references the registered icon names via a data-driven expression). + Future _setupRepeaterClusterLayers() async { + if (_mapController == null) return; + + // Idempotent: tear down any existing source/layers from a previous style load + for (final layerId in [ + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ]) { + try { + await _mapController!.removeLayer(layerId); + } catch (_) {} + } + try { + await _mapController!.removeSource(_repeaterSourceId); + } catch (_) {} + + // Empty source with cluster enabled. We'll push real data via setGeoJsonSource + // from _syncRepeaterSymbols whenever the marker data version changes. + // + // IMPORTANT: pass `data` as a Dart Map (NOT jsonEncode-d string). The iOS + // plugin's `buildShapeSource` assumes that if `data` is a String, it must be + // a URL — and crashes via JSONSerialization.data() if a non-URL string is + // passed and the URL parse fails. Maps are handled correctly. + try { + await _mapController!.addSource( + _repeaterSourceId, + const GeojsonSourceProperties( + data: {'type': 'FeatureCollection', 'features': []}, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + ), + ); + + // Place all three layers BELOW the symbol annotation manager so coverage + // markers / GPS / distance labels still render on top of repeater clusters. + final belowLayer = _symbolAnnotationLayerId(); + + // Layer 1: individual repeater markers (when not part of a cluster). + // Data-driven properties read from each feature's `properties` map. + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterIndividualLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: ['!', ['has', 'point_count']], + belowLayerId: belowLayer, + ); + + // Layer 2: cluster bubble (circle, sized by point_count). + // The 'step' expression makes the bubble grow as more repeaters merge: + // - default radius 18px (clusters of 2-9) + // - 22px for clusters of 10+ + // - 26px for clusters of 50+ + await _mapController!.addCircleLayer( + _repeaterSourceId, + _repeaterClusterBubbleLayerId, + CircleLayerProperties( + circleColor: _colorToHex(PingColors.repeaterActive), + circleRadius: const [ + 'step', + ['get', 'point_count'], + 18, + 10, 22, + 50, 26, + ], + circleStrokeColor: '#FFFFFF', + circleStrokeWidth: 2, + circleOpacity: 0.9, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // Layer 3: cluster count text (uses MapLibre's built-in + // 'point_count_abbreviated' property — automatically formatted as + // "1.2k" for large counts). + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterClusterCountLayerId, + const SymbolLayerProperties( + textField: ['get', 'point_count_abbreviated'], + textColor: '#FFFFFF', + textSize: 14, + textHaloColor: '#000000', + textHaloWidth: 1, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // All 3 layers + source created successfully — mark ready so the + // build()-triggered post-frame sync can run, and so _syncRepeaterSymbols + // is allowed to push data via setGeoJsonSource. + _clusterLayersReady = true; + } catch (e) { + debugError('[MAP] Failed to set up repeater cluster layers: $e'); + } + } + + /// Pushes the current repeater state into the cluster source. MapLibre + /// re-clusters natively whenever the source data changes. Replaces the + /// previous per-symbol addSymbol/updateSymbol/removeSymbol diff loop. + Future _syncRepeaterSymbols(AppStateProvider appState) async { + if (_mapController == null || + !_styleLoaded || + !_imagesRegistered || + !_clusterLayersReady) { + return; + } + try { + final geojson = _buildRepeaterFeatureCollection(appState); + await _mapController!.setGeoJsonSource(_repeaterSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update repeater source: $e'); + } + } + + /// Composite key for a coverage marker symbol — kind + timestamp ms. + /// Used as the map key in [_coverageSymbols] and to detect updates/removals. + String _coverageKey(String type, DateTime ts) => + '${type}_${ts.millisecondsSinceEpoch}'; + + /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. + /// One symbol per ping, image varies by type/success state, opacity reflects + /// focus mode (faded if focus active and this isn't the focused ping). + /// + /// Marker style preference changes are NOT handled here — when the user + /// switches between circle/pin/diamond/dot, the caller must first call + /// [_handleMarkerStyleChange] to re-register the bitmap variants. + Future _syncCoverageSymbols(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + // Re-register coverage images if the user changed their style preference + final currentStyle = appState.preferences.markerStyle; + if (_registeredCoverageStyle != currentStyle) { + await _registerCoverageImages(currentStyle); + // After re-registering, all existing coverage symbols still reference + // the same image names — but the underlying bitmaps have changed shape. + // The native side picks up the new bitmaps automatically. No need to + // update each symbol. + } + + final wantedKeys = {}; + final focusActive = _focusedPingLocation != null; + + Future syncOne({ + required String type, + required double lat, + required double lon, + required DateTime ts, + required bool success, + required int idForMetadata, + }) async { + final key = _coverageKey(type, ts); + final isFocused = _isFocusedPing(lat, lon, ts); + // In focus mode, hide every coverage marker except the focused ping. + // Skipping wantedKeys lets the cleanup loop remove them entirely so the + // map is uncluttered. Dismissing focus re-syncs and restores them. + if (focusActive && !isFocused) return; + wantedKeys.add(key); + + final options = SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: _MapImages.coverage(type, success), + iconSize: isFocused ? 1.2 : 1.0, + ); + + final existing = _coverageSymbols[key]; + if (existing == null) { + try { + final symbol = await _mapController!.addSymbol( + options, + {'kind': type, 'id': idForMetadata}, + ); + _coverageSymbols[key] = symbol; + } catch (e) { + debugError('[MAP] addSymbol($type) failed at $ts: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol($type) failed at $ts: $e'); + } + } + } + + // TX pings + for (final ping in appState.txPings) { + await syncOne( + type: 'tx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: ping.heardRepeaters.isNotEmpty, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // RX pings + for (final ping in appState.rxPings) { + await syncOne( + type: 'rx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: true, // RX has no fail state — always uses the rx color + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // DISC entries (success = received node responses; drop = treat as TX fail) + for (final entry in appState.discLogEntries) { + final received = entry.nodeCount > 0; + // When discDropEnabled, "no response" should look like a TX fail color. + // We model that by using the 'tx' image variant for failed DISCs: + final type = (!received && appState.discDropEnabled) ? 'tx' : 'disc'; + await syncOne( + type: type, + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: received, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + + // Trace entries + for (final entry in appState.traceLogEntries) { + await syncOne( + type: 'trace', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: entry.success, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + + // Remove symbols for pings that no longer exist (e.g., user cleared markers) + final toRemove = _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + for (final key in toRemove) { + final sym = _coverageSymbols.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// Returns true if the given GPS marker style should rotate to face the + /// user's heading (vs staying screen-aligned). Arrow/walk/pacman face the + /// heading; car/bike/boat icons stay upright on a rotated map. + bool _gpsStyleFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'pacman'; + + /// Computes the iconRotate value for the GPS marker. + /// + /// MapLibre annotation symbols use the default `icon-rotation-alignment: auto` + /// which resolves to `viewport` for point symbols — meaning iconRotate is + /// applied in screen space, not map space. That has two consequences: + /// + /// - Rotating styles (arrow/walk/pacman) must point in the direction of + /// travel both in always-north mode (where bearing = 0, so iconRotate + /// = heading) AND in heading mode (where the map is rotated so that + /// direction-of-travel is screen-up — so iconRotate should be 0). + /// The single formula that works for both is `heading - bearing`. + /// + /// - Non-rotating styles (car/bike/boat) should always be drawn upright + /// on screen. With viewport alignment that's iconRotate = 0 regardless + /// of bearing; the icon is already screen-aligned by default. + double _gpsIconRotate(String style, double heading) { + final bearing = _mapController?.cameraPosition?.bearing ?? 0; + if (_gpsStyleFacesHeading(style)) { + final rotated = heading - bearing; + // Normalize to 0..360 so MapLibre doesn't take the "long way around" + // when iconRotate crosses the ±180° seam during interpolation. + return (rotated % 360 + 360) % 360; + } + return 0; + } + + /// Adds, updates, or removes the single GPS position symbol to match + /// [appState.currentPosition]. Called from the post-frame sync trigger. + Future _syncGpsSymbol(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + final pos = appState.currentPosition; + if (pos == null) { + // No GPS lock — remove existing GPS symbol if present + if (_gpsSymbol != null) { + try { + await _mapController!.removeSymbol(_gpsSymbol!); + } catch (_) {} + _gpsSymbol = null; + } + return; + } + + final style = appState.preferences.gpsMarkerStyle; + // Use the derived heading (updated by _computeHeading in build()) so the + // arrow/walk/pacman markers actually point in the direction of travel + // even when pos.heading is stale or unset. + final iconRotate = _gpsIconRotate(style, _computedHeading ?? 0); + + final options = SymbolOptions( + geometry: LatLng(pos.latitude, pos.longitude), + iconImage: _MapImages.gps(style), + iconRotate: iconRotate, + ); + + if (_gpsSymbol == null) { + try { + _gpsSymbol = await _mapController!.addSymbol(options, {'kind': 'gps'}); + } catch (e) { + debugError('[MAP] addSymbol(gps) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(_gpsSymbol!, options); + } catch (e) { + debugError('[MAP] updateSymbol(gps) failed: $e'); + } + } + } + + /// Updates only the GPS symbol's iconRotate. Called from the camera-change + /// listener when the bearing changes — under viewport alignment, rotating + /// styles (arrow/walk/pacman) are the ones whose iconRotate depends on the + /// bearing (iconRotate = heading - bearing), so they need refreshing as the + /// bearing animates. Non-rotating styles use iconRotate = 0 and don't care. + /// Cheaper than calling [_syncGpsSymbol] which also updates position. + Future _updateGpsSymbolRotation() async { + if (_gpsSymbol == null || _mapController == null) return; + final appState = context.read(); + final pos = appState.currentPosition; + if (pos == null) return; + final style = appState.preferences.gpsMarkerStyle; + if (!_gpsStyleFacesHeading(style)) return; + try { + await _mapController!.updateSymbol( + _gpsSymbol!, + SymbolOptions(iconRotate: _gpsIconRotate(style, _computedHeading ?? 0)), + ); + } catch (_) {} + } + + // Source/layer ID constants for the focus-mode dotted lines + static const _focusLinesSourceId = 'focus-lines-source'; + static const _focusLinesLayerId = 'focus-lines-layer'; + static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; + + /// Builds and applies the focus-mode dotted polylines that visually connect + /// a focused ping to each repeater that heard it. Color-coded by SNR; + /// ambiguous matches get a wider white outline drawn underneath. + /// + /// Implementation uses a GeoJSON source + line layer (rather than the + /// annotation-level addLine API) because LineOptions does not expose + /// `lineDasharray`, but LineLayerProperties does. + /// + /// Idempotent: removes any existing source/layers first, then re-adds with + /// the latest focus state. + Future _updateFocusLines() async { + if (_mapController == null || !_styleLoaded) return; + + // Always remove existing layers/source first (silently ignore if absent). + // Order matters: remove the layers BEFORE the source they reference. + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) return; + + // Build a FeatureCollection with one LineString per connected repeater. + // Per-feature properties carry the line color (data-driven styling) and + // ambiguous flag (used as a layer filter for the border line). + final features = >[]; + for (final r in _focusedRepeaters) { + final color = r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + features.add({ + 'type': 'Feature', + 'properties': { + 'color': _colorToHex(color), + 'ambiguous': r.ambiguous, + }, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [_focusedPingLocation!.longitude, _focusedPingLocation!.latitude], + [r.repeater.lon, r.repeater.lat], + ], + }, + }); + } + + // Pass the FeatureCollection as a Dart Map (NOT a jsonEncode-d string). + // The iOS plugin's buildShapeSource crashes if `data` is a string that's + // not a URL — see fix in _setupRepeaterClusterLayers for the same gotcha. + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _focusLinesSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Insert focus line layers BELOW the individual repeater layer so + // repeater boxes (and the cluster bubbles/count text above them, plus + // the symbol annotation markers on top of those) all render on top of + // the connecting lines. This is especially important at the repeater + // end of each line, where the dotted stroke would otherwise draw over + // the repeater box. + const belowLayer = _repeaterIndividualLayerId; + + // Border line (white, wider, only for ambiguous matches) — added FIRST + // so it renders BENEATH the colored line on top. + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLayerId, + const LineLayerProperties( + lineColor: '#FFFFFF', + lineOpacity: 0.6, + lineWidth: 6.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + filter: ['==', ['get', 'ambiguous'], true], + belowLayerId: belowLayer, + ); + + // Main colored line (color from feature property via data-driven expression) + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + lineWidth: 3.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + } catch (e) { + debugError('[MAP] Failed to add focus lines: $e'); + } + } + + /// Diff-syncs the distance label symbols shown in focus mode. Each label is + /// a bitmap pill (white text on a dark rounded rectangle background, baked + /// into an addImage icon) placed at the midpoint of the ping→repeater line. + /// + /// A later pass ([_reflowDistanceLabelsForCollisions]) may slide individual + /// labels along their lines after the zoom-to-fit animation settles, to + /// prevent them from overlapping on screen. + Future _syncDistanceLabels(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // No focus → remove all existing labels and wipe the tracking maps. + // + // Order matters here: snapshot the symbols to remove and clear the + // tracking maps SYNCHRONOUSLY before awaiting any removeSymbol call. + // + // Why: removeSymbol is async. If we cleared after the await loop, a + // concurrent _syncDistanceLabels call (triggered by e.g. the user + // tapping a new ping and its focus activating during the yield) would + // see the old tracking data — populate new symbols for the new focus + // into the still-populated map — and then our late `.clear()` would + // wipe the new-focus entries from tracking, leaving orphaned native + // symbols on the map and causing the NEXT sync to double-add them. + // By clearing first, any concurrent sync starts from a clean slate. + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) { + final toRemove = List.of(_distanceLabelSymbols.values); + _distanceLabelSymbols.clear(); + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + return; + } + + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + final wantedKeys = {}; + + for (final r in _focusedRepeaters) { + final key = r.repeater.id; + wantedKeys.add(key); + final midLat = (ping.latitude + r.repeater.lat) / 2; + final midLon = (ping.longitude + r.repeater.lon) / 2; + final meters = GpsService.distanceBetween( + ping.latitude, + ping.longitude, + r.repeater.lat, + r.repeater.lon, + ); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + // Dedup the bitmap image by label text — identical distances reuse one + // registered image. addImage is idempotent by name, so re-registering + // the same name is a no-op on subsequent calls. + final imageName = 'distance-label-${labelText.hashCode}'; + Size? imageSize; + if (!_registeredDistanceLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredDistanceLabelImages.add(imageName); + imageSize = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(distance label) failed: $e'); + } + } + // If we didn't just render (reuse case) we still need the size for + // collision tests. Re-render for measurement; this is cheap and rare. + if (imageSize == null) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + imageSize = rendered.size; + } catch (_) { + imageSize = const Size(60, 18); + } + } + _distanceLabelImageSize[key] = imageSize; + _distanceLabelRepeaterPos[key] = + LatLng(r.repeater.lat, r.repeater.lon); + + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + ); + + final existing = _distanceLabelSymbols[key]; + if (existing == null) { + try { + _distanceLabelSymbols[key] = await _mapController!.addSymbol( + options, + {'kind': 'distance'}, + ); + } catch (e) { + debugError('[MAP] addSymbol(distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(distance) failed: $e'); + } + } + } + + // Remove labels for repeaters no longer in focus + final toRemove = + _distanceLabelSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + for (final key in toRemove) { + final sym = _distanceLabelSymbols.remove(key); + _distanceLabelImageSize.remove(key); + _distanceLabelRepeaterPos.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } } - /// Collapsible map controls (toggle at top, expands downward) - Widget _buildCollapsibleMapControls(AppStateProvider appState) { - // Use external state if provided, otherwise use internal state - final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + /// After the focus zoom-to-fit animation settles, walks the placed distance + /// labels and slides any that overlap on screen to a different position + /// along their ping→repeater line. Uses toScreenLocationBatch to sample + /// candidate t values (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75) for each label + /// and greedily picks the first non-colliding slot. + Future _reflowDistanceLabelsForCollisions() async { + if (_mapController == null || !mounted) return; + if (_focusedPingLocation == null) return; + if (_distanceLabelSymbols.isEmpty) return; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Toggle button (always visible) - at top - GestureDetector( - onTap: onToggle, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: isExpanded - ? const BorderRadius.vertical(top: Radius.circular(8)) - : BorderRadius.circular(8), - ), - child: Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: Colors.white, - size: 22, - ), - ), - ), - // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), - ], - ); - } + final ping = _focusedPingLocation!; + // Deterministic order: iterate focused repeaters in the list order we got + // them in (SNR-ranked upstream), so the "primary" label wins t=0.5. + final orderedIds = _focusedRepeaters + .map((r) => r.repeater.id) + .where(_distanceLabelSymbols.containsKey) + .toList(); + if (orderedIds.isEmpty) return; + + // Candidate t values to try, in preference order. + const candidateTs = [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75]; + + // Step 1: compute all candidate LatLngs for every label so we can batch + // the toScreenLocation calls (one round-trip instead of N×T). + final candidateLatLngs = []; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + if (repeaterPos == null) continue; + for (final t in candidateTs) { + candidateLatLngs.add(LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * t, + ping.longitude + (repeaterPos.longitude - ping.longitude) * t, + )); + } + } - Widget _buildMap(AppStateProvider appState, LatLng center) { - return Builder( - builder: (context) => FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: center, - initialZoom: _defaultZoom, - minZoom: 3, - maxZoom: 17, - interactionOptions: InteractionOptions( - flags: _rotationLocked - ? InteractiveFlag.all & ~InteractiveFlag.rotate - : InteractiveFlag.all, - ), - onMapReady: () { - _isMapReady = true; - // Initial center on GPS if available - if (appState.currentPosition != null) { - _mapController.move(center, _defaultZoom); - } - }, - ), - children: [ - // Tile layer (dynamic based on selected style from preferences) - // Skipped entirely when map tiles are disabled to save mobile data - if (appState.preferences.mapTilesEnabled) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + List> screenPoints; + try { + screenPoints = + await _mapController!.toScreenLocationBatch(candidateLatLngs); + } catch (e) { + debugError('[MAP] toScreenLocationBatch(distance labels) failed: $e'); + return; + } + if (!mounted || _focusedPingLocation == null) return; + + // Step 2: greedily place each label at the first candidate t whose + // screen rect doesn't overlap any already-placed label rect. + const gap = 4.0; // extra spacing between pills in logical pixels + final placedRects = []; + var cursor = 0; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + final labelSize = + _distanceLabelImageSize[id] ?? const Size(60, 18); + if (repeaterPos == null) { + cursor += candidateTs.length; + continue; + } - // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) - if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) - TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', - userAgentPackageName: 'com.meshmapper.app', - minZoom: 3, - maxZoom: 17, - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ), + int bestIdx = 0; + Rect? bestRect; + for (var i = 0; i < candidateTs.length; i++) { + final sp = screenPoints[cursor + i]; + final rect = Rect.fromCenter( + center: Offset(sp.x.toDouble(), sp.y.toDouble()), + width: labelSize.width + gap, + height: labelSize.height + gap, + ); + final collides = placedRects.any((r) => r.overlaps(rect)); + if (!collides) { + bestIdx = i; + bestRect = rect; + break; + } + // Fallback: keep the first candidate rect so we still place somewhere + // if every slot collides. + bestRect ??= rect; + } - // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top - // During focus mode, the focused marker is excluded and rendered in its own top layer - MarkerLayer( - markers: _buildCoverageMarkers( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - excludeFocused: _focusedPingLocation != null, - ), - ), + final tChosen = candidateTs[bestIdx]; + final targetLatLng = LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * tChosen, + ping.longitude + (repeaterPos.longitude - ping.longitude) * tChosen, + ); + placedRects.add(bestRect!); + + final symbol = _distanceLabelSymbols[id]; + if (symbol != null) { + try { + await _mapController!.updateSymbol( + symbol, + SymbolOptions(geometry: targetLatLng), + ); + } catch (e) { + debugError('[MAP] updateSymbol(distance reflow) failed: $e'); + } + } - // Focus mode: polylines from focused ping to each connected repeater - // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) - PolylineLayer( - polylines: _focusedRepeaters.map((r) { - final lineColor = r.snr != null - ? PingColors.snrColor(r.snr!) - : Colors.grey; - return Polyline( - points: [_focusedPingLocation!, LatLng(r.repeater.lat, r.repeater.lon)], - color: lineColor.withValues(alpha: 0.9), - strokeWidth: 3.5, - isDotted: true, - borderStrokeWidth: r.ambiguous ? 1.5 : 0, - borderColor: r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, - ); - }).toList(), - ), + cursor += candidateTs.length; + } + } - // Repeater markers (magenta with ID, rotate with map) - // During focus mode, split into two layers: faded repeaters below, connected on top - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ - // Faded non-connected repeaters (below) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyFaded: true, - ), - ), - // Distance labels (middle) - MarkerLayer( - rotate: true, - markers: _buildFocusDistanceLabels(appState), - ), - // Connected repeaters (on top) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyConnected: true, - ), - ), - // Focused ping marker (above everything except GPS) - MarkerLayer( - markers: _buildFocusedPingMarker( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - ), - ), - ] else - // Normal mode: single layer with all repeaters - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - ), - ), + /// Single entry point that syncs all native annotations against current + /// app state. Called from the post-frame callback in [build] when the + /// marker data version changes (so we don't sync on every camera tick). + Future _syncAllAnnotations(AppStateProvider appState) async { + await _syncRepeaterSymbols(appState); + await _syncCoverageSymbols(appState); + await _syncGpsSymbol(appState); + await _updateFocusLines(); + await _syncDistanceLabels(appState); + } - // Current position marker - if (appState.currentPosition != null) - MarkerLayer( - // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and pacman rotate with heading (handled by Transform.rotate in the painter) - rotate: appState.preferences.gpsMarkerStyle != 'arrow' && - appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'pacman', - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker(appState.currentPosition!.heading), - ), - ], - ), - ], - ), + /// Compute a version hash of all data that affects the marker list. + /// When this changes, the cached marker list is rebuilt; otherwise it's reused + /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). + int _computeMarkerDataVersion(AppStateProvider appState) { + return Object.hash( + appState.txPings.length, + appState.rxPings.length, + appState.discLogEntries.length, + appState.traceLogEntries.length, + appState.repeaters.length, + appState.discDropEnabled, + appState.enforceHopBytes, + appState.effectiveHopBytes, + _focusedPingLocation, + _focusedPingTimestamp, + _focusedRepeaters.length, + appState.preferences.gpsMarkerStyle, + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, ); } @@ -1117,6 +2786,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (_autoFollow) { setState(() { _autoFollow = false; + _autoFollowDesiredZoom = null; }); appState.setMapAutoFollow(false); return; @@ -1128,14 +2798,31 @@ class _MapWidgetState extends State with TickerProviderStateMixin { appState.currentPosition!.latitude, appState.currentPosition!.longitude, ); + const targetZoom = 17.0; // Street level zoom when enabling follow setState(() { _autoFollow = true; _lastGpsPosition = targetPosition; + _autoFollowDesiredZoom = targetZoom; }); appState.setMapAutoFollow(true); - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(targetPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPositionWithZoom(adjustedPosition, 17.0); // Street level zoom when enabling follow + // Bundle target + zoom + bearing into one animation so the + // initial centering can't be half-cancelled by a racing GPS tick. + final double targetBearing = (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final adjustedPosition = _offsetPositionForPadding( + targetPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + durationMs: 500, + ); } } @@ -1143,6 +2830,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { setState(() { _showMeshMapperOverlay = !_showMeshMapperOverlay; }); + if (_showMeshMapperOverlay) { + _addCoverageOverlay(context.read()); + } else { + _removeCoverageOverlay(); + } } void _toggleNorthMode() { @@ -1151,49 +2843,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _alwaysNorth = !_alwaysNorth; // If switching to Always North mode, smoothly rotate map back to north - if (_alwaysNorth && _isMapReady) { - // Reset heading tracking + if (_alwaysNorth && _isMapReady && _mapController != null) { _lastHeading = null; - // Smoothly rotate back to north (0 degrees) - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } else if (!_alwaysNorth && appState.currentPosition != null) { // If switching to heading mode, immediately start rotating to current heading _lastHeading = null; // Force initial rotation + // Prefer our derived heading; fall back to whatever GPS reports (may + // be 0 if we haven't moved yet — better than no rotation at all). + final initialHeading = _computedHeading ?? appState.currentPosition!.heading; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_alwaysNorth && appState.currentPosition != null) { - _animateToRotation(appState.currentPosition!.heading); + _animateToRotation(initialHeading); } }); } @@ -1207,40 +2874,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationLocked = !_rotationLocked; // When enabling lock in "Always North" mode, rotate back to north - // When in "Rotate with Heading" mode, keep current rotation - if (_rotationLocked && _isMapReady && _alwaysNorth) { - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + if (_rotationLocked && _isMapReady && _alwaysNorth && _mapController != null) { + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } }); @@ -1754,104 +3394,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Build a coverage marker child widget based on the user's marker style preference. - Widget _buildCoverageMarkerChild(Color color) { - final style = context.read().preferences.markerStyle; - switch (style) { - case 'circle': - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2.0), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], - ), - ); - case 'pin': - return CustomPaint( - size: const Size(20, 20), - painter: _PinMarkerPainter(color), - ); - case 'diamond': - return CustomPaint( - size: const Size(20, 20), - painter: _DiamondMarkerPainter(color), - ); - case 'dot': - default: - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], - ), - ); - } - } - - /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). - /// Newer pings always render on top regardless of type. - List _buildCoverageMarkers({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - bool excludeFocused = false, - }) { - final timestamped = <(DateTime, Marker)>[ - for (final ping in txPings) - if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildTxMarker(ping)), - for (final ping in rxPings) - if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildRxMarker(ping)), - for (final entry in discEntries) - if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), - for (final entry in traceEntries) - if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildTraceMarker(entry)), - ]; - - timestamped.sort((a, b) => a.$1.compareTo(b.$1)); - return timestamped.map((e) => e.$2).toList(); - } - - /// Build just the focused ping marker for rendering in its own top layer. - List _buildFocusedPingMarker({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - }) { - if (_focusedPingLocation == null) return []; - - for (final ping in txPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildTxMarker(ping)]; - } - } - for (final ping in rxPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildRxMarker(ping)]; - } - } - for (final entry in discEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildDiscMarker(entry, discDropEnabled)]; - } - } - for (final entry in traceEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildTraceMarker(entry)]; - } - } - return []; - } - /// Check if a ping at given lat/lon/timestamp is the currently focused ping. + /// Used by the native annotation sync to apply focus-mode styling (size, + /// opacity) to the focused ping vs other pings. bool _isFocusedPing(double lat, double lon, DateTime timestamp) { return _focusedPingLocation != null && _focusedPingTimestamp == timestamp && @@ -1859,74 +3404,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedPingLocation!.longitude == lon; } - /// Apply focus fade to a marker color. Returns dimmed color if focus is active - /// and this marker is not the focused one. - Color _applyFocusFade(Color color, bool isFocused) { - if (_focusedPingLocation == null || isFocused) return color; - return color.withValues(alpha: 0.15); - } - - Marker _buildTxMarker(TxPing ping) { - final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final color = ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildRxMarker(RxPing ping) { - final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(PingColors.rx, isFocused)), - ), - ); - } - - Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { - final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.nodeCount == 0 - ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) - : _discMarkerColor; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildTraceMarker(TraceLogEntry entry) { - final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.success ? Colors.cyan : Colors.grey; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - void _showTraceDetails(TraceLogEntry entry) { // Activate focus mode for successful traces with a known repeater if (entry.success) { @@ -1943,6 +3420,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2166,50 +3646,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ).whenComplete(() => _dismissPingFocus()); } - /// Build distance label markers at the midpoint of each focus line. - List _buildFocusDistanceLabels(AppStateProvider appState) { - if (_focusedPingLocation == null) return []; - final isImperial = appState.preferences.isImperial; - final ping = _focusedPingLocation!; - - return _focusedRepeaters.map((r) { - final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); - // Midpoint of the line - final midLat = (ping.latitude + repeaterPos.latitude) / 2; - final midLon = (ping.longitude + repeaterPos.longitude) / 2; - // Distance in meters — use GpsService for consistency with repeater popup - final meters = GpsService.distanceBetween( - ping.latitude, ping.longitude, repeaterPos.latitude, repeaterPos.longitude, - ); - final label = meters < 1000 - ? formatMeters(meters, isImperial: isImperial) - : formatKilometers(meters / 1000, isImperial: isImperial); - - return Marker( - point: LatLng(midLat, midLon), - width: 70, - height: 22, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - ), - ); - }).toList(); - } - /// DISC marker color (delegates to active palette) static Color get _discMarkerColor => PingColors.discSuccess; @@ -2253,8 +3689,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { - _preFocusCenter = _mapController.camera.center; - _preFocusZoom = _mapController.camera.zoom; + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; _wasAutoFollowBeforeFocus = _autoFollow; _wasRotatingBeforeFocus = !_alwaysNorth; @@ -2265,10 +3702,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Lock to north-up during focus so the zoom-to-fit view is stable if (!_alwaysNorth) { _alwaysNorth = true; - _animateToRotation(0); // Won't fire because _alwaysNorth is now true - // Snap rotation to 0 directly - if (_isMapReady) { - _mapController.rotate(0); + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); } } @@ -2278,11 +3717,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedRepeaters = repeaters; }); + // Hide the MeshMapper coverage raster overlay for a clean focus view. + // Uses opacity=0 rather than removing the layer to avoid a tile refetch + // on dismiss. No-ops gracefully if the layer isn't present. + _applyCoverageOverlayOpacity(0.0); + WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { _zoomToFocusBounds(pingLocation, repeaters); } }); + + // Once the 500ms zoom-to-fit animation settles, re-flow the distance + // labels so any that collide on screen slide along their lines to a + // non-overlapping slot. 600ms gives the camera a bit of buffer beyond + // the animation duration. + Future.delayed(const Duration(milliseconds: 600), () { + if (!mounted || _focusedPingLocation == null) return; + _reflowDistanceLabelsForCollisions(); + }); } /// Dismiss ping focus mode — restore map state. @@ -2303,6 +3756,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedRepeaters = []; }); + // Restore the MeshMapper coverage raster overlay opacity. Safe if the + // layer was hidden via the toggle during focus — setLayerProperties is + // wrapped in try/catch inside the helper. + final appState = context.read(); + _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); + if (center != null && zoom != null) { _animateToPositionWithZoom(center, zoom); @@ -2349,118 +3808,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers( - List repeaters, - int? regionHopBytesOverride, { - bool onlyFaded = false, - bool onlyConnected = false, - }) { - final duplicateIds = _getDuplicateRepeaterIds(repeaters); - final hasFocus = _focusedPingLocation != null; - - return repeaters.where((repeater) { - if (!hasFocus) return true; // No focus — include all - final isConnected = _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - if (onlyConnected) return isConnected; - if (onlyFaded) return !isConnected; - return true; - }).map((repeater) { - final isDuplicate = duplicateIds.contains(repeater.id); - final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); - - // During focus mode, fade repeaters not connected to the focused ping - final isConnected = hasFocus && _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - final effectiveColor = (hasFocus && !isConnected) - ? markerColor.withValues(alpha: 0.15) - : markerColor; - final effectiveBorderColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - final effectiveTextColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - - // Display hex ID based on per-repeater hop_bytes (or regional admin override) - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; - final isLongId = displayId.length > 2; - final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; - - // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded - final borderRadius = effectiveBytes >= 3 - ? BorderRadius.circular(8) - : effectiveBytes == 2 - ? BorderRadius.circular(6) - : BorderRadius.circular(4); - - return Marker( - point: LatLng(repeater.lat, repeater.lon), - width: markerWidth, - height: 28, - child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), - child: Container( - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 4) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: effectiveColor, - borderRadius: borderRadius, - border: Border.all(color: effectiveBorderColor, width: 2), - boxShadow: (hasFocus && !isConnected) ? null : const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, - fontWeight: FontWeight.bold, - color: effectiveTextColor, - fontFamily: 'monospace', - ), - ), - ), - ), - ); - }).toList(); - } - - Widget _buildCurrentPositionMarker(double heading) { - // Convert heading from degrees to radians - // heading is 0-360 degrees, 0 = North, 90 = East - final headingRadians = heading * (math.pi / 180); - final style = context.read().preferences.gpsMarkerStyle; - - // Arrow, walk, and pacman rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk' || style == 'pacman'; - - final CustomPainter painter; - switch (style) { - case 'car': - painter = const _CarMarkerPainter(); - case 'bike': - painter = const _BikeMarkerPainter(); - case 'boat': - painter = const _BoatMarkerPainter(); - case 'walk': - painter = const _WalkMarkerPainter(); - case 'pacman': - painter = const _PacmanMarkerPainter(); - case 'arrow': - default: - painter = const _ArrowPainter(); - } - - final child = CustomPaint(size: const Size(24, 24), painter: painter); - return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; - } - /// Compute node column width based on hop byte count. /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { @@ -2496,6 +3843,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2711,6 +4061,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2913,6 +4266,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -3763,6 +5119,132 @@ class _DiamondMarkerPainter extends CustomPainter { bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; } +/// Paints a repeater marker shape (filled colored rounded box with white border +/// and drop shadow). Used at startup to generate bitmap variants for native +/// MapLibre symbols. The text (hex ID) is rendered separately by the symbol's +/// `textField` property at runtime — this painter only draws the box itself. +class _RepeaterShapePainter extends CustomPainter { + final Color fillColor; + final double borderRadius; + + const _RepeaterShapePainter({ + required this.fillColor, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + // Inset the box by the shadow blur amount so the shadow has room to draw + const shadowBlur = 4.0; + final boxRect = Rect.fromLTWH( + shadowBlur, + shadowBlur, + size.width - 2 * shadowBlur, + size.height - 2 * shadowBlur, + ); + + // Drop shadow (positioned 2px below the box) + final shadowPaint = Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur); + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.shift(const Offset(0, 2)), + Radius.circular(borderRadius), + ), + shadowPaint, + ); + + // Filled colored box + final fillPaint = Paint()..color = fillColor; + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, Radius.circular(borderRadius)), + fillPaint, + ); + + // White border (2px wide, drawn inside the box edge) + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + final innerRect = boxRect.deflate(1); + canvas.drawRRect( + RRect.fromRectAndRadius(innerRect, Radius.circular(borderRadius - 1)), + borderPaint, + ); + } + + @override + bool shouldRepaint(covariant _RepeaterShapePainter old) => + old.fillColor != fillColor || old.borderRadius != borderRadius; +} + +/// Paints a coverage ping marker (TX/RX/DISC/Trace) in one of the four user +/// styles. Used at startup to generate bitmap variants for native MapLibre +/// symbols. Reuses _PinMarkerPainter and _DiamondMarkerPainter for those styles. +class _CoverageMarkerPainter extends CustomPainter { + final String style; // 'circle' / 'pin' / 'diamond' / 'dot' + final Color color; + + const _CoverageMarkerPainter({required this.style, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + // Visible glyph area — the canvas is typically larger (40×40) so the + // surrounding pixels stay transparent, giving MapLibre a bigger native + // tap hit target without enlarging the actual marker visual. + const innerSize = Size(24, 24); + final dx = (size.width - innerSize.width) / 2; + final dy = (size.height - innerSize.height) / 2; + canvas.save(); + canvas.translate(dx, dy); + switch (style) { + case 'pin': + _PinMarkerPainter(color).paint(canvas, innerSize); + break; + case 'diamond': + _DiamondMarkerPainter(color).paint(canvas, innerSize); + break; + case 'circle': + _paintCircle(canvas, innerSize, borderAlpha: 1.0, borderWidth: 2.0); + break; + case 'dot': + default: + _paintCircle(canvas, innerSize, borderAlpha: 0.6, borderWidth: 1.5); + break; + } + canvas.restore(); + } + + void _paintCircle(Canvas canvas, Size size, {required double borderAlpha, required double borderWidth}) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2 - 2; + + // Drop shadow (slightly below) + final shadowPaint = Paint() + ..color = Colors.black12 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + canvas.drawCircle(center.translate(0, 1), radius, shadowPaint); + + // Filled circle + canvas.drawCircle(center, radius, Paint()..color = color); + + // White border + canvas.drawCircle( + center, + radius, + Paint() + ..color = Colors.white.withValues(alpha: borderAlpha) + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth, + ); + } + + @override + bool shouldRepaint(covariant _CoverageMarkerPainter old) => + old.style != style || old.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; diff --git a/pubspec.lock b/pubspec.lock index 5b882162..243980ae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -430,22 +430,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" - url: "https://pub.dev" - source: hosted - version: "6.2.1" - flutter_map_cancellable_tile_provider: - dependency: "direct main" - description: - name: flutter_map_cancellable_tile_provider - sha256: ae18dd59faf74f3eca1d28f83e59b47741bbff962e123bbebe9335c04d432f44 - url: "https://pub.dev" - source: hosted - version: "2.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -672,14 +656,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" leak_tracker: dependency: transitive description: @@ -712,30 +688,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - lists: + logging: dependency: transitive description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.0.1" - logger: + version: "1.3.0" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_platform_interface: dependency: transitive description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + name: maplibre_gl_platform_interface + sha256: bd7de401dea24dd7e8a6f2fa736ddee7dbbee3e24a9027f0afdd619994702047 url: "https://pub.dev" source: hosted - version: "2.6.2" - logging: + version: "0.25.0" + maplibre_gl_web: dependency: transitive description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + name: maplibre_gl_web + sha256: af0e48bf96e8dd99f8b958a1953126971eb8a0527b9735441d4f24df3913f5a2 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "0.25.0" matcher: dependency: transitive description: @@ -760,14 +744,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" mime: dependency: transitive description: @@ -960,14 +936,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - polylabel: - dependency: transitive - description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" - url: "https://pub.dev" - source: hosted - version: "1.0.1" pool: dependency: transitive description: @@ -984,14 +952,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" provider: dependency: "direct main" description: @@ -1221,14 +1181,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -1373,14 +1325,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" - wkt_parser: - dependency: transitive - description: - name: wkt_parser - sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" - url: "https://pub.dev" - source: hosted - version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 60b10a0e..a0dcd0c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,9 +13,7 @@ dependencies: flutter_blue_plus: ^1.32.0 flutter_web_bluetooth: ^0.2.3 geolocator: ^11.0.0 - flutter_map: ^6.1.0 - flutter_map_cancellable_tile_provider: ^2.0.0 - latlong2: ^0.9.0 + maplibre_gl: ^0.25.0 http: ^1.2.0 shared_preferences: ^2.2.0 hive: ^2.2.3 diff --git a/web/index.html b/web/index.html index e0c14e7c..4af36e5d 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,10 @@ // The value below is injected by flutter build, do not touch. const serviceWorkerVersion = null; + + + + From 9df1c639b85ccf02aaea5f6bef3192d8ad72d67c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Apr 2026 20:18:21 -0400 Subject: [PATCH 054/360] Enhance map widget state management by adding flags to prevent concurrent overlay refreshes and syncs, improving performance and stability during rapid state changes. --- lib/widgets/map_widget.dart | 125 ++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 25 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 4c0aa414..09b6c271 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -358,6 +358,12 @@ class _MapWidgetState extends State { // the current preference in _buildMap to detect slider changes and apply // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). double? _lastAppliedCoverageOpacity; + // Guard flag that coalesces multiple overlay-refresh triggers (cache bust + // and zone change) in the same frame into a single post-frame callback. + // Without this, two watchers can schedule concurrent _refreshCoverageOverlay + // runs whose remove/add calls interleave and produce "Source already exists" + // errors in the native log. + bool _coverageRefreshScheduled = false; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -365,6 +371,14 @@ class _MapWidgetState extends State { // The build() method computes a version hash from app state and only triggers // _syncAllAnnotations when the hash changes (avoiding unnecessary diff work). int _lastMarkerDataVersion = -1; + // Serializes concurrent _syncAllAnnotations runs. Without this, a second + // build() can fire a sync while the previous one is still awaiting platform + // calls — both would mutate _coverageSymbols / _distanceLabelSymbols, and + // the older sync's cleanup loop would remove symbols the newer sync just + // added. The flag causes re-entrant post-frame callbacks to bail; after the + // in-flight sync finishes, the finally block checks if the data version + // advanced during the run and triggers a rebuild if so. + bool _syncInFlight = false; // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. @@ -428,7 +442,17 @@ class _MapWidgetState extends State { @override void dispose() { _tileLoadTimeoutTimer?.cancel(); - _mapController?.removeListener(_onCameraChanged); + final controller = _mapController; + if (controller != null) { + controller.removeListener(_onCameraChanged); + // Symbol/feature tap listeners are registered in _onMapCreated onto + // separate callback collections that ChangeNotifier.dispose() does NOT + // clear. Remove them explicitly so an in-flight tap that gets queued + // before the platform channel is torn down can't reach into a disposed + // State. try/catch swallows the edge case where _onMapCreated never ran. + try { controller.onSymbolTapped.remove(_handleSymbolTap); } catch (_) {} + try { controller.onFeatureTapped.remove(_handleFeatureTap); } catch (_) {} + } super.dispose(); } @@ -871,8 +895,22 @@ class _MapWidgetState extends State { final dataVersion = _computeMarkerDataVersion(appState); if (dataVersion != _lastMarkerDataVersion) { _lastMarkerDataVersion = dataVersion; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _syncAllAnnotations(appState); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + // Guard against concurrent build()-triggered syncs stepping on each + // other. _syncAllAnnotations awaits multiple native platform calls + // and can take ~100ms+; during auto-ping bursts multiple rebuilds + // would otherwise schedule overlapping runs whose cleanup loops + // would remove symbols the other sync just added. + if (_syncInFlight) return; + _syncInFlight = true; + try { + await _syncAllAnnotations(appState); + } catch (e) { + debugError('[MAP] _syncAllAnnotations failed: $e'); + } finally { + _syncInFlight = false; + } }); } } @@ -1010,24 +1048,32 @@ class _MapWidgetState extends State { // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds // cluster layers, re-adds the coverage overlay, and re-syncs annotations. - // Detect cache bust change and refresh overlay - if (appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded) { - _lastCacheBust = appState.overlayCacheBust; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _refreshCoverageOverlay(appState); - }); - } - - // Detect zoneCode transition (null → value, or zone change) and add/refresh - // the overlay. Needed because _addCoverageOverlay only runs during - // _onStyleLoaded — if the first zone check failed with gps_inaccurate, the - // style loads with zoneCode=null and the overlay is skipped. When a later - // retry sets the zone, nothing would otherwise trigger the raster layer. - if (appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded) { - _lastOverlayZoneCode = appState.zoneCode; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _refreshCoverageOverlay(appState); - }); + // Detect cache bust or zoneCode change → schedule a SINGLE coalesced + // refresh. Previously each watcher scheduled its own post-frame callback, + // which could race when both changed in the same frame (e.g. a zone + // transition that also rotates cache bust). The _coverageRefreshScheduled + // flag ensures at most one refresh is queued per frame. + // + // The zoneCode watcher is needed because _addCoverageOverlay only runs + // during _onStyleLoaded — if the first zone check failed with + // gps_inaccurate, the style loads with zoneCode=null and the overlay is + // skipped. When a later retry sets the zone, nothing else would trigger + // the raster layer. + final cacheBustChanged = + appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded; + final zoneChanged = + appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded; + if (cacheBustChanged || zoneChanged) { + if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; + if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; + if (!_coverageRefreshScheduled) { + _coverageRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + _coverageRefreshScheduled = false; + if (!mounted) return; + await _refreshCoverageOverlay(appState); + }); + } } // Detect coverage overlay opacity change (user dragged the slider in @@ -1107,6 +1153,7 @@ class _MapWidgetState extends State { /// app state and call the existing `_show*Details()` method (which expects /// the full object, not just an ID). void _handleSymbolTap(Symbol symbol) { + if (!mounted) return; final data = symbol.data; if (data == null) return; final kind = data['kind'] as String?; @@ -1324,6 +1371,13 @@ class _MapWidgetState extends State { _gpsSymbol = null; _coverageSymbols.clear(); _distanceLabelSymbols.clear(); + // Distance-label companions: the native side wipes registered images on + // style reload, so the "already registered" cache must be cleared too or + // the next focus mode will skip addImage() and reference a non-existent + // image. The size/repeater-position maps are cleared for consistency. + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + _registeredDistanceLabelImages.clear(); // Mark cluster layers as not-ready until _setupRepeaterClusterLayers // creates them on the new style. This gates build()-driven post-frame // syncs from racing ahead of source creation. @@ -1892,10 +1946,14 @@ class _MapWidgetState extends State { } } - /// Composite key for a coverage marker symbol — kind + timestamp ms. + /// Composite key for a coverage marker symbol — kind + timestamp ms + lat/lon. /// Used as the map key in [_coverageSymbols] and to detect updates/removals. - String _coverageKey(String type, DateTime ts) => - '${type}_${ts.millisecondsSinceEpoch}'; + /// Lat/lon at 5-decimal precision (~1.1m) is included so two distinct pings + /// that happen to land in the same millisecond (possible under heavy RX + /// traffic) don't collide on a shared key. + String _coverageKey(String type, DateTime ts, double lat, double lon) => + '${type}_${ts.millisecondsSinceEpoch}_' + '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. /// One symbol per ping, image varies by type/success state, opacity reflects @@ -1928,7 +1986,7 @@ class _MapWidgetState extends State { required bool success, required int idForMetadata, }) async { - final key = _coverageKey(type, ts); + final key = _coverageKey(type, ts, lat, lon); final isFocused = _isFocusedPing(lat, lon, ts); // In focus mode, hide every coverage marker except the focused ping. // Skipping wantedKeys lets the cleanup loop remove them entirely so the @@ -2475,7 +2533,21 @@ class _MapWidgetState extends State { /// Compute a version hash of all data that affects the marker list. /// When this changes, the cached marker list is rebuilt; otherwise it's reused /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). + /// + /// Captures **in-place** mutations too: TX pings grow `heardRepeaters` during + /// the 7s echo window, and DISC entries grow `discoveredNodes` as late + /// responses land. Summing counts makes the hash sensitive to these additions + /// even though the parent list length doesn't change. int _computeMarkerDataVersion(AppStateProvider appState) { + int txEchoTotal = 0; + for (final p in appState.txPings) { + txEchoTotal += p.heardRepeaters.length; + } + int discNodeTotal = 0; + for (final e in appState.discLogEntries) { + discNodeTotal += e.discoveredNodes.length; + } + return Object.hash( appState.txPings.length, appState.rxPings.length, @@ -2489,9 +2561,12 @@ class _MapWidgetState extends State { _focusedPingTimestamp, _focusedRepeaters.length, appState.preferences.gpsMarkerStyle, + appState.preferences.markerStyle, appState.currentPosition?.latitude, appState.currentPosition?.longitude, _computedHeading, + txEchoTotal, + discNodeTotal, ); } From b6b26044373ed70c1f0242834dbfdd934276b37d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 08:03:46 -0400 Subject: [PATCH 055/360] - Offline upload now shows "GPS required" instead of the misleading "Advert your device on the mesh" when GPS is disabled. The error is caught before hitting the API. Device connection is not required for offline upload, only GPS and network connectivity. --- lib/providers/app_state_provider.dart | 10 +++++++++- lib/screens/settings_screen.dart | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1e8e6a89..31b3f91b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -71,6 +71,8 @@ enum OfflineUploadResult { partialFailure, /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available + gpsRequired, } /// Main application state provider @@ -3626,7 +3628,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Authenticating...'); - // 3. Authenticate with offline_mode: true, skipSessionStore: true + // 3. Check GPS before auth — the server requires current coordinates for geo-auth + if (_currentPosition == null) { + debugError('[OFFLINE] Upload requires GPS - location services not available'); + return OfflineUploadResult.gpsRequired; + } + + // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 89e323a3..eeea0bd0 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2001,6 +2001,10 @@ class _SettingsScreenState extends State { message = 'Authentication failed - Advert your device on the mesh'; backgroundColor = Colors.red; break; + case OfflineUploadResult.gpsRequired: + message = 'GPS required - enable location services to upload'; + backgroundColor = Colors.red; + break; case OfflineUploadResult.partialFailure: message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; From aeb8db4b3e93af37d9204993b0289662bf0c34b3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 08:13:26 -0400 Subject: [PATCH 056/360] - Improved error handling for malformed mesh packets. Corrupted packets with oversized path lengths previously produced an unclear RangeError. The error message now describes the actual problem. Affected packets were already safely skipped; this only improves diagnostic output. --- lib/services/meshcore/packet_metadata.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index c65a6ba1..49e1f0a4 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -104,6 +104,9 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; + if (payloadOffset > raw.length) { + throw RangeError('Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + } final Uint8List encryptedPayload = raw.sublist(payloadOffset); debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' From 61eebd33d9cb3212cbf4d0d7fbc39c9934a56270 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 08:51:35 -0400 Subject: [PATCH 057/360] =?UTF-8?q?-=20Client-side=20zone=20transfers=20du?= =?UTF-8?q?ring=20active=20wardriving.=20When=20you=20cross=20into=20a=20n?= =?UTF-8?q?ew=20zone=20(e.g.,=20BOS=20=E2=86=92=20PVD),=20the=20app=20now?= =?UTF-8?q?=20handles=20the=20transfer=20directly:=20releases=20the=20old?= =?UTF-8?q?=20session,=20acquires=20a=20new=20one=20for=20the=20target=20z?= =?UTF-8?q?one,=20updates=20regional=20channels=20and=20scopes,=20and=20re?= =?UTF-8?q?sumes=20auto-ping=20automatically.=20A=20"Changing=20Zone..."?= =?UTF-8?q?=20overlay=20shows=20during=20the=20transfer=20with=20a=20cance?= =?UTF-8?q?l=20option.=20Previously,=20backend-side=20session=20transfers?= =?UTF-8?q?=20could=20intermittently=20fail=20with=20a=20bad=5Fsession=20e?= =?UTF-8?q?rror,=20forcing=20a=20disconnect=20mid-drive.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/app_state_provider.dart | 328 +++++++++++++++++++++++++- lib/screens/connection_screen.dart | 63 +++++ lib/screens/home_screen.dart | 91 +++++++ 3 files changed, 474 insertions(+), 8 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 31b3f91b..5a4b9ba9 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -282,6 +282,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { AutoMode _autoModeBeforeGrace = AutoMode.active; static const Duration _zoneGraceTimeout = Duration(minutes: 5); + // Zone transfer state — tracks session zone for zone-to-zone detection + String? _sessionZoneCode; + bool _isZoneTransferInProgress = false; + String? _zoneTransferFrom; + String? _zoneTransferTo; + // Geofence zone check log throttle (while disconnected) DateTime? _lastZoneCheckLogTime; int _zoneCheckSuppressedCount = 0; @@ -503,6 +509,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '${(_zoneGraceSecondsRemaining ~/ 60).toString().padLeft(2, '0')}:' '${(_zoneGraceSecondsRemaining % 60).toString().padLeft(2, '0')}'; + // Zone transfer getters + bool get isZoneTransferInProgress => _isZoneTransferInProgress; + String? get zoneTransferFrom => _zoneTransferFrom; + String? get zoneTransferTo => _zoneTransferTo; + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); @@ -1774,6 +1785,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Connected with limited access'); } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; + // Start periodic zone refresh to keep slot counts current if (!_preferences.offlineMode) { _startZoneRefreshTimer(); @@ -2731,6 +2745,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _regionalChannels = []; _scope = null; + // Clear zone transfer state + _sessionZoneCode = null; + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -3382,6 +3402,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; + debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { @@ -4415,15 +4438,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _inZone = result['in_zone'] == true; if (_inZone!) { - _currentZone = result['zone'] as Map?; + final newZone = result['zone'] as Map?; + final newZoneCode = newZone?['code'] as String? ?? ''; + final newZoneName = newZone?['name'] ?? 'Unknown'; + + // Detect zone-to-zone transition during active session + if (isConnected && + !_preferences.offlineMode && + _sessionZoneCode != null && + newZoneCode.isNotEmpty && + newZoneCode != _sessionZoneCode && + !_isInZoneGracePeriod && + !_isZoneTransferInProgress) { + _currentZone = newZone; + _nearestZone = null; + await _handleZoneTransfer(newZoneCode, newZoneName); + return; + } + + _currentZone = newZone; _nearestZone = null; - final zoneName = _currentZone?['name'] ?? 'Unknown'; - final zoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[GEOFENCE] In zone: $zoneName ($zoneCode)'); + debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); - // Fetch repeaters for this zone - if (zoneCode.isNotEmpty) { - await _fetchRepeatersForZone(zoneCode); + if (newZoneCode.isNotEmpty) { + await _fetchRepeatersForZone(newZoneCode); } } else { _currentZone = null; @@ -4611,7 +4649,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']}'); + final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; + debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + + // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume + if (_sessionZoneCode != null && + reEnteredZoneCode.isNotEmpty && + reEnteredZoneCode != _sessionZoneCode) { + debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + return; + } + await _onZoneGraceReEntry(); } } @@ -4696,6 +4749,265 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _abandonZoneGracePeriod(); } + // ============================================ + // Zone-to-Zone Transfer + // ============================================ + + /// Handle zone-to-zone transfer during active wardriving session. + /// Releases old zone session and acquires new session for target zone. + /// Preserves BLE connection and radio configuration. + Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + if (_isZoneTransferInProgress) { + debugLog('[ZONE] Transfer already in progress, skipping'); + return; + } + + final oldZoneCode = _sessionZoneCode ?? 'unknown'; + _isZoneTransferInProgress = true; + _zoneTransferFrom = oldZoneCode; + _zoneTransferTo = newZoneCode; + debugLog('[ZONE] Starting zone transfer: $oldZoneCode → $newZoneCode'); + notifyListeners(); + + try { + // 1. Save auto-ping state for restoration + final wasAutoPing = _autoPingEnabled; + final previousMode = _autoMode; + + // 2. Pause auto-ping and wardriving activity + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + _cooldownTimer.stop(); + if (_autoPingEnabled) { + _autoPingEnabled = false; + _idleAutoStopReference = null; + debugLog('[ZONE] Auto-ping paused for zone transfer'); + } + + // 3. Disable heartbeat (old session is about to be released) + _apiService.disableHeartbeat(); + + // 4. Stop RX logger (no valid session context during transfer) + _rxLogger?.stopWardriving(trigger: 'zone_transfer'); + + // 5. Stop zone refresh timer (we're handling the zone change now) + _stopZoneRefreshTimer(); + + // 6. Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + + // 7. Clear API queue (items were created for old zone's session) + await _apiQueueService.clearOnDisconnect(); + + // 8. Release old session (best effort) + if (_devicePublicKey != null && _apiService.hasSession) { + debugLog('[ZONE] Releasing old session for zone $oldZoneCode'); + try { + await _apiService.requestAuth( + reason: 'disconnect', + publicKey: _devicePublicKey!, + ); + debugLog('[ZONE] Old session released'); + } catch (e) { + debugError('[ZONE] Failed to release old session: $e'); + } + } + + // 9. Acquire new session for target zone + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + + if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); + await disconnect(); + return; + } + + debugLog('[ZONE] Requesting auth for zone $newZoneCode'); + final result = await _apiService.requestAuth( + reason: 'connect', + publicKey: _devicePublicKey!, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + // 10. Check auth result + if (result == null) { + debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); + logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + await disconnect(); + return; + } + + if (result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + _startMaintenancePolling(); + notifyListeners(); + await disconnect(); + return; + } + + if (result['success'] != true) { + final reason = result['reason'] as String? ?? 'unknown'; + final message = result['message'] as String? ?? 'Auth failed'; + debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + await disconnect(); + return; + } + + // 11. Auth succeeded — update session zone code + _sessionZoneCode = newZoneCode; + debugLog('[ZONE] Auth succeeded for zone $newZoneCode'); + + if (result['type'] != null) { + _authType = result['type'] as String; + } + + _syncZoneCapacityFromAuth(result); + + // 12. Update regional channels from new auth response + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[ZONE] Regional channels updated: $_regionalChannels'); + + // 13. Update PacketValidator with new channel configuration + if (_unifiedRxHandler != null) { + final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, + ); + } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + } + + // 14. Update flood scope from new auth response + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[ZONE] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + } else { + if (_scope != null) { + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[ZONE] Failed to clear flood scope: $e'); + } + } + _scope = null; + debugLog('[ZONE] No regional scope — using unscoped flood'); + } + + // 15. Enforce regional admin policies from new zone + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[ZONE] Hybrid mode force-enabled by new zone admin'); + } + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); + } + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + } + + // 16. Reconfigure path hash mode if new zone requires different hop bytes + await _configurePathHashMode(); + if (_pingService != null) { + _pingService!.hopBytes = effectiveHopBytes; + _pingService!.traceHopBytes = _traceHopBytes; + } + + // 17. Fetch repeaters for the new zone + _repeatersLoaded = false; + _repeatersLoadedForIata = null; + await _fetchRepeatersForZone(newZoneCode); + + // 18. Re-enable heartbeat + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + + // 19. Restart zone refresh timer + _startZoneRefreshTimer(); + + // 20. Prepare API queue for fresh data in new zone + await _apiQueueService.clearBeforeConnect(); + + // 21. Restore auto-ping if it was active + if (wasAutoPing) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + return; + } + if (!_autoPingEnabled) { + toggleAutoPing(previousMode); + debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); + } + }); + } else { + _startIdleDisconnectTimer(); + } + + debugLog('[ZONE] Zone transfer complete: $oldZoneCode → $newZoneCode'); + } catch (e) { + debugError('[ZONE] Zone transfer error: $e'); + logError('Zone transfer failed: $e', severity: ErrorSeverity.error); + await disconnect(); + } finally { + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + notifyListeners(); + } + } + + /// Cancel zone transfer (user-triggered from UI cancel button). + Future cancelZoneTransfer() async { + debugLog('[ZONE] Zone transfer cancelled by user'); + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + await disconnect(); + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ae749bbe..2ebb0da3 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -109,6 +109,11 @@ class _ConnectionScreenState extends State with WidgetsBinding /// Routes to the correct sub-view (zone bar is already rendered above) Widget _buildStateContent(BuildContext context, AppStateProvider appState) { + // Show zone transfer in progress + if (appState.isZoneTransferInProgress) { + return _buildZoneTransferView(context, appState); + } + // Show zone grace period state if (appState.isInZoneGracePeriod) { return _buildZoneGraceView(context, appState); @@ -341,6 +346,64 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } + Widget _buildZoneTransferView(BuildContext context, AppStateProvider appState) { + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(isLandscape ? 16 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + SizedBox(height: isLandscape ? 12 : 24), + Text( + 'Changing Zone...', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + SizedBox(height: isLandscape ? 8 : 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + SizedBox(height: isLandscape ? 16 : 24), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + } + Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5b8906d2..e835e9d9 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -407,6 +407,17 @@ class _HomeScreenState extends State { ), ), + // Zone transfer overlay (both orientations) + if (appState.isZoneTransferInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildZoneTransferOverlay(appState), + ), + ), + ), + // Portrait: bottom control panel if (!isLandscape) Positioned( @@ -833,6 +844,86 @@ class _HomeScreenState extends State { ); } + /// Zone transfer overlay shown centered over the map during zone-to-zone transfer + Widget _buildZoneTransferOverlay(AppStateProvider appState) { + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + 'Changing Zone...', + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade500, + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade400, + side: BorderSide(color: Colors.orange.shade400), + ), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } + Widget _buildControlPanel() { return Card( margin: const EdgeInsets.all(8), From ca940900eb8bb5651456b94c76cc0a0f300023e9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 09:28:30 -0400 Subject: [PATCH 058/360] - Seeed Xiao nRF52840 boards are now automatically recognized during connection. Previously these devices were not matched in the device database, requiring manual power level selection on every connect. --- assets/device-models.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/device-models.json b/assets/device-models.json index 0da3f2e6..49cb9e60 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -243,6 +243,14 @@ "txPower": 22, "notes": "Seeed Tracker T1000" }, + { + "manufacturer": "Seeed Xiao-nrf52", + "shortName": "Seeed Xiao nRF52", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Seeed Xiao nRF52840 bare board, no PA amplifier" + }, { "manufacturer": "Xiao C3", "shortName": "Xiao C3", From b82e10f3fb10758664004bf725e4d4fcdb768ad8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 10:04:14 -0400 Subject: [PATCH 059/360] - Zone check retry loop no longer continues after enabling Offline Mode. Previously, switching to offline mode while retrying (e.g., no internet) left the retry timer running, causing repeated failed API calls in the background. Retry timers and maintenance polling are now cancelled immediately when offline mode is enabled. --- lib/providers/app_state_provider.dart | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 5a4b9ba9..a1476411 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3141,6 +3141,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Cancel zone check retries — offline mode doesn't need zone validation + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); // Start periodic auto-save to prevent data loss from app kill _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode @@ -3207,6 +3211,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 5b. Start periodic auto-save to prevent data loss from app kill _startOfflineAutoSaveTimer(); + // 5c. Cancel zone check retries and maintenance polling + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -4344,6 +4353,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } + if (_preferences.offlineMode) { + debugLog('[GEOFENCE] Skipping zone check: offline mode enabled'); + return; + } + if (_isCheckingZone) { debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; @@ -5020,11 +5034,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Fetching repeaters for zone: $iata'); try { final fetchedRepeaters = await _apiService.fetchRepeaters(iata); - _repeaters = fetchedRepeaters; - _repeatersLoaded = true; - _repeatersLoadedForIata = iata; - debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + if (fetchedRepeaters.isNotEmpty) { + _repeaters = fetchedRepeaters; + _repeatersLoaded = true; + _repeatersLoadedForIata = iata; + debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); + notifyListeners(); + } else { + debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); } From 33c38fb73f02eceeedd371f5aab5210199f312be Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 11:56:13 -0400 Subject: [PATCH 060/360] - Zone check no longer gets stuck at "Checking Zone..." if the repeater list fails to load. The zone check now completes immediately and repeaters load in the background --- lib/providers/app_state_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a1476411..f56db05d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4475,7 +4475,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - await _fetchRepeatersForZone(newZoneCode); + _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; From 0df9498852e14b00f05e90d390497ef707aa9cf5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 12:28:18 -0400 Subject: [PATCH 061/360] - Improved error logging when the API returns unexpected HTML responses (e.g., CDN/proxy error pages during network issues). Now logs a clear diagnostic message instead of a raw FormatException. No behavior change, zone check already handled this gracefully. --- lib/services/api_service.dart | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 69c1fd2b..eb8a47df 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -189,15 +189,12 @@ class ApiService { } Map? data; - if (response.statusCode == 200) { + try { data = json.decode(response.body) as Map; - } else if (response.body.isNotEmpty) { - // Try to parse structured error responses (e.g., 403 gps_inaccurate) - try { - data = json.decode(response.body) as Map; - } catch (e) { - debugWarn('[API] Non-JSON response body (HTML error page, etc.): $e'); - } + } on FormatException { + // CDN/proxy can return HTML error pages with HTTP 200 + debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } _logApiCall( From 1d0c03612b78a146944c755225976106dd8f5276 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 14:27:50 -0400 Subject: [PATCH 062/360] - Multi-hop TX echoes are no longer counted in the top-heard display or API payload. Only direct single-hop echoes are tracked, since multi-hop readings reflect the forwarding repeater's link rather than the original repeater's signal. --- lib/services/meshcore/tx_tracker.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 50b1d2ca..575536ee 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -139,6 +139,15 @@ class TxTracker { reportedRssi = null; } + // Only direct (1-hop) echoes yield a meaningful SNR/RSSI for the repeater + // that heard our TX: the radio reports last-hop link quality, so for any + // multi-hop relay the metrics describe a different link entirely. + if (!carpeaterStripped && metadata.pathHashCount > 1) { + debugLog('[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' + 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); + return false; + } + // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { From d4dadaf77f850da3041a0e9b72c69c79229099a3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 14:54:37 -0400 Subject: [PATCH 063/360] - "Pac-Man" GPS marker renamed to "Chomper" with a new design: cyan color, wider mouth, no eye. Existing users are automatically migrated on next launch. --- lib/models/user_preferences.dart | 9 ++++++++- lib/screens/settings_screen.dart | 26 ++++++++++-------------- lib/widgets/map_widget.dart | 34 +++++++++++++------------------- 3 files changed, 32 insertions(+), 37 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 5f25a07a..5efca8d4 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -188,7 +188,7 @@ class UserPreferences { autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, markerStyle: (json['markerStyle'] as String?) ?? 'dot', - gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, @@ -200,6 +200,13 @@ class UserPreferences { ); } + /// Migrate the legacy 'pacman' gps marker id to 'chomper' after the rename. + static String _migrateGpsMarkerStyle(String? value) { + if (value == null) return 'arrow'; + if (value == 'pacman') return 'chomper'; + return value; + } + /// Convert to JSON (for persistence) Map toJson() { return { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index eeea0bd0..4df361e8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1006,7 +1006,7 @@ class _SettingsScreenState extends State { case 'bike': return 'Bike'; case 'boat': return 'Boat'; case 'walk': return 'Walk'; - case 'pacman': return 'Pac-Man'; + case 'chomper': return 'Chomper'; case 'arrow': default: return 'Arrow'; } @@ -1066,7 +1066,7 @@ class _SettingsScreenState extends State { ('bike', 'Bike', const Icon(Icons.directions_bike)), ('boat', 'Boat', const Icon(Icons.directions_boat)), ('walk', 'Walk', const Icon(Icons.directions_walk)), - ('pacman', 'Pac-Man', const _PacmanIcon()), + ('chomper', 'Chomper', const _ChomperIcon()), ]; showModalBottomSheet( context: context, @@ -2634,24 +2634,24 @@ class _OfflineSessionTile extends StatelessWidget { } } -/// Pac-Man icon widget for the GPS marker selector -class _PacmanIcon extends StatelessWidget { - const _PacmanIcon(); +/// Chomper icon widget for the GPS marker selector +class _ChomperIcon extends StatelessWidget { + const _ChomperIcon(); @override Widget build(BuildContext context) { return CustomPaint( size: const Size(24, 24), - painter: _PacmanIconPainter( + painter: _ChomperIconPainter( color: IconTheme.of(context).color ?? Colors.grey, ), ); } } -class _PacmanIconPainter extends CustomPainter { +class _ChomperIconPainter extends CustomPainter { final Color color; - const _PacmanIconPainter({required this.color}); + const _ChomperIconPainter({required this.color}); @override void paint(Canvas canvas, Size size) { @@ -2659,7 +2659,7 @@ class _PacmanIconPainter extends CustomPainter { final cy = size.height / 2; const radius = 10.0; - const mouthAngle = 45.0 * (math.pi / 180); + const mouthAngle = 70.0 * (math.pi / 180); // Mouth faces right for the settings icon (natural reading direction) const startAngle = mouthAngle / 2; const sweepAngle = 2 * math.pi - mouthAngle; @@ -2678,15 +2678,9 @@ class _PacmanIconPainter extends CustomPainter { ) ..close(); canvas.drawPath(path, paint); - - // Eye - final eyePaint = Paint() - ..color = color == Colors.grey ? Colors.white : Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(cx + 2, cy - 3.5), 1.5, eyePaint); } @override - bool shouldRepaint(covariant _PacmanIconPainter oldDelegate) => + bool shouldRepaint(covariant _ChomperIconPainter oldDelegate) => color != oldDelegate.color; } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e8c21f92..76d2edac 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -805,10 +805,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (appState.currentPosition != null) MarkerLayer( // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and pacman rotate with heading (handled by Transform.rotate in the painter) + // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) rotate: appState.preferences.gpsMarkerStyle != 'arrow' && appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'pacman', + appState.preferences.gpsMarkerStyle != 'chomper', markers: [ Marker( point: LatLng( @@ -2437,8 +2437,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final headingRadians = heading * (math.pi / 180); final style = context.read().preferences.gpsMarkerStyle; - // Arrow, walk, and pacman rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk' || style == 'pacman'; + // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk' || style == 'chomper'; final CustomPainter painter; switch (style) { @@ -2450,8 +2450,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { painter = const _BoatMarkerPainter(); case 'walk': painter = const _WalkMarkerPainter(); - case 'pacman': - painter = const _PacmanMarkerPainter(); + case 'chomper': + painter = const _ChomperMarkerPainter(); case 'arrow': default: painter = const _ArrowPainter(); @@ -3610,9 +3610,9 @@ class _WalkMarkerPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -/// Paints a Pac-Man for GPS position marker — mouth faces up (direction of travel) -class _PacmanMarkerPainter extends CustomPainter { - const _PacmanMarkerPainter(); +/// Paints a Chomper (cyan wedge) GPS position marker — mouth faces up (direction of travel) +class _ChomperMarkerPainter extends CustomPainter { + const _ChomperMarkerPainter(); @override void paint(Canvas canvas, Size size) { @@ -3620,10 +3620,10 @@ class _PacmanMarkerPainter extends CustomPainter { final cy = size.height / 2; const radius = 10.0; - // Mouth opening angle: 45° total (22.5° each side of the top) + // Mouth opening angle: 70° total (35° each side of the top) // In canvas coordinates, 0° = 3 o'clock, so "up" = -90° = -π/2. - // Arc sweeps clockwise. We start at (-90 - 22.5)° and sweep (360 - 45)°. - const mouthAngle = 45.0 * (math.pi / 180); + // Arc sweeps clockwise. We start at (-90 + 35)° and sweep (360 - 70)°. + const mouthAngle = 70.0 * (math.pi / 180); const startAngle = -math.pi / 2 + mouthAngle / 2; // right edge of mouth const sweepAngle = 2 * math.pi - mouthAngle; @@ -3643,9 +3643,9 @@ class _PacmanMarkerPainter extends CustomPainter { ..close(); canvas.drawPath(outlinePath, outlinePaint); - // Yellow Pac-Man body + // Cyan body final bodyPaint = Paint() - ..color = const Color(0xFFFFEB3B) // Material yellow + ..color = const Color(0xFF00BCD4) ..style = PaintingStyle.fill; final bodyPath = ui.Path() @@ -3658,12 +3658,6 @@ class _PacmanMarkerPainter extends CustomPainter { ) ..close(); canvas.drawPath(bodyPath, bodyPaint); - - // Eye — small black circle, offset toward the mouth side (upper-left of center) - final eyePaint = Paint() - ..color = Colors.black - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(cx - 2.5, cy - 4.5), 1.5, eyePaint); } @override From 08239d0351c4749856ad436978c14197803886b0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 13 Apr 2026 22:29:40 -0400 Subject: [PATCH 064/360] ci: add PR-triggered analyze/test workflow and CODEOWNERS Adds a new CI workflow that runs flutter analyze and flutter test on every PR targeting main or dev, and a CODEOWNERS file routing all review requests to @MrAlders0n. Combined with branch protection updates (require_code_owner_reviews), this enforces owner approval on every incoming PR before merge. The workflow uses least-privilege contents:read permissions and does not inject API_KEY, since analyze/test do not need it and keeping it out of PR runs prevents fork-PR exposure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3d4e8f53 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @MrAlders0n diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..951a208f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - run: flutter analyze + - run: flutter test From a431a6ae297743a8b4b0df029411441c464d2f78 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Tue, 14 Apr 2026 15:26:36 -0700 Subject: [PATCH 065/360] format with dart format using `dart format .` manual add missing curly braces in control flow statements with dartls adds check in ci to confirm formatting and remove tests and there are none --- .github/workflows/ci.yml | 4 +- bin/test_message.dart | 42 +- lib/main.dart | 49 +- lib/models/api_queue_item.dart | 21 +- lib/models/connection_state.dart | 32 +- lib/models/device_model.dart | 15 +- lib/models/log_entry.dart | 74 +- lib/models/noise_floor_session.dart | 10 +- lib/models/ping_data.dart | 15 +- lib/models/user_preferences.dart | 36 +- lib/providers/app_state_provider.dart | 1023 +++++++---- lib/screens/connection_screen.dart | 278 ++- lib/screens/graph_screen.dart | 18 +- lib/screens/home_screen.dart | 139 +- lib/screens/log_screen.dart | 378 ++-- lib/screens/main_scaffold.dart | 16 +- lib/screens/settings_screen.dart | 515 ++++-- lib/services/api_queue_service.dart | 99 +- lib/services/api_service.dart | 253 ++- lib/services/audio_service.dart | 18 +- lib/services/background_service.dart | 6 +- lib/services/bluetooth/mobile_bluetooth.dart | 90 +- lib/services/bluetooth/web_bluetooth.dart | 35 +- lib/services/countdown_timer_service.dart | 12 +- lib/services/custom_api_service.dart | 21 +- lib/services/debug_file_logger.dart | 4 +- lib/services/debug_submit_service.dart | 159 +- lib/services/device_model_service.dart | 13 +- lib/services/gps_service.dart | 70 +- lib/services/gps_simulator_service.dart | 85 +- lib/services/meshcore/buffer_utils.dart | 7 +- lib/services/meshcore/channel_service.dart | 45 +- lib/services/meshcore/connection.dart | 204 ++- lib/services/meshcore/crypto_service.dart | 95 +- lib/services/meshcore/disc_tracker.dart | 58 +- lib/services/meshcore/packet_metadata.dart | 35 +- lib/services/meshcore/packet_parser.dart | 4 +- lib/services/meshcore/packet_validator.dart | 55 +- lib/services/meshcore/protocol_constants.dart | 22 +- lib/services/meshcore/rx_logger.dart | 113 +- lib/services/meshcore/trace_tracker.dart | 32 +- lib/services/meshcore/tx_tracker.dart | 144 +- lib/services/meshcore/unified_rx_handler.dart | 19 +- lib/services/offline_session_service.dart | 49 +- .../permission_disclosure_service.dart | 14 +- lib/services/ping_service.dart | 178 +- lib/utils/debug_logger.dart | 44 +- lib/utils/debug_logger_io.dart | 4 +- lib/utils/debug_logger_stub.dart | 28 +- lib/utils/ping_colors.dart | 88 +- lib/widgets/bug_report_dialog.dart | 533 +++--- lib/widgets/connection_panel.dart | 30 +- lib/widgets/map_widget.dart | 1536 +++++++++++------ lib/widgets/noise_floor_chart.dart | 176 +- lib/widgets/offline_mode_toggle.dart | 15 +- lib/widgets/ping_controls.dart | 899 +++++++--- lib/widgets/regional_config_card.dart | 52 +- lib/widgets/repeater_id_chip.dart | 54 +- lib/widgets/repeater_picker_sheet.dart | 24 +- lib/widgets/status_bar.dart | 104 +- lib/widgets/upload_logs_dialog.dart | 109 +- 61 files changed, 5432 insertions(+), 2868 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 951a208f..08f6a819 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,7 @@ jobs: - run: flutter pub get - run: dart run build_runner build --delete-conflicting-outputs + - run: dart format --output=none --set-exit-if-changed . - run: flutter analyze - - run: flutter test + #- run: flutter test + # no tests yet, fails without ./test directory diff --git a/bin/test_message.dart b/bin/test_message.dart index f111341e..48ab857b 100644 --- a/bin/test_message.dart +++ b/bin/test_message.dart @@ -107,8 +107,22 @@ class PayloadType { class CryptoService { /// Fixed key for "Public" channel static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a hashtag channel name using SHA-256 @@ -228,8 +242,10 @@ class PacketMetadata { final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - final int payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; - final int protocolVersion = (header >> PacketHeader.verShift) & PacketHeader.verMask; + final int payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final int protocolVersion = + (header >> PacketHeader.verShift) & PacketHeader.verMask; // Calculate offset for Path Length based on route type int pathLengthOffset = 1; @@ -427,9 +443,12 @@ void main(List arguments) { // Print packet metadata print('PACKET METADATA'); - print(' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); - print(' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); - print(' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); print(' Protocol Version: ${metadata.protocolVersion}'); print(' Path Length: ${metadata.pathLength} bytes'); @@ -444,10 +463,12 @@ void main(List arguments) { print(' Path: (empty)'); } - print(' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); + print( + ' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); if (metadata.channelHash != null) { - print(' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); } print(''); @@ -514,7 +535,8 @@ void main(List arguments) { print(''); print(' Known channel hashes:'); for (final entry in channels.entries) { - print(' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); + print( + ' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); } printValidationResults(steps, false, 'Unknown channel hash'); return; diff --git a/lib/main.dart b/lib/main.dart index 08ef6efb..985fb78c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -114,7 +114,8 @@ Future _requestPermissions() async { Future _requestiOSPermissions() async { // Note: Location permission is now requested AFTER showing the prominent disclosure // dialog in MainScaffold (required for Google Play compliance) - debugLog('[APP] iOS: Skipping location permission (handled after disclosure)'); + debugLog( + '[APP] iOS: Skipping location permission (handled after disclosure)'); // Trigger Core Bluetooth authorization by checking adapter state // This will cause iOS to show the Bluetooth permission prompt if not already granted @@ -132,7 +133,8 @@ Future _requestiOSPermissions() async { .where((state) => state == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 3), onTimeout: () { - debugLog('[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); + debugLog( + '[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); return fbp.BluetoothAdapterState.off; }); } @@ -165,36 +167,39 @@ Future _requestAndroidPermissions() async { // Dark theme - Tailwind Slate palette const darkColorScheme = ColorScheme.dark( - primary: Color(0xFF059669), // emerald-600 (main actions) + primary: Color(0xFF059669), // emerald-600 (main actions) onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 (TX ping) + secondary: Color(0xFF0284C7), // sky-600 (TX ping) onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) + tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) onTertiary: Colors.white, - surface: Color(0xFF1E293B), // slate-800 (cards/panels) - onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) - onSurfaceVariant: Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) + surface: Color(0xFF1E293B), // slate-800 (cards/panels) + onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) + onSurfaceVariant: + Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) surfaceContainerHighest: Color(0xFF0F172A), // slate-900 (main bg) - outline: Color(0xFF334155), // slate-700 (borders) - error: Color(0xFFF87171), // red-400 + outline: Color(0xFF334155), // slate-700 (borders) + error: Color(0xFFF87171), // red-400 onError: Colors.white, ); // Light theme - Tailwind Slate palette (inverted) // Note: Using darker grays for better text contrast const lightColorScheme = ColorScheme.light( - primary: Color(0xFF059669), // emerald-600 + primary: Color(0xFF059669), // emerald-600 onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 + secondary: Color(0xFF0284C7), // sky-600 onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 + tertiary: Color(0xFF4F46E5), // indigo-600 onTertiary: Colors.white, - surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) - onSurface: Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) - onSurfaceVariant: Color(0xFF475569), // slate-600 (muted text - darker for readability) + surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) + onSurface: + Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) + onSurfaceVariant: + Color(0xFF475569), // slate-600 (muted text - darker for readability) surfaceContainerHighest: Color(0xFFFFFFFF), // white (main bg) - outline: Color(0xFFCBD5E1), // slate-300 (borders) - error: Color(0xFFDC2626), // red-600 + outline: Color(0xFFCBD5E1), // slate-300 (borders) + error: Color(0xFFDC2626), // red-600 onError: Colors.white, ); @@ -206,9 +211,8 @@ class MeshMapperApp extends StatelessWidget { @override Widget build(BuildContext context) { // Create platform-appropriate Bluetooth service - final BluetoothService bluetoothService = kIsWeb - ? WebBluetoothService() - : MobileBluetoothService(); + final BluetoothService bluetoothService = + kIsWeb ? WebBluetoothService() : MobileBluetoothService(); return MultiProvider( providers: [ @@ -260,7 +264,8 @@ class _ThemedAppState extends State<_ThemedApp> { scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 appBarTheme: const AppBarTheme( backgroundColor: Color(0xFFF8FAFC), // slate-50 - foregroundColor: Color(0xFF0F172A), // slate-900 (darker for contrast) + foregroundColor: + Color(0xFF0F172A), // slate-900 (darker for contrast) ), cardTheme: CardThemeData( color: const Color(0xFFF8FAFC), // slate-50 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 0f58d317..3a197358 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -87,7 +87,8 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing + canUploadAfter: DateTime.now() + .millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, @@ -135,7 +136,8 @@ class ApiQueueItem extends HiveObject { double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" - final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; + final heardRepeats = + '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; return ApiQueueItem( type: 'DISC', latitude: latitude, @@ -163,7 +165,8 @@ class ApiQueueItem extends HiveObject { int? noiseFloor, double? power, }) { - final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + final heardRepeats = + '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( type: 'TRACE', latitude: latitude, @@ -249,7 +252,8 @@ class ApiQueueItem extends HiveObject { 'local_rssi': parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0, 'remote_snr': parts.length > 4 ? double.tryParse(parts[4]) ?? 0.0 : 0.0, 'public_key': parts.length > 5 ? parts[5] : '', - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': timestamp.millisecondsSinceEpoch ~/ + 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; @@ -261,7 +265,8 @@ class ApiQueueItem extends HiveObject { 'lon': longitude, 'noisefloor': noiseFloor, 'heard_repeats': heardRepeats, - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': + timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; @@ -281,7 +286,8 @@ class ApiQueueItem extends HiveObject { } /// Check if item is eligible for upload based on canUploadAfter - bool get isUploadEligible => DateTime.now().millisecondsSinceEpoch >= canUploadAfter; + bool get isUploadEligible => + DateTime.now().millisecondsSinceEpoch >= canUploadAfter; /// Mark as retried void markRetried() { @@ -291,5 +297,6 @@ class ApiQueueItem extends HiveObject { } @override - String toString() => 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; + String toString() => + 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; } diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index d8045983..e8072954 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -2,16 +2,16 @@ enum ConnectionStatus { /// Not connected to any device disconnected, - + /// Currently scanning for devices scanning, - + /// Connecting to device connecting, - + /// Connected and ready connected, - + /// Connection error occurred error, } @@ -27,31 +27,31 @@ enum ConnectionStep { /// Step 1: BLE GATT connect bleConnecting, - + /// Step 2: Protocol handshake protocolHandshake, - + /// Step 3: Device info query deviceQuery, - + /// Step 4: Device identification (match device model for display/reporting) powerConfiguration, - + /// Step 5: Time synchronization timeSync, - + /// Step 6: API slot acquisition slotAcquisition, - + /// Step 7: Channel setup (#wardriving) channelSetup, - + /// Step 8: GPS initialization gpsInit, - + /// Step 9: Fully connected and ready connected, - + /// Error state error, } @@ -60,13 +60,13 @@ enum ConnectionStep { enum GpsStatus { /// GPS permissions not granted permissionDenied, - + /// GPS is disabled on device disabled, - + /// Searching for GPS signal searching, - + /// GPS lock acquired locked, diff --git a/lib/models/device_model.dart b/lib/models/device_model.dart index 41ff907e..61505f98 100644 --- a/lib/models/device_model.dart +++ b/lib/models/device_model.dart @@ -1,24 +1,24 @@ /// Represents a MeshCore device model with its power configuration. -/// +/// /// This maps to the device-models.json database from the WebClient repo. /// Power configuration is critical for PA amplifier models to prevent hardware damage. class DeviceModel { /// Full manufacturer string reported by device (e.g., "Ikoka Stick-E22-30dBm (Xiao_nrf52)") final String manufacturer; - + /// Short display name (e.g., "Ikoka Stick") final String shortName; - + /// Power setting for wardrive.js (0.3, 0.6, 1.0, 2.0) /// CRITICAL: PA amplifier models require exact values final double power; - + /// Hardware platform (nrf52, esp32, esp32-s3, etc.) final String platform; - + /// Firmware TX power setting in dBm final int txPower; - + /// Additional notes about the device final String notes; @@ -55,7 +55,8 @@ class DeviceModel { } @override - String toString() => 'DeviceModel($shortName, power=$power, txPower=$txPower)'; + String toString() => + 'DeviceModel($shortName, power=$power, txPower=$txPower)'; } /// Container for the full device models database diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 26429beb..2fe84af8 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,11 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); + : events + .map((e) => e.snr != null + ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' + : '${e.repeaterId}(null)') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,7 +43,8 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ @@ -68,8 +73,10 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) - final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? + rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -190,7 +197,8 @@ class UnifiedPingLogEntry implements Comparable { final DateTime timestamp; final dynamic entry; - UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + UnifiedPingLogEntry( + {required this.type, required this.timestamp, required this.entry}); TxLogEntry get asTx => entry as TxLogEntry; RxLogEntry get asRx => entry as RxLogEntry; @@ -198,28 +206,29 @@ class UnifiedPingLogEntry implements Comparable { TraceLogEntry get asTrace => entry as TraceLogEntry; @override - int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + int compareTo(UnifiedPingLogEntry other) => + other.timestamp.compareTo(timestamp); String get timeString => switch (type) { - PingLogType.tx => asTx.timeString, - PingLogType.rx => asRx.timeString, - PingLogType.disc => asDisc.timeString, - PingLogType.trace => asTrace.timeString, - }; + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; String get locationString => switch (type) { - PingLogType.tx => asTx.locationString, - PingLogType.rx => asRx.locationString, - PingLogType.disc => asDisc.locationString, - PingLogType.trace => asTrace.locationString, - }; + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; String toCsv() => switch (type) { - PingLogType.tx => 'TX,${asTx.toCsv()}', - PingLogType.rx => 'RX,${asRx.toCsv()}', - PingLogType.disc => 'DISC,${asDisc.toCsv()}', - PingLogType.trace => 'TRC,${asTrace.toCsv()}', - }; + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; } /// User Error Entry for error log @@ -249,9 +258,9 @@ class UserErrorEntry { /// Error severity levels enum ErrorSeverity { - info, // Blue: informational messages + info, // Blue: informational messages warning, // Orange: warnings - error, // Red: errors + error, // Red: errors } /// Discovery Log Entry (discovery protocol observation) @@ -290,19 +299,24 @@ class DiscLogEntry { String toCsv() { final nodesStr = discoveredNodes.isEmpty ? 'None' - : discoveredNodes.map((n) => '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})').join(','); + : discoveredNodes + .map((n) => + '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,${noiseFloor ?? ''},${discoveredNodes.length},$nodesStr'; } } /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") - final String nodeType; // "REPEATER" or "ROOM" - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String? pubkeyHex; // Full public key hex (64 chars) for exact repeater matching + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final String nodeType; // "REPEATER" or "ROOM" + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String? + pubkeyHex; // Full public key hex (64 chars) for exact repeater matching DiscoveredNodeEntry({ required this.repeaterId, diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 6bd9fbf3..c2af3531 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -163,11 +163,11 @@ class NoiseFloorSession extends HiveObject { /// Display name for the mode String get modeDisplay => switch (mode) { - 'active' => 'Active Mode', - 'hybrid' => 'Hybrid Mode', - 'targeted' => 'Trace Mode', - _ => 'Passive Mode', - }; + 'active' => 'Active Mode', + 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', + _ => 'Passive Mode', + }; /// Formatted duration string (M:SS or H:MM:SS for long sessions) String get durationDisplay { diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 0e42d087..9d7e1051 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -7,7 +7,7 @@ part 'ping_data.g.dart'; enum PingType { @HiveField(0) tx, - + @HiveField(1) rx, } @@ -48,7 +48,8 @@ class TxPing { /// Note: power is stored in dBm but the message format uses watts /// The actual message is built in PingService with the correct watts value String toMessageFormat({double? powerWatts}) { - final coordsStr = '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + final coordsStr = + '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; final pw = powerWatts ?? 0.3; // Default to 0.3w if not provided return '@[MapperBot] $coordsStr [${pw.toStringAsFixed(1)}w]'; } @@ -70,19 +71,19 @@ class TxPing { class RxPing { @HiveField(0) final double latitude; - + @HiveField(1) final double longitude; - + @HiveField(2) final String repeaterId; - + @HiveField(3) final DateTime timestamp; - + @HiveField(4) final double snr; - + @HiveField(5) final int rssi; diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 5efca8d4..11c04a75 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -173,7 +173,8 @@ class UserPreferences { backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, mapStyle: (json['mapStyle'] as String?) ?? 'dark', - closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, + closeAppAfterDisconnect: + (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, @@ -183,7 +184,8 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, - deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + deleteChannelOnDisconnect: + (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, @@ -191,12 +193,15 @@ class UserPreferences { gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, - disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, + disconnectAlertEnabled: + (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, customApiUrl: json['customApiUrl'] as String?, customApiKey: json['customApiKey'] as String?, - customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, - customApiIncludeContact: (json['customApiIncludeContact'] as bool?) ?? true, + customApiDisclaimerAccepted: + (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: + (json['customApiIncludeContact'] as bool?) ?? true, ); } @@ -304,10 +309,12 @@ class UserPreferences { powerLevelSet: powerLevelSet ?? this.powerLevelSet, offlineMode: offlineMode ?? this.offlineMode, iataCode: iataCode ?? this.iataCode, - backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + backgroundModeEnabled: + backgroundModeEnabled ?? this.backgroundModeEnabled, developerModeEnabled: developerModeEnabled ?? this.developerModeEnabled, mapStyle: mapStyle ?? this.mapStyle, - closeAppAfterDisconnect: closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, + closeAppAfterDisconnect: + closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, themeMode: themeMode ?? this.themeMode, unitSystem: unitSystem ?? this.unitSystem, hybridModeEnabled: hybridModeEnabled ?? this.hybridModeEnabled, @@ -317,20 +324,25 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, - deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, - minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + deleteChannelOnDisconnect: + deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: + minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, - disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, + disconnectAlertEnabled: + disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, customApiUrl: customApiUrl ?? this.customApiUrl, customApiKey: customApiKey ?? this.customApiKey, - customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, - customApiIncludeContact: customApiIncludeContact ?? this.customApiIncludeContact, + customApiDisclaimerAccepted: + customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: + customApiIncludeContact ?? this.customApiIncludeContact, ); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f56db05d..d96125b1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -30,7 +31,8 @@ import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; -import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; +import '../services/meshcore/packet_validator.dart' + show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; @@ -46,10 +48,13 @@ import '../utils/debug_logger_io.dart'; enum AutoMode { /// Active Mode: Sends pings on movement, listens for RX responses active, + /// Passive Mode: Listening only (no transmit) passive, + /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater targeted, } @@ -61,16 +66,22 @@ enum OverlayPingType { tx, disc, trace, rx } enum OfflineUploadResult { /// Upload completed successfully success, + /// Session file not found notFound, + /// Session data is invalid or empty invalidSession, + /// API authentication failed authFailed, + /// Some pings failed to upload partialFailure, + /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available gpsRequired, } @@ -90,11 +101,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DeviceModelService _deviceModelService; late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); - late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + late final CooldownTimer + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + late final ManualPingCooldownTimer + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) late final AutoPingTimer _autoPingTimer; late final RxWindowTimer _rxWindowTimer; - late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final DiscoveryWindowTimer + _discoveryWindowTimer; // Discovery listening window (Passive Mode) MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -111,8 +125,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; - bool _isAuthError = false; // Track if connection failed due to auth - bool _isNetworkError = false; // Track if connection failed due to network + bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; @@ -125,8 +139,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { GpsStatus _gpsStatus = GpsStatus.permissionDenied; Position? _currentPosition; ({double lat, double lon})? _lastKnownPosition; - DateTime? _lastPositionSaveTime; // Throttle position saves to every 30 seconds - bool _firstGpsLockLogged = false; // Track if we've logged first GPS lock message + DateTime? + _lastPositionSaveTime; // Throttle position saves to every 30 seconds + bool _firstGpsLockLogged = + false; // Track if we've logged first GPS lock message // Device info DeviceModel? _deviceModel; @@ -144,7 +160,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The device name to display (prefers SelfInfo name over BLE advertisement name) /// SelfInfo name reflects user's chosen name in MeshCore; BLE name may be cached/stale - String? get displayDeviceName => _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + String? get displayDeviceName => + _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); // Ping state PingStats _pingStats = const PingStats(); @@ -177,7 +194,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _traceLogEntries = []; // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _topRepeatersOverlay = []; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -191,8 +209,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { UserPreferences _preferences = const UserPreferences(); // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -228,11 +246,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isCheckingZone = false; // Zone check retry state - String? _zoneCheckError; // Error message from last failed check (null = no error) - String? _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' - int _zoneCheckRetryCountdown = 0; // Seconds until next retry (0 = not counting) - Timer? _zoneCheckRetryTimer; // Fires to trigger the retry - Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown + String? + _zoneCheckError; // Error message from last failed check (null = no error) + String? + _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' + int _zoneCheckRetryCountdown = + 0; // Seconds until next retry (0 = not counting) + Timer? _zoneCheckRetryTimer; // Fires to trigger the retry + Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown // Maintenance mode state bool _maintenanceMode = false; @@ -274,9 +295,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry bool _isInZoneGracePeriod = false; - Timer? _zoneGraceTimer; // 5-minute overall timeout - Timer? _zoneGracePollingTimer; // 5-second zone polling - Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick int _zoneGraceSecondsRemaining = 0; bool _autoPingWasEnabledBeforeGrace = false; AutoMode _autoModeBeforeGrace = AutoMode.active; @@ -311,10 +332,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _scope; // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) - int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + int? + _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = + false; // True if user manually changed hopBytes while connected + int _hopBytes = + 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = + 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; @@ -359,7 +384,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; - bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; + bool get isBluetoothOff => + _bluetoothAdapterState == BluetoothAdapterState.off; GpsStatus get gpsStatus => _gpsStatus; Position? get currentPosition => _currentPosition; ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; @@ -371,14 +397,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; - bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) - bool get isDiscoveryListening => _pingService?.isDiscoveryListening ?? false; // True during discovery listening window (for Passive Mode) + bool get isPingInProgress => + _pingService?.pingInProgress ?? + false; // True during entire ping + RX window (for auto pings) + bool get isDiscoveryListening => + _pingService?.isDiscoveryListening ?? + false; // True during discovery listening window (for Passive Mode) /// Check if auto-ping disable is pending (waiting for RX window) bool get isPendingDisable => _pingService?.pendingDisable ?? false; + /// True when running any mode that does TX (Active or Hybrid) - bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + bool get isTxModeRunning => + _autoPingEnabled && + (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) - bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + bool get isTargetedModeRunning => + _autoPingEnabled && _autoMode == AutoMode.targeted; String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; @@ -389,13 +424,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get rxPings => List.unmodifiable(_rxPings); /// Top 3 repeaters by best SNR from TX/DISC/Trace pings - List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + List<({String repeaterId, double snr, OverlayPingType type})> + get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. /// Replaces all 3 slots entirely (no carryover from previous pings). - void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + void _updateTopRepeaters( + List<({String repeaterId, double snr})> current, OverlayPingType type) { final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -419,7 +457,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { + _rxOverlayWindowTimer = + Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared }); } @@ -432,21 +471,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; } + List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get traceLogEntries => List.unmodifiable(_traceLogEntries); - List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get traceLogEntries => + List.unmodifiable(_traceLogEntries); + List get errorLogEntries => + List.unmodifiable(_errorLogEntries); List get unifiedPingLogEntries { final merged = [ - ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), - ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), - ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), - ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ..._txLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.trace, timestamp: e.timestamp, entry: e)), ]; merged.sort(); return merged; } + ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; @@ -476,7 +523,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; String? get nearestZoneCode => _nearestZone?['code'] as String?; - double? get nearestZoneDistanceKm => (_nearestZone?['distance_km'] as num?)?.toDouble(); + double? get nearestZoneDistanceKm => + (_nearestZone?['distance_km'] as num?)?.toDouble(); // Zone check retry getters String? get zoneCheckError => _zoneCheckError; @@ -546,11 +594,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; bool get enforceHybrid => _apiService.enforceHybrid; bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + bool get discDropEnabled => + _preferences.discDropEnabled || _apiService.enforceDiscDrop; int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get effectiveHopBytes => + enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; @@ -573,11 +623,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Countdown timers - CooldownTimer get cooldownTimer => _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + CooldownTimer get cooldownTimer => + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) AutoPingTimer get autoPingTimer => _autoPingTimer; RxWindowTimer get rxWindowTimer => _rxWindowTimer; - DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + DiscoveryWindowTimer get discoveryWindowTimer => + _discoveryWindowTimer; // Discovery listening window (Passive Mode) // ============================================ // Initialization @@ -596,11 +649,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize custom API forwarding service _customApiService = CustomApiService(prefsGetter: () => _preferences); _customApiService.onError = (message) { - logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Custom API: $message', + severity: ErrorSeverity.warning, autoSwitch: false); }; _customApiService.contactGetter = () { final pk = _devicePublicKey; - return (pk != null && pk.length >= 8) ? pk.substring(0, 8).toUpperCase() : null; + return (pk != null && pk.length >= 8) + ? pk.substring(0, 8).toUpperCase() + : null; }; _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; @@ -622,7 +678,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize countdown timers with notifyListeners callback for smooth UI updates _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = ManualPingCooldownTimer(onUpdate: notifyListeners); + _manualPingCooldownTimer = + ManualPingCooldownTimer(onUpdate: notifyListeners); _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); @@ -650,9 +707,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -666,7 +727,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); - debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); + debugLog( + '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); // Schedule overlay tile refresh after server has time to regenerate tiles @@ -709,7 +771,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth adapter state changes (on/off) debugLog('[INIT] Setting up Bluetooth adapter state listener...'); - _adapterStateSubscription = _bluetoothService.adapterStateStream.listen((state) { + _adapterStateSubscription = + _bluetoothService.adapterStateStream.listen((state) { final previousState = _bluetoothAdapterState; _bluetoothAdapterState = state; @@ -725,7 +788,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); await _connectionSubscription?.cancel(); - _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { + _connectionSubscription = + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -735,7 +799,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isInZoneGracePeriod) { // BLE disconnected during zone grace period — abandon grace, full cleanup - debugLog('[CONN] BLE disconnect during zone grace period — full cleanup'); + debugLog( + '[CONN] BLE disconnect during zone grace period — full cleanup'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; @@ -743,14 +808,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingWasEnabledBeforeGrace = false; await _fullDisconnectCleanup(); } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { - debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); + debugLog( + '[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { // Normal disconnect (user-requested or no remembered device) await _fullDisconnectCleanup(); } else { // Disconnected during a reconnect attempt - _attemptReconnect handles retry - debugLog('[CONN] BLE disconnect during reconnect attempt - will retry'); + debugLog( + '[CONN] BLE disconnect during reconnect attempt - will retry'); } } notifyListeners(); @@ -769,23 +836,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Log when we transition to locked state (permission granted + GPS available) if (status == GpsStatus.locked) { - debugLog('[GPS] GPS lock acquired - zone check should trigger on first position'); + debugLog( + '[GPS] GPS lock acquired - zone check should trigger on first position'); } // Log when permission is denied or GPS disabled if (status == GpsStatus.permissionDenied) { - debugLog('[GPS] Location permission denied - zone checks will be blocked'); + debugLog( + '[GPS] Location permission denied - zone checks will be blocked'); } else if (status == GpsStatus.disabled) { - debugLog('[GPS] Location services disabled - zone checks will be blocked'); + debugLog( + '[GPS] Location services disabled - zone checks will be blocked'); } } notifyListeners(); }); - _gpsStatus = _gpsService.status; // Sync initial status + _gpsStatus = _gpsService.status; // Sync initial status debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); await _gpsPositionSubscription?.cancel(); - _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { + _gpsPositionSubscription = + _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -798,7 +869,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] First GPS lock, triggering zone check'); await checkZoneStatus(); _firstGpsLockLogged = true; - } else if (_inZone == null && _preferences.offlineMode && !_firstGpsLockLogged) { + } else if (_inZone == null && + _preferences.offlineMode && + !_firstGpsLockLogged) { debugLog('[GEOFENCE] First GPS lock skipped: offline mode enabled'); _firstGpsLockLogged = true; } @@ -806,14 +879,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check zone every 100m movement (while disconnected) // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled - if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { + if (!isConnected && + !_preferences.offlineMode && + _shouldRecheckZone(position)) { // Throttle log to once per 30s to avoid spam while driving final now = DateTime.now(); - if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_lastZoneCheckLogTime == null || + now.difference(_lastZoneCheckLogTime!) >= + const Duration(seconds: 30)) { if (_zoneCheckSuppressedCount > 0) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); } else { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); } _lastZoneCheckLogTime = now; _zoneCheckSuppressedCount = 0; @@ -857,15 +936,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'isCheckingZone=$_isCheckingZone, hasPosition=${_currentPosition != null}'); await _gpsService.startWatching(); - _gpsStatus = _gpsService.status; // Sync after restart + _gpsStatus = _gpsService.status; // Sync after restart debugLog('[GPS] GPS restarted, new status: $_gpsStatus'); - debugLog('[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}'); // If we now have a position and zone hasn't been checked, trigger check - if (_currentPosition != null && _inZone == null && !_preferences.offlineMode) { - debugLog('[GPS] Permission granted with existing position - triggering zone check'); + if (_currentPosition != null && + _inZone == null && + !_preferences.offlineMode) { + debugLog( + '[GPS] Permission granted with existing position - triggering zone check'); await checkZoneStatus(); } notifyListeners(); @@ -923,7 +1006,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!isEnabled) { debugLog('[SCAN] Bluetooth still disabled after retries'); - _connectionError = 'Bluetooth is disabled. Please enable Bluetooth and try again.'; + _connectionError = + 'Bluetooth is disabled. Please enable Bluetooth and try again.'; notifyListeners(); return; } @@ -938,21 +1022,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen for discovered devices using subscription so stopScan() can cancel DiscoveredDevice? selectedDevice; final completer = Completer(); - _activeScanSubscription = _bluetoothService.scanForDevices( + _activeScanSubscription = _bluetoothService + .scanForDevices( timeout: const Duration(seconds: 15), - ).listen( + ) + .listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { // Prefer remembered device name (from SelfInfo) over BLE cache var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + if (_rememberedDevice != null && + device.id == _rememberedDevice!.id && device.name != _rememberedDevice!.name) { enrichedDevice = DiscoveredDevice( id: device.id, name: _rememberedDevice!.name, rssi: device.rssi, ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + debugLog( + '[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); } _discoveredDevices.add(enrichedDevice); selectedDevice = enrichedDevice; @@ -1024,7 +1112,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final publicKey = _meshCoreConnection!.devicePublicKey; if (publicKey == null) { debugError('[APP] Cannot request auth: no public key'); - return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; } // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" @@ -1036,7 +1128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + debugLog( + '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1049,16 +1142,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name final deviceName = _isAnonymousRenamed ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection!.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot request auth: could not retrieve device name'); - return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); final result = await _apiService.requestAuth( reason: 'connect', @@ -1067,7 +1167,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1078,7 +1180,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); return { @@ -1115,12 +1218,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); return { 'success': false, 'reason': stage1Reason, @@ -1137,13 +1242,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); return { 'success': false, 'reason': 'registration_failed', - 'message': 'Companion not found in backend and failed to register via API' + 'message': + 'Companion not found in backend and failed to register via API' }; } @@ -1155,7 +1262,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1171,9 +1280,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); return { 'success': false, 'reason': serverReason, @@ -1208,10 +1319,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification @@ -1222,7 +1335,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { + if (deviceName != null && + deviceName.isNotEmpty && + _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } @@ -1240,7 +1355,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for noise floor updates - _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _currentNoiseFloor = noiseFloor; // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); @@ -1248,7 +1364,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for battery updates - _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { _currentBatteryPercent = batteryPercent; notifyListeners(); }); @@ -1261,16 +1378,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update preferences if device model was recognized (for display/API reporting) // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { final device = connectionResult.deviceModel!; _preferences = _preferences.copyWith( powerLevel: device.power, txPower: device.txPower, - autoPowerSet: true, // Indicates power was auto-detected from device model + autoPowerSet: + true, // Indicates power was auto-detected from device model powerLevelSet: false, // Clear stale manual flag from previous session ); notifyListeners(); - debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + debugLog( + '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } // Note: API session acquisition is now handled by the auth callback @@ -1287,7 +1407,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update unified RX handler's validator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -1301,7 +1422,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } @@ -1310,7 +1432,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Any other value (e.g., "ottawa") → derive TransportKey and set scope final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -1337,8 +1460,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); } // Configure multi-byte path hash mode on radio @@ -1363,7 +1488,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { shouldIgnoreRepeater: (String repeaterId) { final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); } return false; }, @@ -1377,13 +1503,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // External antenna must be explicitly set (yes or no) before pinging return _preferences.externalAntennaSet; }; - + _pingService!.checkPowerLevelConfigured = () { // Power is configured if: // - Auto-detected from device model, OR // - Manually selected by user, OR // - Device model is known (has default power) - return _preferences.autoPowerSet || _preferences.powerLevelSet || _deviceModel != null; + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; }; // Get external antenna value for API payloads @@ -1450,9 +1578,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1465,14 +1597,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events if (_txLogEntries.isNotEmpty) { final lastEntry = _txLogEntries.last; // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); if (timeDiff <= 10) { // Build updated events list final existingEvents = List.from(lastEntry.events); @@ -1489,7 +1623,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _audioService.playReceiveSound(); } else { // Update existing event's SNR - final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); if (idx >= 0) { existingEvents[idx] = newEvent; } @@ -1504,19 +1639,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: existingEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); // Update top repeaters overlay with current TX echoes - _updateTopRepeaters(existingEvents - .where((e) => e.snr != null) - .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), OverlayPingType.tx); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); } else { - debugLog('[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } } else { debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); @@ -1533,7 +1673,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Track idle time for auto-stop if (skipReason != null) { // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { final elapsed = DateTime.now().difference(_idleAutoStopReference!); if (elapsed >= _autoStopIdleTimeout) { _triggerIdleAutoStop(); @@ -1552,15 +1693,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up real-time disc node discovery callback (like onEchoReceived) _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog('[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); if (isNew) { _audioService.playReceiveSound(); } // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters(discPing.discoveredNodes - .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), OverlayPingType.disc); + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); notifyListeners(); }; @@ -1577,11 +1722,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTx.latitude; lon = lastTx.longitude; if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )).toList(); + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } } @@ -1606,12 +1753,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastDisc.latitude; lon = lastDisc.longitude; if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes.map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )).toList(); + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); } } @@ -1648,11 +1797,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTrace.latitude; lon = lastTrace.longitude; if (result != null && result.success) { - repeaters = [MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - )]; + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; // Update the log entry with success data _traceLogEntries[0] = TraceLogEntry( timestamp: lastTrace.timestamp, @@ -1681,7 +1832,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }; @@ -1735,20 +1887,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + final realName = _isAnonymousRenamed + ? (_originalDeviceName ?? selfInfoName) + : selfInfoName; if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName)); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } } } // Restore per-device antenna preference if previously saved // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( externalAntenna: savedAntenna, @@ -1756,12 +1914,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _antennaRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); notifyListeners(); } // Restore per-device power override if previously saved - if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { final saved = _devicePowerOverrides[resolvedName]!; _preferences = _preferences.copyWith( powerLevel: (saved['powerLevel'] as num).toDouble(), @@ -1771,7 +1931,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _powerRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); notifyListeners(); } @@ -1780,7 +1941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (txAllowed && rxAllowed) { debugLog('[CONN] Connected with full access (TX + RX allowed)'); } else if (rxAllowed) { - debugLog('[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { debugLog('[CONN] Connected with limited access'); } @@ -1818,7 +1980,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (validation != PingValidation.valid) { debugLog('[CONN] Ping validation after connect: $validation'); } - } catch (e) { debugError('[APP] Connection failed: $e'); @@ -1849,7 +2010,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (parts.length > 1) { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { @@ -1859,7 +2021,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAuthError = false; _isNetworkError = false; // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; } else { _connectionError = errorStr.replaceFirst('Exception: ', ''); @@ -1879,8 +2043,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); + _txTracker!.carpeaterPrefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog( + '[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1893,16 +2059,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + carpeaterPrefix: + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { - debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' + debugLog( + '[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging - debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); + debugLog( + '[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); // Check if repeater already has a pin in CURRENT BATCH (not all-time) // This allows new pins after batch flushes (25m movement) @@ -1924,7 +2093,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Increment RX count immediately when pin is created (not on batch flush) _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); - debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' + debugLog( + '[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); // Update RX overlay slot immediately @@ -1948,7 +2118,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); notifyListeners(); } else { - debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); + debugLog( + '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); } } catch (e, stackTrace) { debugError('[APP] Error in immediate observation callback: $e'); @@ -1961,7 +2132,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onRxEntry: (entry) async { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); - debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' + debugLog( + '[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1980,20 +2152,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + final shouldUpdateSnr = + entry.snr != null && entry.snr! > existingPin.snr; if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( - latitude: existingPin.latitude, // KEEP batch start location + latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + snr: entry.snr ?? + existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, ); - debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { - debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { @@ -2008,7 +2184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' + debugLog( + '[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -2080,7 +2257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { severity: ErrorSeverity.warning, autoSwitch: false); }, ); - + // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; @@ -2091,7 +2268,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); final validator = PacketValidator( allowedChannels: allowedChannels, @@ -2104,15 +2282,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxLogger: _rxLogger!, validator: validator, ); - + // Subscribe to LogRxData stream - _logRxDataSubscription = _meshCoreConnection!.logRxDataStream.listen((data) { + _logRxDataSubscription = + _meshCoreConnection!.logRxDataStream.listen((data) { _unifiedRxHandler!.handlePacket(data.raw, data.snr, data.rssi); }); - + // Start listening _unifiedRxHandler!.startListening(); - + debugLog('[APP] Unified RX handler created and listening'); } @@ -2134,14 +2313,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; _traceHopBytes = 1; } final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) final deviceHopBytes = deviceMode + 1; if (effective != deviceHopBytes && _originalPathHashMode != null) { @@ -2151,7 +2332,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = effective; // Update runtime state to reflect new mode _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2166,13 +2348,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else if (_originalPathHashMode == null && effective > 1) { // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + debugWarn( + '[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: effective, reason: 'firmware_unsupported'); notifyListeners(); } } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + debugLog( + '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); } } @@ -2182,7 +2367,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) return; if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + debugLog( + '[PATH] User manually changed path mode, not restoring on disconnect'); _originalPathHashMode = null; _userChangedPathMode = false; return; @@ -2195,12 +2381,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_hopBytes != originalHopBytes) { try { await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + debugLog( + '[PATH] Restored path hash mode to original: $originalHopBytes-byte'); } catch (e) { debugError('[PATH] Failed to restore path hash mode: $e'); } } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + debugLog( + '[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); } _originalPathHashMode = null; _userChangedPathMode = false; @@ -2211,7 +2399,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) { // Old firmware — can't send command, show warning debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: newHopBytes, reason: 'firmware_unsupported'); _hopBytes = 1; // Force back to 1 notifyListeners(); return; @@ -2230,7 +2419,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + debugLog( + '[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -2261,7 +2451,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => + _pendingPathHashWarning; /// Clear the pending warning after UI has shown it void clearPathHashWarning() { @@ -2406,7 +2597,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService = null; // Do NOT release API session or clear API queue - debugLog('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); + debugLog( + '[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); notifyListeners(); @@ -2423,40 +2615,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Attempt a single reconnection void _attemptReconnect() { if (_reconnectAttempt >= _maxReconnectAttempts) { - debugLog('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); + debugLog( + '[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); _abandonAutoReconnect(); return; } _reconnectAttempt++; - debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); + debugLog( + '[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); // Use longer delay after bond errors to give iOS time to clear stale keys - final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + final delay = _lastReconnectWasBondError + ? _reconnectDelayAfterBondError + : _reconnectDelay; // Delay before attempting reconnection _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { - debugLog('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); + debugLog( + '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { - debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + debugLog( + '[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again - debugLog('[CONN] Auto-reconnect: connection did not complete, retrying...'); + debugLog( + '[CONN] Auto-reconnect: connection did not complete, retrying...'); _connectionStep = ConnectionStep.reconnecting; notifyListeners(); _attemptReconnect(); } } catch (e) { - debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); + debugError( + '[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { // Check for iOS apple-code 14 (Peer removed pairing information) // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying @@ -2479,10 +2679,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { if (!isConnected || _autoPingEnabled) return; debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); - logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + logError('Disconnected: 15 minutes of inactivity', + severity: ErrorSeverity.warning); disconnect(); }); - debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + debugLog( + '[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); } /// Cancel the idle disconnect timer @@ -2497,11 +2699,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry Future _handleBondErrorIfNeeded(Object error) async { final errorStr = error.toString(); - if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + if (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')) { _lastReconnectWasBondError = true; final deviceId = _rememberedDevice?.id; if (deviceId != null) { - debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + debugLog( + '[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); await _bluetoothService.removeBond(deviceId); } } @@ -2523,7 +2728,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - debugLog('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); + debugLog( + '[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); // Restore auto-ping if it was active if (wasAutoPing) { @@ -2537,13 +2743,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + debugLog( + '[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { toggleAutoPing(previousMode); - debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); + debugLog( + '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); } else { @@ -2582,7 +2790,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); // Reset anonymous mode state (BLE already gone, can't restore name) @@ -2671,11 +2880,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed && _originalDeviceName != null) { try { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + debugLog( + '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); + logError( + 'Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, + autoSwitch: false); } _isAnonymousRenamed = false; _originalDeviceName = null; @@ -2709,7 +2921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); - + // Cancel stream subscriptions await _noiseFloorSubscription?.cancel(); _noiseFloorSubscription = null; @@ -2730,7 +2942,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _displayDeviceName = null; _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; @@ -2830,7 +3043,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { - debugWarn('[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); + debugWarn( + '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors return false; } @@ -2851,7 +3065,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); - logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Auto-ping stopped: no movement for 30 minutes', + severity: ErrorSeverity.warning, autoSwitch: false); _idleAutoStopReference = null; toggleAutoPing(_autoMode); } @@ -2919,7 +3134,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Passive Mode is listening only, no cooldown needed if (isTxMode) { _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2936,7 +3152,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Block starting if shared cooldown is active (TX modes only) // Passive Mode is listening only and can start during cooldown if (isTxMode && _cooldownTimer.isRunning) { - debugLog('[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); return false; } @@ -2967,7 +3184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Set interval from user preferences before starting final intervalMs = _preferences.autoPingInterval * 1000; _pingService!.setAutoPingInterval(intervalMs); - debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); final started = await _pingService!.enableAutoPing( passiveMode: isPassive, @@ -2978,7 +3196,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { - debugLog('[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); } else { debugLog('[PING] Auto mode start blocked'); } @@ -2991,7 +3210,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -3011,7 +3236,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -3050,7 +3281,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } - debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); + debugLog( + '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); notifyListeners(); } @@ -3060,14 +3292,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_traceLogEntries.length > _maxLogEntries) { _traceLogEntries.removeLast(); } - debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + debugLog( + '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); // Update top repeaters overlay with successful trace result if (entry.success && entry.localSnr != null) { // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace); } notifyListeners(); @@ -3075,13 +3309,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab - void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { + void logError(String message, + {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { _errorLogEntries.add(UserErrorEntry( timestamp: DateTime.now(), message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) _errorLogEntries.removeAt(0); + if (_errorLogEntries.length > _maxErrorEntries) { + _errorLogEntries.removeAt(0); + } if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3129,9 +3366,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Hot-switch while connected - return enabled - ? await _switchToOfflineMode() - : await _switchToOnlineMode(); + return enabled ? await _switchToOfflineMode() : await _switchToOnlineMode(); } /// Simple offline mode change (when not connected) @@ -3158,7 +3393,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); + debugLog( + '[GEOFENCE] Re-checking zone status after offline mode disabled'); checkZoneStatus(); } } @@ -3257,13 +3493,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot switch to online mode: no device name available'); + debugError( + '[APP] Cannot switch to online mode: no device name available'); _modeSwitchError = 'Device name not available'; return (success: false, error: _modeSwitchError); } if (_devicePublicKey == null) { - debugError('[APP] Cannot switch to online mode: no public key available'); + debugError( + '[APP] Cannot switch to online mode: no public key available'); _modeSwitchError = 'Device public key not available'; return (success: false, error: _modeSwitchError); } @@ -3280,17 +3518,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (zoneCode == null) { debugError('[APP] Cannot switch to online mode: not in a zone'); - _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + _modeSwitchError = + 'Could not determine your zone. Check GPS and internet connection.'; return (success: false, error: _modeSwitchError); } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; var result = await _apiService.requestAuth( reason: 'connect', @@ -3310,10 +3551,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); - _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; + _modeSwitchError = + _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } @@ -3333,11 +3576,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } else { // Stage 1 failed — check if Stage 2 is worth attempting - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); _modeSwitchError = result['message'] as String? ?? 'GPS error'; return (success: false, error: _modeSwitchError); } @@ -3351,10 +3596,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); - _modeSwitchError = 'Companion not found in backend and failed to register via API'; + _modeSwitchError = + 'Companion not found in backend and failed to register via API'; return (success: false, error: _modeSwitchError); } @@ -3378,9 +3625,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); _modeSwitchError = serverMessage ?? 'Registration rejected by server'; return (success: false, error: _modeSwitchError); } @@ -3509,7 +3758,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Note: Connection already validates device name exists, so this should never be null final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, @@ -3525,14 +3775,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) { + return; + } final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); _offlineSessionService.updateCurrentSession( pings, @@ -3582,7 +3835,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (success) { // Delete the session file on successful upload await _offlineSessionService.deleteSession(filename); - debugLog('[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); + debugLog( + '[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); } else { debugError('[API] Failed to upload offline session: $filename'); } @@ -3607,7 +3861,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }) async { // Concurrency guard — only one offline upload at a time if (_isUploadingOfflineSession) { - debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + debugWarn( + '[OFFLINE] Upload already in progress, rejecting concurrent request'); return OfflineUploadResult.uploadInProgress; } @@ -3615,7 +3870,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); try { - return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + return await _uploadOfflineSessionIsolated(filename, + onProgress: onProgress); } finally { _isUploadingOfflineSession = false; notifyListeners(); @@ -3662,13 +3918,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 3. Check GPS before auth — the server requires current coordinates for geo-auth if (_currentPosition == null) { - debugError('[OFFLINE] Upload requires GPS - location services not available'); + debugError( + '[OFFLINE] Upload requires GPS - location services not available'); return OfflineUploadResult.gpsRequired; } // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. - debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); + debugLog( + '[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3697,7 +3955,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stage 2: If unknown_device and we have a stored contactUri, attempt registration if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + debugLog( + '[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); final registerResult = await _apiService.requestAuth( reason: 'register', contactUri: session.contactUri, @@ -3719,7 +3978,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + debugLog( + '[OFFLINE] Stage 2 succeeded: device registered for offline upload'); effectiveAuth = registerResult; } else { debugError('[OFFLINE] Auth failed: $reason'); @@ -3734,7 +3994,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); + debugLog( + '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); @@ -3750,7 +4011,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + final result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); @@ -3779,7 +4041,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn( + '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -3803,7 +4066,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { - debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' + debugLog( + '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); _preferences = preferences; @@ -3813,26 +4077,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final deviceName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); - debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + debugLog( + '[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } // Persist power override per device name - if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + if (deviceName != null && + preferences.powerLevelSet && + !preferences.autoPowerSet) { _devicePowerOverrides[deviceName] = { 'powerLevel': preferences.powerLevel, 'txPower': preferences.txPower, }; _saveDevicePowerOverrides(); - debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + debugLog( + '[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); } else if (deviceName != null && preferences.autoPowerSet) { // User re-selected the auto-detected value — clear any saved override if (_devicePowerOverrides.remove(deviceName) != null) { _saveDevicePowerOverrides(); - debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + debugLog( + '[APP] Cleared power override for "$deviceName" (auto-detected selected)'); } } @@ -3843,7 +4113,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _syncCarpeaterPrefix(); // Propagate min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; notifyListeners(); @@ -3859,7 +4130,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + if (_connectionStatus == ConnectionStatus.connected && + _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { _requestConnectionTabSwitch = true; @@ -3874,7 +4146,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Propagate carpeaterPrefix to live TxTracker and RxLogger void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + final prefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; if (_txTracker != null) { _txTracker!.carpeaterPrefix = prefix; debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); @@ -3931,7 +4204,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setColorVisionType(String type) { _preferences = _preferences.copyWith(colorVisionType: type); PingColors.setColorVisionType( - ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ColorVisionType.values.firstWhere((e) => e.name == type, + orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); notifyListeners(); @@ -4012,7 +4286,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { + return; + } debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } @@ -4096,13 +4372,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { - debugWarn('[API] Rate limited - continuing without disconnect: $userMessage'); + debugWarn( + '[API] Rate limited - continuing without disconnect: $userMessage'); return; } // Zone grace period: intercept outside_zone during active session if (reason == 'outside_zone' && _isInZoneGracePeriod) { - debugLog('[ZONE GRACE] outside_zone during grace period — already handling'); + debugLog( + '[ZONE GRACE] outside_zone during grace period — already handling'); return; } if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { @@ -4158,7 +4436,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, ); - debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + debugLog( + '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); } } catch (e) { debugError('[APP] Failed to preserve queue to offline storage: $e'); @@ -4172,7 +4451,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Handle maintenance mode while connected - end session and log error - Future _handleMaintenanceModeConnected(String message, String? url) async { + Future _handleMaintenanceModeConnected( + String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); // Alert if auto-ping was running (maintenance is not user-initiated) @@ -4181,7 +4461,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Log to error log (this sets _requestErrorLogSwitch = true) - logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); + logError('Maintenance Mode Enabled: $message', + severity: ErrorSeverity.warning); // Disconnect (ends session, cleans up) await disconnect(); @@ -4212,7 +4493,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start periodic polling to check if maintenance mode has ended void _startMaintenancePolling() { _maintenanceCheckTimer?.cancel(); - _maintenanceCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + _maintenanceCheckTimer = + Timer.periodic(const Duration(seconds: 30), (_) async { if (!_maintenanceMode) { _maintenanceCheckTimer?.cancel(); _maintenanceCheckTimer = null; @@ -4242,7 +4524,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Validate GPS position for API calls /// Returns (isValid, errorMessage, errorCode) tuple - ({bool isValid, String? errorMessage, String? errorCode}) _validateGps(Position? position) { + ({bool isValid, String? errorMessage, String? errorCode}) _validateGps( + Position? position) { if (position == null) { return ( isValid: false, @@ -4256,7 +4539,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ageSeconds > _maxGpsAgeSeconds) { return ( isValid: false, - errorMessage: 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', + errorMessage: + 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', errorCode: 'gps_stale', ); } @@ -4265,7 +4549,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (position.accuracy > _maxGpsAccuracyMeters) { return ( isValid: false, - errorMessage: 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', + errorMessage: + 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', errorCode: 'gps_inaccurate', ); } @@ -4304,7 +4589,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Schedule a zone check retry with countdown timer for UI feedback - void _scheduleZoneCheckRetry({required int seconds, required String error, required String reason}) { + void _scheduleZoneCheckRetry( + {required int seconds, required String error, required String reason}) { // Cancel any existing timers _zoneCheckRetryTimer?.cancel(); _zoneCheckCountdownTimer?.cancel(); @@ -4345,11 +4631,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Should be called on app launch and every 100m of GPS movement while disconnected Future checkZoneStatus() async { debugLog('[GEOFENCE] checkZoneStatus() called'); - debugLog('[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); if (_currentPosition == null) { - debugLog('[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); + debugLog( + '[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); return; } @@ -4359,18 +4647,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (_isCheckingZone) { - debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); + debugLog( + '[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; } - debugLog('[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); + debugLog( + '[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); _isCheckingZone = true; // Don't clear error or notify here — keep current error view visible during retry // to avoid a full-screen flash. Error is cleared in finally block on success, // or overwritten by _scheduleZoneCheckRetry on failure. try { - debugLog('[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); final result = await _apiService.checkZoneStatus( @@ -4380,7 +4671,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, ); - debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); + debugLog( + '[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { // Update position even on failure to prevent zone check flooding @@ -4403,7 +4695,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); // Start polling to detect when maintenance ends _startMaintenancePolling(); @@ -4420,8 +4713,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final success = result['success'] == true; if (!success) { final reason = result['reason'] as String?; - final message = result['message'] as String? ?? 'Zone status check failed'; - debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + final message = + result['message'] as String? ?? 'Zone status check failed'; + debugError( + '[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); @@ -4436,14 +4731,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 30, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 30, error: errorMsg, reason: reason!); } else if (reason == 'bad_key' || reason == 'invalid_request') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 60, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 60, error: errorMsg, reason: reason!); } else { // Unknown server errors — use server message - _scheduleZoneCheckRetry(seconds: 15, error: message, reason: 'server_error'); + _scheduleZoneCheckRetry( + seconds: 15, error: message, reason: 'server_error'); } return; @@ -4475,14 +4773,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check + _fetchRepeatersForZone( + newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; - final distanceKm = (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; - debugWarn('[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + final distanceKm = + (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; + debugWarn( + '[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); // Clear repeaters when exiting zone _repeaters = []; @@ -4493,7 +4794,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check error: $e'); } finally { _isCheckingZone = false; - debugLog('[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); notifyListeners(); } @@ -4509,11 +4811,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth response includes slot data, use it directly (forward-compatible) if (authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = authResult['slots_available']; - debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + debugLog( + '[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); } if (authResult.containsKey('slots_max')) { _currentZone!['slots_max'] = authResult['slots_max']; - debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + debugLog( + '[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); } // Sync at_capacity with tx_allowed @@ -4523,7 +4827,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth says TX not allowed and server didn't provide slot data, set slots to 0 if (!authTxAllowed && !authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = 0; - debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + debugLog( + '[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); } // If auth says TX allowed and we have slot data but server didn't provide updated count, @@ -4581,8 +4886,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _startZoneGracePeriod() async { if (_isInZoneGracePeriod) return; _isInZoneGracePeriod = true; - debugLog('[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); - logError('Left wardriving zone. Searching for nearby zone...', severity: ErrorSeverity.warning, autoSwitch: false); + debugLog( + '[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', + severity: ErrorSeverity.warning, autoSwitch: false); // Save auto-ping state for restoration on zone re-entry _autoPingWasEnabledBeforeGrace = _autoPingEnabled; @@ -4664,18 +4971,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + debugLog( + '[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume if (_sessionZoneCode != null && reEnteredZoneCode.isNotEmpty && reEnteredZoneCode != _sessionZoneCode) { - debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + debugLog( + '[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + await _handleZoneTransfer( + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); return; } @@ -4696,8 +5006,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - debugLog('[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); - logError('Re-entered wardriving zone. Resuming...', severity: ErrorSeverity.info, autoSwitch: false); + debugLog( + '[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', + severity: ErrorSeverity.info, autoSwitch: false); // Re-enable heartbeat _apiService.enableHeartbeat( @@ -4723,7 +5035,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -4770,7 +5083,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle zone-to-zone transfer during active wardriving session. /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. - Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + Future _handleZoneTransfer( + String newZoneCode, String newZoneName) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -4833,7 +5147,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + if (_devicePublicKey == null || + deviceName == null || + _currentPosition == null) { debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); await disconnect(); return; @@ -4848,7 +5164,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4857,7 +5174,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 10. Check auth result if (result == null) { debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); - logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + logError('Zone transfer failed: unable to reach server', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4875,8 +5193,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (result['success'] != true) { final reason = result['reason'] as String? ?? 'unknown'; final message = result['message'] as String? ?? 'Auth failed'; - debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + debugError( + '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4899,7 +5219,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 13. Update PacketValidator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -4913,13 +5234,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + debugLog( + '[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); } // 14. Update flood scope from new auth response final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -4948,8 +5271,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); } // 16. Reconfigure path hash mode if new zone requires different hop bytes @@ -4988,7 +5313,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -5041,7 +5367,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); notifyListeners(); } else { - debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + debugWarn( + '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); @@ -5292,7 +5619,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load a route file (KML or GPX) bool loadSimulatorRoute(String content, {String? filename}) { - final success = _gpsService.simulator.loadRoute(content, filename: filename); + final success = + _gpsService.simulator.loadRoute(content, filename: filename); if (success) { _gpsSimulatorPattern = SimulatorPattern.route; // If simulator is running, it will automatically use the new route @@ -5351,7 +5679,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Attempt to recover from Hive corruption - Future?> _attemptHiveRecovery(String boxName, Duration timeout) async { + Future?> _attemptHiveRecovery( + String boxName, Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -5365,7 +5694,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return box; } catch (e) { debugError('[HIVE] Recovery failed for "$boxName": $e'); - logError('Storage for "$boxName" unavailable - some settings may not persist'); + logError( + 'Storage for "$boxName" unavailable - some settings may not persist'); return null; } } @@ -5381,7 +5711,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('device'); if (json != null) { - _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); + _rememberedDevice = + RememberedDevice.fromJson(Map.from(json)); debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); notifyListeners(); } @@ -5466,13 +5797,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('preferences'); if (json != null) { - _preferences = UserPreferences.fromJson(Map.from(json)); - debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' + _preferences = + UserPreferences.fromJson(Map.from(json)); + debugLog( + '[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); // Apply saved min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; // Apply saved color vision type @@ -5516,7 +5850,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_antenna_preferences'); if (raw != null) { _deviceAntennaPreferences = Map.from(raw as Map); - debugLog('[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); + debugLog( + '[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device antenna preferences: $e'); @@ -5548,9 +5883,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_power_overrides'); if (raw != null) { _devicePowerOverrides = (raw as Map).map( - (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + (key, value) => + MapEntry(key.toString(), Map.from(value as Map)), ); - debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + debugLog( + '[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device power overrides: $e'); @@ -5579,10 +5916,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (box == null) return; try { - _lastConnectedDeviceName = box.get('last_connected_device_name') as String?; + _lastConnectedDeviceName = + box.get('last_connected_device_name') as String?; _lastConnectedPublicKey = box.get('last_connected_public_key') as String?; if (_lastConnectedDeviceName != null) { - debugLog('[APP] Loaded last connected device: $_lastConnectedDeviceName'); + debugLog( + '[APP] Loaded last connected device: $_lastConnectedDeviceName'); } } catch (e) { debugLog('[APP] Failed to load last connected device: $e'); @@ -5590,7 +5929,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save last connected device info to Hive storage - Future _saveLastConnectedDevice(String deviceName, String publicKey) async { + Future _saveLastConnectedDevice( + String deviceName, String publicKey) async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; @@ -5681,34 +6021,45 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[HIVE] Opening typed box "$_noiseFloorSessionBoxName"...'); try { - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); return box; } on TimeoutException { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } catch (e) { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } } /// Attempt to recover from Hive corruption for noise floor box - Future?> _attemptNoiseFloorBoxRecovery(Duration timeout) async { + Future?> _attemptNoiseFloorBoxRecovery( + Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$_noiseFloorSessionBoxName"...'); await Hive.deleteBoxFromDisk(_noiseFloorSessionBoxName); debugLog('[HIVE] Retrying open...'); // Notify user that cleanup happened - logError('Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); - - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); + + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); return box; } catch (e) { debugError('[HIVE] Recovery failed for "$_noiseFloorSessionBoxName": $e'); - logError('Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); return null; } } @@ -5724,7 +6075,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _storedNoiseFloorSessions = _noiseFloorSessionBox!.values.toList() ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Newest first - debugLog('[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); + debugLog( + '[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); } catch (e) { debugError('[GRAPH] Failed to load noise floor sessions: $e'); _storedNoiseFloorSessions = []; @@ -5787,7 +6139,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_currentNoiseFloorSession == null) return; _currentNoiseFloorSession!.endTime = DateTime.now(); - debugLog('[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' + debugLog( + '[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' '${_currentNoiseFloorSession!.samples.length} samples, ' '${_currentNoiseFloorSession!.markers.length} markers'); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 2ebb0da3..5e6b9a79 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -24,7 +24,8 @@ class ConnectionScreen extends StatefulWidget { State createState() => _ConnectionScreenState(); } -class _ConnectionScreenState extends State with WidgetsBindingObserver { +class _ConnectionScreenState extends State + with WidgetsBindingObserver { @override void initState() { super.initState(); @@ -125,7 +126,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (pathWarning != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + _showPathHashWarning( + context, pathWarning.hopBytes, pathWarning.reason); appState.clearPathHashWarning(); }); } @@ -234,10 +236,12 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { + Widget _buildConnectionProgress( + BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -272,7 +276,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildZoneGraceView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final nearestName = appState.nearestZoneName; final nearestDistance = appState.nearestZoneDistanceKm; final hasNearestInfo = nearestName != null && nearestDistance != null; @@ -299,7 +304,10 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -322,14 +330,20 @@ class _ConnectionScreenState extends State with WidgetsBinding height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), const SizedBox(width: 8), Text( 'Searching for zone...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), ], @@ -346,8 +360,10 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildZoneTransferView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildZoneTransferView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final from = appState.zoneTransferFrom ?? '?'; final to = appState.zoneTransferTo ?? '?'; @@ -368,7 +384,10 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( '$from → $to', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), SizedBox(height: isLandscape ? 8 : 12), @@ -380,14 +399,20 @@ class _ConnectionScreenState extends State with WidgetsBinding height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), const SizedBox(width: 8), Text( 'Re-authenticating...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), ], @@ -404,8 +429,10 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildReconnectingView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; return SafeArea( @@ -425,14 +452,20 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Attempt ${appState.reconnectAttempt} of 3', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), const SizedBox(height: 4), Text( deviceName, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), SizedBox(height: isLandscape ? 16 : 24), @@ -459,7 +492,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (semverMatch != null) { version = semverMatch.group(1); } else { - final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + final nightlyMatch = + RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); if (nightlyMatch != null) { version = nightlyMatch.group(1); } @@ -468,7 +502,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (version == null) { final manufacturerString = appState.manufacturerString; if (manufacturerString != null) { - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final versionRegex = + RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); final match = versionRegex.firstMatch(manufacturerString); if (match != null) { version = match.group(1); @@ -476,12 +511,17 @@ class _ConnectionScreenState extends State with WidgetsBinding } } - final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; + final hardware = appState.deviceModel?.shortName ?? + appState.manufacturerString ?? + 'Unknown'; final platform = appState.deviceModel?.platform; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final prefs = appState.preferences; final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Compact device summary card final deviceSummaryCard = Card( @@ -494,7 +534,8 @@ class _ConnectionScreenState extends State with WidgetsBinding // Header: BT icon + name/status Row( children: [ - const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const Icon(Icons.bluetooth_connected, + color: Colors.green, size: 20), const SizedBox(width: 8), Expanded( child: Column( @@ -503,15 +544,15 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( deviceName, style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), overflow: TextOverflow.ellipsis, ), Text( 'Connected', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.green, - ), + color: Colors.green, + ), ), ], ), @@ -526,8 +567,10 @@ class _ConnectionScreenState extends State with WidgetsBinding runSpacing: 4, children: [ _buildDetailChip(context, Icons.memory, hardware), - if (version != null) _buildDetailChip(context, Icons.code, version), - if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + if (version != null) + _buildDetailChip(context, Icons.code, version), + if (platform != null) + _buildDetailChip(context, Icons.developer_board, platform), ], ), @@ -606,7 +649,9 @@ class _ConnectionScreenState extends State with WidgetsBinding return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: InkWell( - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showPowerLevelSelector(context, appState), borderRadius: BorderRadius.circular(4), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -622,10 +667,15 @@ class _ConnectionScreenState extends State with WidgetsBinding Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + Icon(Icons.bolt, + size: 16, + color: + isPowerSet ? Colors.amber.shade700 : Colors.orange), const SizedBox(width: 4), Text( - isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + isPowerSet + ? prefs.powerLevelDisplay + : 'Unknown - tap to set', style: TextStyle( fontWeight: FontWeight.w500, color: isPowerSet ? null : Colors.orange, @@ -633,7 +683,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ), if (prefs.autoPowerSet) ...[ const SizedBox(width: 4), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const Icon(Icons.auto_awesome, + size: 14, color: Colors.green), const SizedBox(width: 2), const Text( 'Auto', @@ -643,7 +694,9 @@ class _ConnectionScreenState extends State with WidgetsBinding fontWeight: FontWeight.bold, ), ), - ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + ] else if (prefs.powerLevelSet && + !prefs.autoPowerSet && + appState.deviceModel != null) ...[ const SizedBox(width: 4), const Icon(Icons.edit, size: 14, color: Colors.orange), const SizedBox(width: 2), @@ -658,7 +711,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ], if (!isAutoMode) ...[ const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + const Icon(Icons.chevron_right, + size: 16, color: Colors.grey), ], ], ), @@ -695,8 +749,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - - Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -841,17 +893,19 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.verified_user, color: Colors.blue, size: 20), + child: const Icon(Icons.verified_user, + color: Colors.blue, size: 20), ), const SizedBox(width: 12), Expanded( child: Text( 'Registration Methods', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -875,7 +929,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.green, title: 'Mesh', trustLevel: 'Most trusted', - description: 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', + description: + 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', isCurrentType: currentType == 'Mesh', ), const SizedBox(height: 12), @@ -885,7 +940,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.blue, title: 'API', trustLevel: 'Trusted', - description: 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', + description: + 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', isCurrentType: currentType == 'API', ), const SizedBox(height: 12), @@ -895,7 +951,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.orange, title: 'Manual', trustLevel: 'Basic', - description: 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', + description: + 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', isCurrentType: currentType == 'Manual', ), ], @@ -924,7 +981,9 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: isCurrentType ? color.withValues(alpha: 0.1) : null, borderRadius: BorderRadius.circular(8), - border: isCurrentType ? Border.all(color: color.withValues(alpha: 0.4)) : null, + border: isCurrentType + ? Border.all(color: color.withValues(alpha: 0.4)) + : null, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -949,13 +1008,15 @@ class _ConnectionScreenState extends State with WidgetsBinding trustLevel, style: TextStyle( fontSize: 11, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: + theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), if (isCurrentType) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(4), @@ -988,11 +1049,13 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { + void _showPowerLevelSelector( + BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; // Only show selection if power has been set (auto or manual) - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; + final isPowerSet = + prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; final currentPower = isPowerSet ? prefs.powerLevel : null; // Helper to handle power selection with confirmation for overrides @@ -1040,7 +1103,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, - powerLevelSet: true, // Mark as manually set + powerLevelSet: true, // Mark as manually set ), ); Navigator.pop(context); @@ -1061,8 +1124,10 @@ class _ConnectionScreenState extends State with WidgetsBinding padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), - border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange) + .withValues(alpha: 0.1), + border: Border.all( + color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -1097,7 +1162,8 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = deviceModel != null && power == deviceModel.power; + final isRecommended = + deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -1105,10 +1171,12 @@ class _ConnectionScreenState extends State with WidgetsBinding return RadioListTile( title: Row( children: [ - Flexible(child: Text(tempPrefs.powerLevelDisplayWithDbm)), + Flexible( + child: Text(tempPrefs.powerLevelDisplayWithDbm)), if (isRecommended) ...[ const SizedBox(width: 8), - const Icon(Icons.check_circle, size: 16, color: Colors.green), + const Icon(Icons.check_circle, + size: 16, color: Colors.green), ], ], ), @@ -1157,7 +1225,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); Navigator.pop(context); }, - child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + child: const Text('Reset to Auto', + style: TextStyle(color: Colors.green)), ), TextButton( onPressed: () => Navigator.pop(context), @@ -1169,7 +1238,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildError(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -1187,7 +1257,9 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( appState.isNetworkError ? 'Server Unreachable' - : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + : appState.isAuthError + ? 'Authentication Failed' + : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -1226,24 +1298,24 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.gps_off; locationText = '-'; locationColor = Colors.grey; - // Check maintenance mode + // Check maintenance mode } else if (appState.maintenanceMode) { locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; - // Network error: show wifi off indicator + // Network error: show wifi off indicator } else if (appState.zoneCheckErrorReason == 'network') { locationIcon = Icons.wifi_off; locationText = 'No Internet'; locationColor = Colors.red; - // GPS error: show GPS issue indicator + // GPS error: show GPS issue indicator } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || - appState.zoneCheckErrorReason == 'gps_stale') { + appState.zoneCheckErrorReason == 'gps_stale') { locationIcon = Icons.gps_off; locationText = 'GPS Unavailable'; locationColor = Colors.orange; - // Show "Checking Zone..." whenever a zone check is in progress - // This provides consistent UI feedback during both initial and re-checks + // Show "Checking Zone..." whenever a zone check is in progress + // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { locationIcon = Icons.location_searching; locationText = 'Checking Zone...'; @@ -1367,7 +1439,8 @@ class _ConnectionScreenState extends State with WidgetsBinding required String message, Widget? action, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Use Center with CustomScrollView for both vertical centering and scroll capability return Center( @@ -1392,7 +1465,10 @@ class _ConnectionScreenState extends State with WidgetsBinding message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), if (action != null) ...[ @@ -1408,7 +1484,8 @@ class _ConnectionScreenState extends State with WidgetsBinding Widget _buildDeviceList(BuildContext context, AppStateProvider appState) { // Offline mode bypasses both zone and maintenance checks - final canConnect = appState.offlineMode || (appState.inZone == true && !appState.maintenanceMode); + final canConnect = appState.offlineMode || + (appState.inZone == true && !appState.maintenanceMode); // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { @@ -1433,7 +1510,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ), const SizedBox(height: 8), Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + appState.maintenanceMessage ?? + 'Service is temporarily unavailable.', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, @@ -1447,13 +1525,15 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: const Icon(Icons.cloud_off), label: const Text('Enable Offline Mode'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), if (appState.maintenanceUrl != null) ...[ const SizedBox(height: 12), OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + onPressed: () => + _launchMaintenanceUrl(appState.maintenanceUrl!), icon: const Icon(Icons.open_in_new, size: 18), label: const Text('More Info'), ), @@ -1470,12 +1550,14 @@ class _ConnectionScreenState extends State with WidgetsBinding child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), const SizedBox(width: 8), Flexible( child: Text( 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], @@ -1507,8 +1589,10 @@ class _ConnectionScreenState extends State with WidgetsBinding String message = 'Your geo zone is not on-boarded into MeshMapper.'; if (nearestName != null && distKmValue != null) { - final zoneDisplay = nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; - final dist = formatKilometers(distKmValue, isImperial: appState.preferences.isImperial); + final zoneDisplay = + nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; + final dist = formatKilometers(distKmValue, + isImperial: appState.preferences.isImperial); message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } @@ -1578,7 +1662,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: const Icon(Icons.cloud_off), label: const Text('Enable Offline Mode'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), ), ), const SizedBox(height: 32), @@ -1587,17 +1672,20 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), const SizedBox(width: 8), Flexible( child: Text( 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], @@ -1619,13 +1707,15 @@ class _ConnectionScreenState extends State with WidgetsBinding title: appState.zoneCheckErrorReason == 'gps_inaccurate' ? 'GPS Accuracy Error' : 'GPS Stale Error', - message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + message: + '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', action: FilledButton.icon( onPressed: () => appState.checkZoneStatus(), icon: const Icon(Icons.refresh), label: const Text('Retry Zone Check'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ); @@ -1657,7 +1747,9 @@ class _ConnectionScreenState extends State with WidgetsBinding return Column( children: [ const LinearProgressIndicator(), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), + Expanded( + child: _buildDeviceListView(context, appState, + canConnect: canConnect)), ], ); } @@ -1666,7 +1758,8 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); + return _buildRememberedDeviceView(context, appState, remembered, + canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1679,7 +1772,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: Icons.gps_off, iconColor: Colors.red.withValues(alpha: 0.7), title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', + message: + 'Please enable Location Services to verify you\'re in an allowed zone.', action: isIOS ? null : ElevatedButton.icon( @@ -1697,7 +1791,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: Icons.location_off, iconColor: Colors.orange.withValues(alpha: 0.7), title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', + message: + 'Location access is needed to verify you\'re in an allowed zone.', action: ElevatedButton.icon( onPressed: () => _requestLocationPermission(appState), icon: const Icon(Icons.location_on), @@ -1742,7 +1837,8 @@ class _ConnectionScreenState extends State with WidgetsBinding RememberedDevice remembered, { bool canConnect = true, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SingleChildScrollView( child: Center( @@ -1772,7 +1868,9 @@ class _ConnectionScreenState extends State with WidgetsBinding ), SizedBox(height: isLandscape ? 12 : 24), ElevatedButton.icon( - onPressed: canConnect ? () => appState.reconnectToRememberedDevice() : null, + onPressed: canConnect + ? () => appState.reconnectToRememberedDevice() + : null, icon: const Icon(Icons.bluetooth_connected), label: Text(canConnect ? 'Reconnect' @@ -1819,7 +1917,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, {bool canConnect = true}) { + Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, + {bool canConnect = true}) { return ListView.builder( itemCount: appState.discoveredDevices.length, itemBuilder: (context, index) { @@ -1947,9 +2046,8 @@ class _DeviceListTile extends StatelessWidget { device.id, style: TextStyle(color: enabled ? null : Colors.grey), ), - trailing: device.rssi != null - ? _buildRssiChip(device.rssi!, enabled) - : null, + trailing: + device.rssi != null ? _buildRssiChip(device.rssi!, enabled) : null, enabled: enabled, onTap: onTap, ); diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 977ce8cf..623520de 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -20,7 +20,8 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: + const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) @@ -79,7 +80,8 @@ class GraphScreen extends StatelessWidget { _SessionListTile( session: currentSession, isActive: true, - onTap: () => _openFullScreenGraph(context, currentSession, isLive: true), + onTap: () => + _openFullScreenGraph(context, currentSession, isLive: true), ), if (sessions.isNotEmpty) const Divider(), ], @@ -94,10 +96,12 @@ class GraphScreen extends StatelessWidget { ); } - void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, {bool isLive = false}) { + void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, + {bool isLive = false}) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => _FullScreenGraphPage(session: session, isLive: isLive), + builder: (context) => + _FullScreenGraphPage(session: session, isLive: isLive), ), ); } @@ -107,7 +111,8 @@ class GraphScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Clear All Sessions?'), - content: const Text('This will delete all saved noise floor session graphs. The current active session will not be affected.'), + content: const Text( + 'This will delete all saved noise floor session graphs. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -149,7 +154,8 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = widget.session; if (widget.isLive) { _liveTimer = Timer.periodic(const Duration(seconds: 2), (_) { - final current = context.read().currentNoiseFloorSession; + final current = + context.read().currentNoiseFloorSession; if (current != null) { setState(() { _session = current; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e835e9d9..90c94030 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -68,11 +68,11 @@ class _HomeScreenState extends State { return _isControlsMinimized ? 60 : 320; } - @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // In landscape: no AppBar, everything on map overlays if (isLandscape) { @@ -148,7 +148,8 @@ class _HomeScreenState extends State { } /// Stats row for AppBar/floating status bar (matches StatusBar exactly) - Widget _buildAppBarStats(AppStateProvider appState, {bool withTapHandlers = false}) { + Widget _buildAppBarStats(AppStateProvider appState, + {bool withTapHandlers = false}) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -173,7 +174,8 @@ class _HomeScreenState extends State { Icons.radar, appState.pingStats.discCount, PingColors.discSuccess, - onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), // Trace count @@ -181,7 +183,8 @@ class _HomeScreenState extends State { Icons.route, appState.pingStats.traceCount, PingColors.traceSuccess, - onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), // Upload count @@ -189,14 +192,16 @@ class _HomeScreenState extends State { Icons.cloud_done, appState.pingStats.successfulUploads, Colors.teal.shade400, - onTap: withTapHandlers ? () => _showInfoPopup('upload', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('upload', appState) : null, ), ], ); } /// Stat chip for AppBar (same style as StatusBar) - Widget _buildAppBarStatChip(IconData icon, int value, Color color, {VoidCallback? onTap}) { + Widget _buildAppBarStatChip(IconData icon, int value, Color color, + {VoidCallback? onTap}) { final chip = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -239,7 +244,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -300,51 +306,102 @@ class _HomeScreenState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); + return ( + 'Discovery Requests', + 'Discovery request packets we have sent out.', + Icons.radar, + PingColors.discSuccess + ); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -576,7 +633,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.help_outline, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.help_outline, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -588,7 +646,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.close, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.close, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -676,13 +735,14 @@ class _HomeScreenState extends State { children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), - Text(text, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)), + Text(text, + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: color)), ], ), ); } - /// Reconnecting overlay shown centered over the map during auto-reconnect Widget _buildReconnectingOverlay(AppStateProvider appState) { final deviceName = appState.rememberedDevice?.displayName ?? 'device'; @@ -932,7 +992,8 @@ class _HomeScreenState extends State { children: [ // Header with help and minimize buttons ListTile( - title: const Text('Controls', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text('Controls', + style: TextStyle(fontWeight: FontWeight.bold)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1007,7 +1068,8 @@ class _HomeScreenState extends State { /// Show help bottom sheet explaining each control void _showControlsHelp(BuildContext context) { - final prefs = Provider.of(context, listen: false).preferences; + final prefs = + Provider.of(context, listen: false).preferences; showModalBottomSheet( context: context, useSafeArea: true, @@ -1061,7 +1123,8 @@ class _HomeScreenState extends State { icon: Icons.settings_input_antenna, color: Colors.orange, title: 'External Antenna', - description: 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', + description: + 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', ), // Send Ping button @@ -1069,12 +1132,15 @@ class _HomeScreenState extends State { icon: Icons.cell_tower, color: const Color(0xFF0EA5E9), title: 'Send Ping', - description: 'Send a single ping to #wardriving and track which repeaters heard it.', + description: + 'Send a single ping to #wardriving and track which repeaters heard it.', ), // Active Mode / Hybrid Mode button _buildHelpItem( - icon: prefs.hybridModeEnabled ? Icons.compare_arrows : Icons.sensors, + icon: prefs.hybridModeEnabled + ? Icons.compare_arrows + : Icons.sensors, color: const Color(0xFF6366F1), title: prefs.hybridModeEnabled ? 'Hybrid Mode' : 'Active Mode', description: prefs.hybridModeEnabled @@ -1087,7 +1153,8 @@ class _HomeScreenState extends State { icon: Icons.hearing, color: const Color(0xFF6366F1), title: 'Passive Mode', - description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', + description: + 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), // Trace Mode @@ -1095,7 +1162,8 @@ class _HomeScreenState extends State { icon: Icons.gps_fixed, color: Colors.cyan, title: 'Trace Mode', - description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', + description: + 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), @@ -1217,4 +1285,3 @@ class _HomeScreenState extends State { return Colors.red; } } - diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 565f3920..e7bd7f70 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -16,7 +16,8 @@ class LogScreen extends StatefulWidget { State createState() => _LogScreenState(); } -class _LogScreenState extends State with SingleTickerProviderStateMixin { +class _LogScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; final _allPingsKey = GlobalKey<_AllPingsTabState>(); @@ -68,7 +69,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix }, itemBuilder: (context) => [ const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), - const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + const PopupMenuItem( + value: 'clear', child: Text('Clear all logs')), ], ), ], @@ -80,8 +82,12 @@ class _LogScreenState extends State with SingleTickerProviderStateMix dividerHeight: 1, labelPadding: EdgeInsets.zero, tabs: [ - Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), - Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + Tab( + height: 32, + text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab( + height: 32, + text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), ], ), ), @@ -120,7 +126,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix final filtered = tabState._filteredEntries; if (filtered.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No matching entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -131,7 +139,10 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), + SnackBar( + content: + Text('${filtered.length} filtered entries copied to clipboard'), + duration: const Duration(seconds: 2)), ); return; } @@ -143,7 +154,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No ping log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -161,7 +174,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + buffer.writeln( + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -170,7 +184,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (disc.isNotEmpty) { buffer.writeln('--- DISC Log ---'); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + buffer + .writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); for (final entry in disc) { buffer.writeln(entry.toCsv()); } @@ -179,7 +194,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (trace.isNotEmpty) { buffer.writeln('--- TRC Log ---'); - buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + buffer.writeln( + 'timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); for (final entry in trace) { buffer.writeln(entry.toCsv()); } @@ -187,14 +203,18 @@ class _LogScreenState extends State with SingleTickerProviderStateMix Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('All ping logs copied to clipboard'), + duration: Duration(seconds: 2)), ); } void _copyErrorLogToCsv(BuildContext context, List entries) { if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No error log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No error log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -206,7 +226,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Error log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Error log copied to clipboard'), + duration: Duration(seconds: 2)), ); } @@ -215,7 +237,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), + content: + const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -299,7 +322,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Resolve a short repeater ID to known repeater names via prefix matching. static ({List names, bool ambiguous}) _resolveRepeaterNames( - String repeaterId, List repeaters, + String repeaterId, + List repeaters, ) { final idLower = repeaterId.toLowerCase(); final matches = repeaters @@ -330,7 +354,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final event in tx.events) { if (event.repeaterId.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } } return false; case PingLogType.rx: @@ -341,32 +367,43 @@ class _AllPingsTabState extends State<_AllPingsTab> { case PingLogType.disc: final disc = entry.asDisc; for (final node in disc.discoveredNodes) { - if (node.repeaterId.toLowerCase().startsWith(query)) return true; - if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + if (node.repeaterId.toLowerCase().startsWith(query)) { + return true; + } + if (node.pubkeyHex != null && + node.pubkeyHex!.toLowerCase().startsWith(query)) { + return true; + } final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } } return false; case PingLogType.trace: final trace = entry.asTrace; if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + final resolved = + _resolveRepeaterNames(trace.targetRepeaterId, repeaters); return resolved.names.any((n) => n.toLowerCase().contains(query)); } } /// Whether an entry should show the ambiguity indicator. /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. - bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + bool _shouldShowAmbiguity( + UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; switch (entry.type) { case PingLogType.tx: - return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + return entry.asTx.events + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); case PingLogType.rx: return _isAmbiguousId(entry.asRx.repeaterId, repeaters); case PingLogType.disc: - return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + return entry.asDisc.discoveredNodes + .any((n) => _isAmbiguousId(n.repeaterId, repeaters)); case PingLogType.trace: return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); } @@ -412,11 +449,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { contentPadding: const EdgeInsets.symmetric(vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), ), onChanged: (value) => setState(() => _searchQuery = value.trim()), @@ -429,19 +474,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), clipBehavior: Clip.antiAlias, child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, + PingColors.txSuccess, + isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), + _buildFilterSegment( + PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), + _buildFilterSegment(PingLogType.disc, 'DISC', + widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', + widget.traceCount, PingColors.traceSuccess, + isLast: true), ], ), ), @@ -464,7 +519,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { hasEntries && _searchQuery.isNotEmpty ? 'No results for \'$_searchQuery\'' : 'No pings logged yet', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -474,12 +531,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { itemCount: filtered.length, itemBuilder: (context, index) { final unified = filtered[index]; - final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + final showAmbiguity = + _shouldShowAmbiguity(unified, widget.repeaters); return switch (unified.type) { - PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), - PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), - PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), - PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + PingLogType.tx => _buildTxCard(context, unified.asTx, + showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, + showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard( + context, unified.asDisc, + showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard( + context, unified.asTrace, + showAmbiguity: showAmbiguity), }; }, ), @@ -488,7 +552,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + Widget _buildFilterSegment( + PingLogType type, String label, int count, Color color, + {bool isFirst = false, bool isLast = false}) { final active = _activeFilters.contains(type); return Expanded( child: GestureDetector( @@ -504,16 +570,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { style: TextStyle( fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), ), ), if (count > 0) ...[ const SizedBox(width: 4), Container( - constraints: const BoxConstraints(minWidth: 18, minHeight: 16), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + constraints: + const BoxConstraints(minWidth: 18, minHeight: 16), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, @@ -600,19 +678,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- - Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.tx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), @@ -640,7 +722,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ @@ -670,9 +754,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null ? '${event.rssi}' : '-', + rssiColor))), ], ), ), @@ -683,7 +775,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // RX Card // --------------------------------------------------------------------------- - Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildRxCard(BuildContext context, RxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); @@ -692,43 +785,71 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.rx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), const SizedBox(height: 10), // Repeater table (single row) Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'SNR', center: true)), - Expanded(child: _tableHeader(context, 'RSSI', center: true)), + SizedBox( + width: 60, child: _tableHeader(context, 'Node')), + Expanded( + child: + _tableHeader(context, 'SNR', center: true)), + Expanded( + child: + _tableHeader(context, 'RSSI', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.repeaterId), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: entry.repeaterId, + fontSize: 14, + width: 60), + Expanded( + child: Center( + child: _buildChip( + entry.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.rssi != null + ? '${entry.rssi}' + : '-', + rssiColor))), ], ), ), @@ -747,44 +868,63 @@ class _AllPingsTabState extends State<_AllPingsTab> { // DISC Card // --------------------------------------------------------------------------- - Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.disc, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes + .map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -812,7 +952,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, + fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( @@ -821,7 +962,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { width: 70, child: Row( children: [ - Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), + Flexible( + child: RepeaterIdChip( + repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: TextStyle( @@ -833,9 +976,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), - Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), - Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), - Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), + Expanded( + child: Center( + child: _buildChip( + node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded( + child: + Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded( + child: Center( + child: _buildChip( + node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), @@ -846,7 +997,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Trace Card // --------------------------------------------------------------------------- - Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, + {bool showAmbiguity = false}) { final colorScheme = Theme.of(context).colorScheme; final appState = context.read(); @@ -854,13 +1006,16 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.trace, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Results table if (entry.success) ...[ const SizedBox(height: 10), @@ -868,18 +1023,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), @@ -915,10 +1080,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), - Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), - Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), - Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + SizedBox( + width: 70, + child: RepeaterIdChip( + repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded( + child: Center( + child: _buildChip( + entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.localRssi != null ? '${entry.localRssi}' : '-', + rssiColor))), + Expanded( + child: Center( + child: _buildChip( + entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), ], ), ); @@ -928,7 +1106,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Shared helpers // --------------------------------------------------------------------------- - static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + static Widget _buildCardHeader(BuildContext context, PingLogType type, + String timeString, String locationString, + {bool showAmbiguity = false}) { return Row( children: [ _buildTypeBadge(type), @@ -936,7 +1116,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { const SizedBox(width: 2), Tooltip( message: 'Repeater ID matches multiple nodes', - child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + child: Icon(Icons.help_outline, + size: 14, color: Colors.amber.shade700), ), ], const SizedBox(width: 6), @@ -950,7 +1131,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), const Spacer(), - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 2), Text( locationString, @@ -964,7 +1146,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + static Widget _tableHeader(BuildContext context, String text, + {bool center = false}) { return Text( text, textAlign: center ? TextAlign.center : TextAlign.left, @@ -1014,9 +1197,12 @@ class _ErrorLogTab extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle_outline, size: 48, color: Colors.green), + const Icon(Icons.check_circle_outline, + size: 48, color: Colors.green), const SizedBox(height: 16), - Text('No errors logged', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text('No errors logged', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant)), ], ), ); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 87c2c435..a3cdfef1 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -53,7 +53,8 @@ class _MainScaffoldState extends State { if (kIsWeb) { // Web: No disclosure dialog needed, just request permission // This triggers the browser's native location permission prompt - debugLog('[DISCLOSURE] Web platform - requesting GPS permission directly'); + debugLog( + '[DISCLOSURE] Web platform - requesting GPS permission directly'); await _requestWebGpsPermission(); return; } @@ -109,7 +110,7 @@ class _MainScaffoldState extends State { return; } granted = permission == LocationPermission.always || - permission == LocationPermission.whileInUse; + permission == LocationPermission.whileInUse; } else { // Android: only request if needed so previously granted permission just restarts GPS. var status = await Permission.locationWhenInUse.status; @@ -187,7 +188,8 @@ class _MainScaffoldState extends State { }); } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( body: IndexedStack( @@ -233,8 +235,12 @@ class _MainScaffoldState extends State { index: 2, ), _buildCompactNavItem( - icon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, - activeIcon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, + icon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, + activeIcon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, index: 3, color: appState.isConnected ? Colors.green : null, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4df361e8..5f269d7c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'dart:io' show File; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -43,13 +44,15 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + Future _showUploadLogsDialog( + BuildContext context, AppStateProvider appState) async { final result = await showUploadLogsDialog(context, appState); if (!context.mounted || result == null) return; if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + String message = + 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; if (result.failedCount > 0) { message += ' (${result.failedCount} failed)'; } @@ -115,11 +118,13 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), child: const Row( children: [ @@ -141,23 +146,25 @@ class _SettingsScreenState extends State { prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, ), title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + subtitle: + Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + if (!kIsWeb) _BackgroundModeToggle(appState: appState), SwitchListTile( - secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + secondary: + Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' : 'Disabled to save mobile data'), value: !prefs.mapTilesEnabled, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), ListTile( @@ -172,7 +179,8 @@ class _SettingsScreenState extends State { prefs.isImperial ? Icons.square_foot : Icons.straighten, ), title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); @@ -181,10 +189,12 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.cell_tower), title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), value: prefs.showTopRepeaters, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), ListTile( @@ -202,9 +212,11 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), value: appState.isSoundEnabled, onChanged: (_) => appState.toggleSoundEnabled(), ), @@ -219,14 +231,16 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Response Received'), - subtitle: const Text('Sound when repeater echo or RX is received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), value: appState.isRxSoundEnabled, onChanged: (value) => appState.setRxSoundEnabled(value), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Disconnect Alert'), - subtitle: const Text('Triple beep when pinging stops unexpectedly'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), value: appState.isDisconnectAlertEnabled, onChanged: (value) => appState.setDisconnectAlertEnabled(value), ), @@ -242,17 +256,20 @@ class _SettingsScreenState extends State { ? 'Device broadcasts as "Anonymous"' : 'Device uses its real name'), value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == + ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, ), ListTile( leading: const Icon(Icons.timer), @@ -260,7 +277,9 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showIntervalSelector(context, appState), ), ListTile( leading: const Icon(Icons.straighten), @@ -268,16 +287,22 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.minPingDistanceDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showDistanceSelector(context, appState), ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), + subtitle: + const Text('Stops auto-ping after 30 min without movement'), value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(autoStopAfterIdle: value)); + }, ), ]), @@ -287,7 +312,9 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.compare_arrows), title: Row( children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHybridModeInfo(context), @@ -309,15 +336,20 @@ class _SettingsScreenState extends State { ) : const Text('Combines Active and Passive modes'), value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, + onChanged: (isAutoMode || appState.enforceHybrid) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(hybridModeEnabled: value)); + }, ), SwitchListTile( secondary: const Icon(Icons.signal_wifi_off), title: Row( children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('Discovery Drop', + overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showDiscDropInfo(context), @@ -339,13 +371,16 @@ class _SettingsScreenState extends State { ) : const Text('Count failed discoveries as failed pings'), value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value == true) { - _showDiscDropEnableConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); - } - }, + onChanged: (isAutoMode || appState.enforceDiscDrop) + ? null + : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(discDropEnabled: false)); + } + }, ), ]), @@ -354,17 +389,21 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.filter_alt), title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), + subtitle: Text( + prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(ignoreCarpeater: value)); + } + }, ), if (prefs.ignoreCarpeater) ListTile( @@ -375,7 +414,9 @@ class _SettingsScreenState extends State { : 'Not set'), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showRepeaterIdDialog(context, appState), ), SwitchListTile( secondary: const Icon(Icons.shield_outlined), @@ -384,13 +425,16 @@ class _SettingsScreenState extends State { ? 'Allows all signal strengths' : 'Drops signals stronger than -30 dBm'), value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(disableRssiFilter: false)); + } + }, ), ]), @@ -400,7 +444,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.linear_scale), title: Row( children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHopBytesInfo(context), @@ -432,14 +477,19 @@ class _SettingsScreenState extends State { ) : const Text('Repeater ID size in TX/RX path hops'), trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + value: appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes, underline: const SizedBox(), items: const [ DropdownMenuItem(value: 1, child: Text('1')), DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 3, child: Text('3')), ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + appState.enforceHopBytes || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setHopBytes(value); @@ -450,7 +500,9 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.gps_fixed), title: Row( children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Trace Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showTraceBytesInfo(context), @@ -484,7 +536,9 @@ class _SettingsScreenState extends State { DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 4, child: Text('4')), ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setTraceHopBytes(value); @@ -515,7 +569,8 @@ class _SettingsScreenState extends State { : 'Keeps #wardriving channel on device'), value: prefs.deleteChannelOnDisconnect, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + appState.updatePreferences( + prefs.copyWith(deleteChannelOnDisconnect: value)); }, ), ]), @@ -570,12 +625,15 @@ class _SettingsScreenState extends State { ) else ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession( + context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession( + context, appState, session.filename), + onDownload: () => _downloadOfflineSession( + context, appState, session.filename), + )), ]), // API Endpoints @@ -592,13 +650,16 @@ class _SettingsScreenState extends State { ? (prefs.customApiUrl ?? 'Not configured') : 'Forward pings to a third-party server'), value: prefs.customApiEnabled, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showCustomApiDisclaimer(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(customApiEnabled: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(customApiEnabled: false)); + } + }, ), if (prefs.customApiEnabled) ...[ ListTile( @@ -606,29 +667,41 @@ class _SettingsScreenState extends State { title: const Text('Endpoint URL'), subtitle: Text(prefs.customApiUrl ?? 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiUrlDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiUrlDialog(context, appState), ), ListTile( leading: const SizedBox(width: 24), title: const Text('API Key'), - subtitle: Text(prefs.customApiKey != null ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Not set'), + subtitle: Text(prefs.customApiKey != null + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' + : 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiKeyDialog(context, appState), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Include Contact Key'), - subtitle: const Text('Share device public key prefix with endpoint'), + subtitle: + const Text('Share device public key prefix with endpoint'), value: prefs.customApiIncludeContact, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(customApiIncludeContact: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(customApiIncludeContact: value)); + }, ), ListTile( leading: const Icon(Icons.content_paste), title: const Text('Import from Clipboard'), subtitle: const Text('Paste a meshmapper:// config link'), - onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState), + onTap: isAutoMode + ? null + : () => _importCustomApiFromClipboard(context, appState), ), ], ]), @@ -656,7 +729,8 @@ class _SettingsScreenState extends State { leading: const FaIcon(FontAwesomeIcons.github), title: const Text('GitHub'), subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + onTap: () => _launchUrl( + 'https://github.com/MeshMapper/MeshMapper_Project'), ), ListTile( leading: const FaIcon(FontAwesomeIcons.discord), @@ -667,7 +741,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.groups), title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + subtitle: const Text( + 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), ListTile( @@ -684,12 +759,15 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.exit_to_app), title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), + subtitle: + const Text('Automatically exit the app when disconnecting'), value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + onChanged: (value) => + appState.setCloseAppAfterDisconnect(value), ), ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), + leading: + const Icon(Icons.power_settings_new, color: Colors.red), title: const Text('Close App'), subtitle: const Text('Exit the app completely'), onTap: () => _showCloseAppConfirmation(context, appState), @@ -719,7 +797,8 @@ class _SettingsScreenState extends State { if (appState.isGpsSimulatorEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -757,13 +836,15 @@ class _SettingsScreenState extends State { min: 10, max: 120, divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + label: formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), onChanged: (value) { appState.setGpsSimulatorSpeed(value); }, ), trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -779,15 +860,18 @@ class _SettingsScreenState extends State { items: [ const DropdownMenuItem( value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), + child: Text('Straight Line', + overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + child: + Text('Circle', overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + child: Text('Random Walk', + overflow: TextOverflow.ellipsis), ), if (appState.hasSimulatorRoute) DropdownMenuItem( @@ -868,7 +952,8 @@ class _SettingsScreenState extends State { if (appState.debugLogsEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -899,7 +984,8 @@ class _SettingsScreenState extends State { } }, ), - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + if (appState.debugLogsEnabled || + appState.debugLogFiles.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( @@ -915,12 +1001,14 @@ class _SettingsScreenState extends State { TextButton.icon( icon: const Icon(Icons.cloud_upload, size: 18), label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + onPressed: () => + _showUploadLogsDialog(context, appState), ), TextButton.icon( icon: const Icon(Icons.delete_sweep, size: 18), label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + onPressed: () => + _confirmDeleteAllLogs(context, appState), ), ], ], @@ -931,7 +1019,8 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + style: + TextStyle(color: Colors.grey.shade500, fontSize: 13), ), ) else @@ -941,19 +1030,27 @@ class _SettingsScreenState extends State { final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final timestampMatch = + RegExp(r'meshmapper-debug-(\d+)\.txt') + .firstMatch(filename); final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + int.parse(timestampMatch.group(1)!) * 1000) : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + final dateStr = fileDate != null + ? DateFormat('MMM d, h:mm a').format(fileDate) + : filename; String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } if (isCurrentLog) { sizeDisplay = '$sizeDisplay (current)'; @@ -961,7 +1058,8 @@ class _SettingsScreenState extends State { return ListTile( leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), + title: + Text(dateStr, style: const TextStyle(fontSize: 13)), subtitle: Text( sizeDisplay, style: const TextStyle(fontSize: 11), @@ -971,7 +1069,8 @@ class _SettingsScreenState extends State { children: [ IconButton( icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), + onPressed: () => + _showLogViewer(context, appState, file), tooltip: 'View', ), IconButton( @@ -992,27 +1091,38 @@ class _SettingsScreenState extends State { String _markerStyleLabel(String style) { switch (style) { - case 'circle': return 'Outlined Dot'; - case 'pin': return 'Pin'; - case 'diamond': return 'Diamond'; + case 'circle': + return 'Outlined Dot'; + case 'pin': + return 'Pin'; + case 'diamond': + return 'Diamond'; case 'dot': - default: return 'Dot'; + default: + return 'Dot'; } } String _gpsMarkerLabel(String style) { switch (style) { - case 'car': return 'Car'; - case 'bike': return 'Bike'; - case 'boat': return 'Boat'; - case 'walk': return 'Walk'; - case 'chomper': return 'Chomper'; + case 'car': + return 'Car'; + case 'bike': + return 'Bike'; + case 'boat': + return 'Boat'; + case 'walk': + return 'Walk'; + case 'chomper': + return 'Chomper'; case 'arrow': - default: return 'Arrow'; + default: + return 'Arrow'; } } - void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + void _showMarkerStyleSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('dot', 'Dot', Icons.circle), ('circle', 'Outlined Dot', Icons.circle_outlined), @@ -1030,13 +1140,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Map Marker Style', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.markerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(markerStyle: v)); } Navigator.pop(context); }, @@ -1079,13 +1191,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('GPS Marker', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.gpsMarkerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(gpsMarkerStyle: v)); } Navigator.pop(context); }, @@ -1118,13 +1232,30 @@ class _SettingsScreenState extends State { }; } - void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + void _showColorVisionSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('none', 'Default', 'Standard color palette'), - ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), - ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), - ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), - ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ( + 'protanopia', + 'Protanopia', + 'Red-blind — difficulty distinguishing red and green' + ), + ( + 'deuteranopia', + 'Deuteranopia', + 'Green-blind — difficulty distinguishing red and green' + ), + ( + 'tritanopia', + 'Tritanopia', + 'Blue-blind — difficulty distinguishing blue and yellow' + ), + ( + 'achromatopsia', + 'Achromatopsia', + 'Total color blindness — sees in greyscale' + ), ]; showModalBottomSheet( context: context, @@ -1137,7 +1268,8 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Color Vision', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.colorVisionType, @@ -1152,7 +1284,8 @@ class _SettingsScreenState extends State { RadioListTile( secondary: const Icon(Icons.visibility), title: Text(label), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), value: value, ), ], @@ -1165,7 +1298,8 @@ class _SettingsScreenState extends State { ); } - Widget _buildSection(BuildContext context, String title, List children) { + Widget _buildSection( + BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Card( @@ -1202,7 +1336,8 @@ class _SettingsScreenState extends State { } } - Future _showBugReportDialog(BuildContext context, AppStateProvider appState) async { + Future _showBugReportDialog( + BuildContext context, AppStateProvider appState) async { final result = await showBugReportDialog(context, appState); if (!context.mounted || result == null) return; @@ -1222,7 +1357,8 @@ class _SettingsScreenState extends State { message, duration: const Duration(seconds: 5), actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + onAction: + result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, ); } else if (result.errorMessage != null) { AppToast.error( @@ -1283,7 +1419,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableRssiFilterConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1316,7 +1453,8 @@ class _SettingsScreenState extends State { ); } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showEnableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, @@ -1350,7 +1488,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1395,21 +1534,24 @@ class _SettingsScreenState extends State { style: TextStyle(fontSize: 14), ), SizedBox(height: 12), - Text('How it works:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('How it works:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'Discovery \u2192 wait \u2192 TX Ping \u2192 wait \u2192 Discovery \u2192 ...', style: TextStyle(fontSize: 13, fontFamily: 'monospace'), ), SizedBox(height: 12), - Text('Interval timing:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('Interval timing:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'At 15s interval, each ping type fires every 30s. Discovery\'s 30s firmware rate limit is naturally respected.', style: TextStyle(fontSize: 13), ), SizedBox(height: 12), - Text('When enabled:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('When enabled:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( '\u2022 Replaces the Active button with Hybrid\n' @@ -1466,7 +1608,8 @@ class _SettingsScreenState extends State { ); } - void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + void _showDiscDropEnableConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1688,7 +1831,8 @@ class _SettingsScreenState extends State { final tile = RadioListTile( title: Text( '$interval seconds', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), ), subtitle: isDisabled ? const Text( @@ -1796,7 +1940,8 @@ class _SettingsScreenState extends State { textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters - final filtered = value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + final filtered = + value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (filtered != value) { controller.value = controller.value.copyWith( text: filtered, @@ -1840,7 +1985,8 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); + AppToast.warning( + context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1850,7 +1996,8 @@ class _SettingsScreenState extends State { ); } - Future _pickRouteFile(BuildContext context, AppStateProvider appState) async { + Future _pickRouteFile( + BuildContext context, AppStateProvider appState) async { try { debugLog('[SETTINGS] Opening file picker...'); @@ -1868,9 +2015,8 @@ class _SettingsScreenState extends State { if (result != null && result.files.isNotEmpty) { debugLog('[SETTINGS] File picked: ${result.files.first.name}'); final file = result.files.first; - final content = file.bytes != null - ? String.fromCharCodes(file.bytes!) - : null; + final content = + file.bytes != null ? String.fromCharCodes(file.bytes!) : null; if (content != null && context.mounted) { debugLog('[SETTINGS] File content loaded, ${content.length} chars'); @@ -1904,7 +2050,8 @@ class _SettingsScreenState extends State { ); } - void _processRouteFile(BuildContext context, AppStateProvider appState, String content, String filename) { + void _processRouteFile(BuildContext context, AppStateProvider appState, + String content, String filename) { debugLog('[SETTINGS] Calling loadSimulatorRoute...'); final success = appState.loadSimulatorRoute( content, @@ -1925,7 +2072,8 @@ class _SettingsScreenState extends State { } } - Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { + Future _uploadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { // Progress text notifier for updating dialog without rebuilding screen final progressNotifier = ValueNotifier('Authenticating...'); @@ -2025,7 +2173,8 @@ class _SettingsScreenState extends State { } } - void _confirmDeleteOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _confirmDeleteOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2051,9 +2200,11 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { try { - final sessionData = appState.offlineSessionService.getSessionData(filename); + final sessionData = + appState.offlineSessionService.getSessionData(filename); if (sessionData == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2065,7 +2216,8 @@ class _SettingsScreenState extends State { } // Convert to pretty JSON - final jsonString = const JsonEncoder.withIndent(' ').convert(sessionData); + final jsonString = + const JsonEncoder.withIndent(' ').convert(sessionData); if (kIsWeb && isWebFileHelpersAvailable) { // Web: Create a blob and trigger download @@ -2132,7 +2284,8 @@ class _SettingsScreenState extends State { } /// Show debug log viewer dialog - void _showLogViewer(BuildContext context, AppStateProvider appState, File file) async { + void _showLogViewer( + BuildContext context, AppStateProvider appState, File file) async { await appState.viewDebugLog(file); if (!context.mounted) return; @@ -2164,7 +2317,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiDisclaimer(BuildContext context, AppStateProvider appState) { + void _showCustomApiDisclaimer( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2222,7 +2376,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiUrlDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiUrlDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiUrl ?? '', ); @@ -2282,7 +2437,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiKeyDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiKeyDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiKey ?? '', ); @@ -2321,7 +2477,8 @@ class _SettingsScreenState extends State { ); } - Future _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async { + Future _importCustomApiFromClipboard( + BuildContext context, AppStateProvider appState) async { final clipData = await Clipboard.getData('text/plain'); final text = clipData?.text?.trim(); @@ -2344,11 +2501,15 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the url parameter'); + if (context.mounted) { + AppToast.error(context, 'Link is missing the url parameter'); + } return; } if (key == null || key.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the key parameter'); + if (context.mounted) { + AppToast.error(context, 'Link is missing the key parameter'); + } return; } @@ -2357,7 +2518,9 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl'); + if (context.mounted) { + AppToast.error(context, 'Invalid URL in link: $rawUrl'); + } return; } @@ -2374,11 +2537,14 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link'); + if (context.mounted) { + AppToast.error(context, 'Invalid meshmapper:// link'); + } } } - void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { + void _showCloseAppConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; showDialog( @@ -2458,7 +2624,9 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Future _requestPermission() async { // Show prominent disclosure before requesting background location - final accepted = await PermissionDisclosureService.showBackgroundLocationDisclosure(context); + final accepted = + await PermissionDisclosureService.showBackgroundLocationDisclosure( + context); if (!accepted) { return; // User declined } @@ -2593,7 +2761,10 @@ class _OfflineSessionTile extends StatelessWidget { if (isUploaded) const Text( 'Uploaded', - style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500), ), if (session.deviceName != null) Text( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 932f06ed..4ed600a4 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -79,7 +79,8 @@ class ApiQueueService { // Pings without a valid session cannot be uploaded, so delete them try { if (_box != null && _box!.isNotEmpty) { - debugLog('[API QUEUE] Clearing ${_box!.length} stale items from previous session'); + debugLog( + '[API QUEUE] Clearing ${_box!.length} stale items from previous session'); await _box!.clear(); } } catch (e) { @@ -108,10 +109,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened successfully'); return box; } on TimeoutException { - debugError('[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); return _attemptRecovery(timeout); } catch (e) { - debugError('[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); return _attemptRecovery(timeout); } } @@ -132,10 +135,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened after recovery'); return box; } catch (e) { - debugError('[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); + debugError( + '[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); // Notify user of persistence failure - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); return null; } @@ -150,7 +155,8 @@ class ApiQueueService { _isRecovering = true; try { - debugLog('[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); + debugLog( + '[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); // Close the corrupt box try { @@ -168,16 +174,19 @@ class ApiQueueService { _box = box; debugLog('[API QUEUE] Box recovered successfully'); } catch (e) { - debugError('[API QUEUE] Runtime recovery failed: $e - operating without persistence'); + debugError( + '[API QUEUE] Runtime recovery failed: $e - operating without persistence'); _box = null; - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); } finally { _isRecovering = false; } } /// Wrap a write operation with corruption recovery and single retry - Future _safeWrite(Future Function(Box box) operation) async { + Future _safeWrite( + Future Function(Box box) operation) async { final box = _box; if (box == null) return false; @@ -249,9 +258,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -344,9 +355,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -393,9 +406,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -434,9 +449,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -474,7 +491,8 @@ class ApiQueueService { } } - debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); + debugLog( + '[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); onQueueUpdated?.call(queueSize); } finally { _isFlushing = false; @@ -525,13 +543,15 @@ class ApiQueueService { try { // Collect items from both Hive and memory queue - final hiveItems = _safeRead((box) => box.values - .where((item) => - item.retryCount < _maxRetries && - item.isReadyForRetry && - item.isUploadEligible) - .take(_batchSize) - .toList(), []); + final hiveItems = _safeRead( + (box) => box.values + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize) + .toList(), + []); final memoryItems = _memoryQueue .where((item) => @@ -555,12 +575,14 @@ class ApiQueueService { // Log each item with external_antenna value for (int i = 0; i < items.length; i++) { final item = items[i]; - debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); + debugLog( + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } final memoryCount = memoryItems.length; if (memoryCount > 0) { - debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + debugLog( + '[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); } else { debugLog('[API QUEUE] Uploading ${items.length} items...'); } @@ -572,7 +594,9 @@ class ApiQueueService { final uploadedCount = items.length; // Remove successful Hive items for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} + try { + await item.delete(); + } catch (_) {} } // Remove successful memory items for (final item in memoryItems) { @@ -585,12 +609,15 @@ class ApiQueueService { } else if (result == UploadResult.nonRetryable) { // Data is permanently invalid — discard for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} + try { + await item.delete(); + } catch (_) {} } for (final item in memoryItems) { _memoryQueue.remove(item); } - debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); + debugWarn( + '[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried for (final item in hiveItems) { @@ -601,7 +628,8 @@ class ApiQueueService { item.retryCount++; item.lastRetryAt = DateTime.now(); } - debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); + debugLog( + '[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } onQueueUpdated?.call(queueSize); @@ -648,7 +676,8 @@ class ApiQueueService { final count = queueSize + _rxBuffer.length; if (count > 0) { - debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); + debugLog( + '[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); _memoryQueue.clear(); @@ -679,10 +708,12 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { final hiveItems = _safeRead( - (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), + (box) => + box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); - final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + final memoryItems = + _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); return [...hiveItems, ...memoryItems]; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index eb8a47df..1cdd4325 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -33,7 +33,7 @@ class ApiService { static const Duration heartbeatBuffer = Duration(minutes: 1); final http.Client _client; - bool _heartbeatEnabled = false; // Track if heartbeat mode is active + bool _heartbeatEnabled = false; // Track if heartbeat mode is active String? _sessionId; bool _txAllowed = false; bool _rxAllowed = false; @@ -91,7 +91,8 @@ class ApiService { /// Check if response indicates maintenance mode, trigger callback if so bool _checkMaintenanceMode(Map response) { if (response['maintenance'] == true) { - final message = response['maintenance_message'] as String? ?? 'Service is under maintenance'; + final message = response['maintenance_message'] as String? ?? + 'Service is under maintenance'; final url = response['maintenance_url'] as String?; debugLog('[MAINTENANCE] Maintenance mode detected: $message'); onMaintenanceMode?.call(message, url); @@ -109,7 +110,8 @@ class ApiService { Map? request, dynamic response, }) { - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); String reqSummary; if (request != null) { @@ -136,13 +138,13 @@ class ApiService { /// Check if we have a valid session bool get hasSession => _sessionId != null; - + /// Check if TX is allowed bool get txAllowed => _txAllowed; - + /// Check if RX is allowed bool get rxAllowed => _rxAllowed; - + /// Get session ID String? get sessionId => _sessionId; @@ -174,17 +176,21 @@ class ApiService { 'key': apiKey, }; - final response = await _client.post( - Uri.parse(geoAuthStatusUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthStatusUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); debugError('[API] Response headers: ${response.headers}'); } @@ -193,7 +199,8 @@ class ApiService { data = json.decode(response.body) as Map; } on FormatException { // CDN/proxy can return HTML error pages with HTTP 200 - debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } @@ -226,8 +233,8 @@ class ApiService { /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, - String? publicKey, // Now optional - either publicKey or contactUri required - String? contactUri, // NEW: for registration flow + String? publicKey, // Now optional - either publicKey or contactUri required + String? contactUri, // NEW: for registration flow String? who, String? appVersion, double? power, @@ -269,7 +276,9 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + if (power != null) { + payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; payload['coords'] = { @@ -283,24 +292,29 @@ class ApiService { payload['session_id'] = sessionId ?? _sessionId; } - final response = await _client.post( - Uri.parse(geoAuthUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -316,7 +330,8 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) - if ((reason == 'connect' || reason == 'register') && data['success'] == true) { + if ((reason == 'connect' || reason == 'register') && + data['success'] == true) { if (!skipSessionStore) { _sessionId = data['session_id'] as String?; _txAllowed = data['tx_allowed'] == true; @@ -367,7 +382,8 @@ class ApiService { if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { _apiHopBytes = hopBytes; if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + debugLog( + '[API] Regional admin enforces $_apiHopBytes-byte paths'); } } else { _apiHopBytes = 1; @@ -397,7 +413,8 @@ class ApiService { /// /// @param entries List of wardrive entries (TX/RX) /// @returns Map with success, expires_at, reason, message - Future?> submitWardriveData(List> entries) async { + Future?> submitWardriveData( + List> entries) async { if (_sessionId == null) { throw Exception('Cannot submit: no session_id'); } @@ -410,32 +427,37 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } // Log with data summary including external_antenna values - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive', method: 'POST', @@ -486,24 +508,29 @@ class ApiService { }; } - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -533,7 +560,8 @@ class ApiService { return data; } catch (e) { stopwatch.stop(); - debugError('[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); + debugError( + '[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); return null; } } @@ -547,7 +575,11 @@ class ApiService { }) async { if (_sessionId == null) { debugWarn('[SESSION] No session to validate'); - return (isValid: false, reason: 'no_session', message: 'No active session'); + return ( + isValid: false, + reason: 'no_session', + message: 'No active session' + ); } debugLog('[SESSION] Checking session validity via heartbeat...'); @@ -555,11 +587,16 @@ class ApiService { if (result == null) { debugWarn('[SESSION] Session check failed: no response'); - return (isValid: false, reason: 'no_response', message: 'Server did not respond'); + return ( + isValid: false, + reason: 'no_response', + message: 'Server did not respond' + ); } if (result['success'] == true) { - debugLog('[SESSION] Session is valid (expires_at: ${result['expires_at']})'); + debugLog( + '[SESSION] Session is valid (expires_at: ${result['expires_at']})'); return (isValid: true, reason: null, message: null); } @@ -570,9 +607,15 @@ class ApiService { // Trigger session error callback for critical errors const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); @@ -628,14 +671,17 @@ class ApiService { // Calculate when to send heartbeat (1 minute before expiry) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final secondsUntilExpiry = expiresAt - now; - final secondsUntilHeartbeat = secondsUntilExpiry - heartbeatBuffer.inSeconds; + final secondsUntilHeartbeat = + secondsUntilExpiry - heartbeatBuffer.inSeconds; if (secondsUntilHeartbeat <= 0) { // Session is about to expire or already expired - send heartbeat immediately - debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); } else { - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + debugLog( + '[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { debugLog('[HEARTBEAT] Timer fired, sending keepalive'); @@ -662,11 +708,14 @@ class ApiService { if (_heartbeatRetryCount < _maxHeartbeatRetries) { final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); _heartbeatRetryCount++; - debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + debugWarn( + '[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + _heartbeatRetryTimer = + Timer(Duration(seconds: delay), _sendScheduledHeartbeat); } else { - debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + debugError( + '[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); } _onSessionExpiring?.call(); } else { @@ -676,9 +725,15 @@ class ApiService { debugWarn('[HEARTBEAT] Heartbeat failed: $reason - $message'); const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { @@ -773,7 +828,8 @@ class ApiService { // outside_zone: preserve session (backend auto-transfers on zone re-entry), // but discard this batch (gap-GPS coords would be rejected again) if (reason == 'outside_zone') { - debugWarn('[API] Upload batch outside_zone — discarding batch, preserving session'); + debugWarn( + '[API] Upload batch outside_zone — discarding batch, preserving session'); final message = result['message'] as String?; onSessionError?.call(reason, message); return UploadResult.nonRetryable; @@ -781,10 +837,15 @@ class ApiService { // Errors where the batch data itself is invalid — retrying won't help const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } @@ -803,9 +864,11 @@ class ApiService { try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; - final response = await _client.get( - Uri.parse(url), - ).timeout(const Duration(seconds: 15)); + final response = await _client + .get( + Uri.parse(url), + ) + .timeout(const Duration(seconds: 15)); stopwatch.stop(); @@ -820,7 +883,8 @@ class ApiService { return []; } - final List jsonList = json.decode(response.body) as List; + final List jsonList = + json.decode(response.body) as List; final repeaters = []; for (final item in jsonList) { @@ -865,31 +929,36 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive (offline)', method: 'POST', @@ -933,9 +1002,16 @@ class ApiService { // For offline uploads, session/auth errors are non-retryable but do NOT cascade const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'outside_zone', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { debugError('[API] Offline upload batch session error: $reason'); @@ -943,10 +1019,15 @@ class ApiService { } const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Offline upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 6b74c07c..27cc57b9 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -25,8 +25,10 @@ class AudioService { AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it - bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) - bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) + bool _txEnabled = + true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = + true; // RX sound sub-toggle (only matters when master is on) Timer? _focusReleaseTimer; /// Whether the audio service is initialized @@ -148,13 +150,15 @@ class AudioService { debugError('[AUDIO] Hive box "$boxName" timed out - attempting recovery'); return _attemptRecovery(boxName, timeout); } catch (e) { - debugError('[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); + debugError( + '[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); return _attemptRecovery(boxName, timeout); } } /// Attempt to recover from Hive corruption - Future?> _attemptRecovery(String boxName, Duration timeout) async { + Future?> _attemptRecovery( + String boxName, Duration timeout) async { try { debugLog('[AUDIO] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -163,7 +167,8 @@ class AudioService { debugLog('[AUDIO] Box "$boxName" opened after recovery'); return box; } catch (e) { - debugError('[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); + debugError( + '[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); return null; } } @@ -182,7 +187,8 @@ class AudioService { /// Shared playback logic for both TX and RX sounds. /// Ensures audio session is active before playing and debounces focus release. - Future _playSound(AudioPlayer? player, String assetPath, String label) async { + Future _playSound( + AudioPlayer? player, String assetPath, String label) async { if (!_initialized || !_enabled || player == null) return; try { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 1e76464f..2d7bed52 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -95,7 +95,8 @@ class BackgroundServiceManager { // (e.g., Android resurrecting a previously-killed foreground service). final isRunning = await _service!.isRunning(); if (isRunning) { - debugLog('[BACKGROUND] Service unexpectedly running after configure(), stopping it'); + debugLog( + '[BACKGROUND] Service unexpectedly running after configure(), stopping it'); _service!.invoke('stop'); } @@ -221,7 +222,8 @@ class BackgroundServiceManager { static Future cleanupOrphanedService() async { if (kIsWeb) return; try { - debugLog('[BACKGROUND] Dismissing any orphaned notification from previous session'); + debugLog( + '[BACKGROUND] Dismissing any orphaned notification from previous session'); final plugin = FlutterLocalNotificationsPlugin(); await plugin.cancel(_notificationId); debugLog('[BACKGROUND] Orphaned notification cleanup complete'); diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 8fb3d629..47a5f23f 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -35,10 +35,13 @@ class MobileBluetoothService implements BluetoothService { } void _ensureControllers() { - if (_isDisposed || _connectionController == null || _connectionController!.isClosed) { + if (_isDisposed || + _connectionController == null || + _connectionController!.isClosed) { _initControllers(); } } + DiscoveredDevice? _connectedDevice; fbp.BluetoothDevice? _bleDevice; fbp.BluetoothCharacteristic? _rxCharacteristic; @@ -135,19 +138,21 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] iOS location permission check: $locationPermission'); if (locationPermission == LocationPermission.deniedForever) { - debugLog('[BLE] iOS location permission permanently denied - user must enable in Settings'); + debugLog( + '[BLE] iOS location permission permanently denied - user must enable in Settings'); throw BlePermissionDeniedException( - 'Location permission required for Bluetooth scanning. ' - 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper' - ); + 'Location permission required for Bluetooth scanning. ' + 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper'); } if (locationPermission == LocationPermission.denied) { - debugLog('[BLE] iOS location permission not yet granted (disclosure flow will handle)'); + debugLog( + '[BLE] iOS location permission not yet granted (disclosure flow will handle)'); return false; } - debugLog('[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); + debugLog( + '[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); return true; } else { // Android: Use bluetoothScan and bluetoothConnect (Android 12+) @@ -155,18 +160,26 @@ class MobileBluetoothService implements BluetoothService { // Location requests are handled by the disclosure flow in MainScaffold. final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); - final location = await Permission.locationWhenInUse.status; // CHECK only, don't request + final location = await Permission + .locationWhenInUse.status; // CHECK only, don't request - debugLog('[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); + debugLog( + '[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); // Check for permanently denied permissions - if (bluetoothScan.isPermanentlyDenied || bluetoothConnect.isPermanentlyDenied || location.isPermanentlyDenied) { + if (bluetoothScan.isPermanentlyDenied || + bluetoothConnect.isPermanentlyDenied || + location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) denied.add('Bluetooth Connect'); + if (bluetoothConnect.isPermanentlyDenied) { + denied.add('Bluetooth Connect'); + } if (location.isPermanentlyDenied) denied.add('Location'); - debugLog('[BLE] Android permissions permanently denied: ${denied.join(", ")}'); - throw BlePermissionDeniedException('${denied.join(", ")} permission(s) denied. Please enable in Settings'); + debugLog( + '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); + throw BlePermissionDeniedException( + '${denied.join(", ")} permission(s) denied. Please enable in Settings'); } final granted = bluetoothScan.isGranted && @@ -185,7 +198,7 @@ class MobileBluetoothService implements BluetoothService { Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); _scanController = controller; - + _updateStatus(ConnectionStatus.scanning); try { @@ -203,9 +216,11 @@ class MobileBluetoothService implements BluetoothService { _scanSubscription = fbp.FlutterBluePlus.scanResults.listen((results) { for (final result in results) { final hasName = result.device.platformName.isNotEmpty; - final deviceName = hasName ? result.device.platformName : 'MeshCore Device'; + final deviceName = + hasName ? result.device.platformName : 'MeshCore Device'; if (!hasName) { - debugLog('[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); } final device = DiscoveredDevice( id: result.device.remoteId.str, @@ -222,7 +237,9 @@ class MobileBluetoothService implements BluetoothService { // Complete stream when scan naturally stops (timeout or platform stop) unawaited(() async { - await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + await fbp.FlutterBluePlus.isScanning + .where((isScanning) => !isScanning) + .first; if (!controller.isClosed) { await controller.close(); } @@ -296,7 +313,8 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Connecting to GATT...'); await _bleDevice!.connect( timeout: const Duration(seconds: 15), - mtu: null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android + mtu: + null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android ); debugLog('[BLE] GATT connected'); @@ -313,7 +331,8 @@ class MobileBluetoothService implements BluetoothService { } catch (e) { // MTU negotiation failure is not fatal - continue with default MTU // Some older devices may not support MTU negotiation - debugLog('[BLE] MTU negotiation failed (continuing with default): $e'); + debugLog( + '[BLE] MTU negotiation failed (continuing with default): $e'); } } else { // iOS auto-negotiates MTU, just log the current value @@ -326,7 +345,8 @@ class MobileBluetoothService implements BluetoothService { // Flutter Blue Plus emits the current state immediately when you subscribe, // but we only want to react to CHANGES, not the initial state. // This prevents false disconnection triggers during connection setup. - _connectionStateSubscription = _bleDevice!.connectionState.skip(1).listen((state) { + _connectionStateSubscription = + _bleDevice!.connectionState.skip(1).listen((state) { debugLog('[BLE] Connection state changed: $state'); if (state == fbp.BluetoothConnectionState.disconnected) { _handleDisconnection(); @@ -364,8 +384,11 @@ class MobileBluetoothService implements BluetoothService { // Enable notifications on TX characteristic debugLog('[BLE] Enabling notifications...'); await _txCharacteristic!.setNotifyValue(true); - _notificationSubscription = _txCharacteristic!.lastValueStream.listen((value) { - if (value.isNotEmpty && _dataController != null && !_dataController!.isClosed) { + _notificationSubscription = + _txCharacteristic!.lastValueStream.listen((value) { + if (value.isNotEmpty && + _dataController != null && + !_dataController!.isClosed) { _dataController!.add(Uint8List.fromList(value)); } }); @@ -380,41 +403,48 @@ class MobileBluetoothService implements BluetoothService { deviceName = _bleDevice!.platformName; } else { deviceName = 'MeshCore Device'; - debugLog('[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: deviceId, name: deviceName, ); if (deviceName == 'MeshCore Device') { - debugLog('[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); } else { - debugLog('[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); } debugLog('[BLE] Connection complete'); _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop - } catch (e, stackTrace) { final errorStr = e.toString(); // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + final isError133 = + Platform.isAndroid && errorStr.contains('android-code: 133'); // Check for iOS apple-code 14 (Peer removed pairing information) or // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys final isBondError = Platform.isIOS && - (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')); if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { - debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + debugLog( + '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); await removeBond(deviceId); await Future.delayed(const Duration(seconds: 2)); } else { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + debugLog( + '[BLE] Error 133 on attempt $attempt, retrying after delay...'); await Future.delayed(_retryDelay); } // Force cleanup before retry diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index ef5ed4c2..e93d16f9 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -13,14 +13,16 @@ import 'bluetooth_service.dart'; class WebBluetoothService implements BluetoothService { final _connectionController = StreamController.broadcast(); final _dataController = StreamController.broadcast(); - final fwb.FlutterWebBluetoothInterface _webBluetooth = fwb.FlutterWebBluetooth.instance; + final fwb.FlutterWebBluetoothInterface _webBluetooth = + fwb.FlutterWebBluetooth.instance; ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; DiscoveredDevice? _connectedDevice; fwb.BluetoothDevice? _device; fwb.BluetoothDevice? _pendingDevice; // Store device from scan for connect() - fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) - fwb.BluetoothCharacteristic? _txCharacteristic; // For notifications (device TX) + fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) + fwb.BluetoothCharacteristic? + _txCharacteristic; // For notifications (device TX) StreamSubscription? _notificationSubscription; @override @@ -73,13 +75,15 @@ class WebBluetoothService implements BluetoothService { // Web Bluetooth doesn't support scanning - uses requestDevice dialog // This is a stub that will yield devices from the request dialog _updateStatus(ConnectionStatus.scanning); - debugLog('[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); - + debugLog( + '[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); + try { // Request device filtered by MeshCore service UUID (matches JS implementation) final device = await _webBluetooth.requestDevice( fwb.RequestOptionsBuilder([ - fwb.RequestFilterBuilder(services: [BleUuids.serviceUuid.toLowerCase()]), + fwb.RequestFilterBuilder( + services: [BleUuids.serviceUuid.toLowerCase()]), ]), ); @@ -89,7 +93,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = device.name ?? 'MeshCore Device'; if (device.name == null) { - debugWarn('[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); } yield DiscoveredDevice( id: device.id, @@ -123,7 +128,7 @@ class WebBluetoothService implements BluetoothService { debugError('[BLE] No pending device - must call scanForDevices first'); throw Exception('No device selected. Please scan for devices first.'); } - + _device = _pendingDevice; _pendingDevice = null; // Clear pending debugLog('[BLE] Using stored device: ${_device!.name ?? _device!.id}'); @@ -137,7 +142,7 @@ class WebBluetoothService implements BluetoothService { debugLog('[BLE] Discovering services...'); final services = await _device!.discoverServices(); debugLog('[BLE] Found ${services.length} services'); - + // Find our MeshCore service fwb.BluetoothService? meshCoreService; for (final service in services) { @@ -148,7 +153,7 @@ class WebBluetoothService implements BluetoothService { break; } } - + if (meshCoreService == null) { throw Exception('MeshCore service not found'); } @@ -179,14 +184,15 @@ class WebBluetoothService implements BluetoothService { try { await _txCharacteristic!.startNotifications(); debugLog('[BLE] Notifications started, setting up listener...'); - + // HIGH-LEVEL API: BluetoothCharacteristic.value is a Stream _notificationSubscription = _txCharacteristic!.value.listen( (ByteData data) { try { // Convert ByteData to Uint8List - final buffer = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - + final buffer = data.buffer + .asUint8List(data.offsetInBytes, data.lengthInBytes); + if (buffer.isNotEmpty) { debugLog('[BLE] Received ${buffer.length} bytes'); _dataController.add(buffer); @@ -209,7 +215,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = _device!.name ?? 'MeshCore Device'; if (_device!.name == null) { - debugWarn('[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: _device!.id, diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index bf5c94a2..412bd3fb 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -13,8 +13,8 @@ import '../utils/debug_logger_io.dart'; class CountdownTimerService { Timer? _timer; DateTime? _endTime; - int? _durationMs; // Original duration for progress calculation - final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick + int? _durationMs; // Original duration for progress calculation + final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick CountdownTimerService({this.onUpdate}); @@ -42,11 +42,12 @@ class CountdownTimerService { /// @param durationMs - Duration in milliseconds void start(int durationMs) { stop(); - _durationMs = durationMs; // Track original duration for progress + _durationMs = durationMs; // Track original duration for progress _endTime = DateTime.now().add(Duration(milliseconds: durationMs)); // Start 500ms update timer for responsive countdown - _timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); + _timer = + Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update _update(); @@ -136,7 +137,8 @@ class ManualPingCooldownTimer extends CountdownTimerService { final remaining = remainingMs; super.stop(); if (wasRunning) { - debugLog('[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); + debugLog( + '[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); } } } diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index b839622b..f5ba0c71 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -49,7 +49,8 @@ class CustomApiService { if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) return; // Enrich with contact and iata (custom API only — never sent to MeshMapper) - final contact = prefs.customApiIncludeContact ? contactGetter?.call() : null; + final contact = + prefs.customApiIncludeContact ? contactGetter?.call() : null; final iata = iataGetter?.call(); final enriched = pings.map((ping) { @@ -86,16 +87,21 @@ class CustomApiService { stopwatch.stop(); if (response.statusCode >= 200 && response.statusCode < 300) { - debugLog('[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); } else { final errorType = 'http_${response.statusCode}'; - debugError('[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); - debugError('[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); - _throttledError(errorType, 'Custom API returned HTTP ${response.statusCode}'); + debugError( + '[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); + debugError( + '[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + _throttledError( + errorType, 'Custom API returned HTTP ${response.statusCode}'); } } on TimeoutException { stopwatch.stop(); - debugError('[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); + debugError( + '[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); _throttledError('timeout', 'Custom API request timed out'); } catch (e) { stopwatch.stop(); @@ -124,7 +130,8 @@ class CustomApiService { String _describeError(Object e) { final full = e.toString(); // Look for SocketException detail (e.g. "Failed host lookup: 'blah.blah'") - final socketMatch = RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); + final socketMatch = + RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); if (socketMatch != null) return socketMatch.group(1)!.trim(); // Look for OS-level message final osMatch = RegExp(r'OS Error: (.+?)(?:,|\))').firstMatch(full); diff --git a/lib/services/debug_file_logger.dart b/lib/services/debug_file_logger.dart index 1406023c..5b43551e 100644 --- a/lib/services/debug_file_logger.dart +++ b/lib/services/debug_file_logger.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; /// - Non-persistent (always starts disabled on app launch) class DebugFileLogger { static const int maxLogFiles = 10; + /// Maximum file size for upload (4.5MB, 0.5MB safety margin under 5MB server limit) static const int maxUploadSizeBytes = 4718592; static File? _currentLogFile; @@ -293,7 +294,8 @@ class DebugFileLogger { for (final line in lines) { final lineBytes = line.length + 1; // +1 for newline - if (currentSize + lineBytes > maxUploadSizeBytes && currentChunk.isNotEmpty) { + if (currentSize + lineBytes > maxUploadSizeBytes && + currentChunk.isNotEmpty) { chunkLines.add(currentChunk); currentChunk = []; currentSize = 0; diff --git a/lib/services/debug_submit_service.dart b/lib/services/debug_submit_service.dart index fe902ba3..df147e74 100644 --- a/lib/services/debug_submit_service.dart +++ b/lib/services/debug_submit_service.dart @@ -135,7 +135,8 @@ class DebugSubmitService { ); if (ticketResult == null || ticketResult['success'] != true) { - final error = ticketResult?['message'] as String? ?? 'Failed to create ticket'; + final error = + ticketResult?['message'] as String? ?? 'Failed to create ticket'; debugError('[BUG REPORT] FAILED: Ticket creation failed: $error'); debugLog('[BUG REPORT] ========================================'); return BugReportResult.error(error); @@ -167,11 +168,13 @@ class DebugSubmitService { debugLog('[BUG REPORT] ----------------------------------------'); debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: $filename'); - reportProgress('Uploading $filename...', fileProgress, currentFile: i + 1); + reportProgress('Uploading $filename...', fileProgress, + currentFile: i + 1); // Add delay before file uploads to prevent server overload if (totalFiles > 1) { - final delayMs = i == 0 ? 500 : 1000; // 500ms before first, 1s between others + final delayMs = + i == 0 ? 500 : 1000; // 500ms before first, 1s between others debugLog('[BUG REPORT] Waiting ${delayMs}ms before upload...'); await Future.delayed(Duration(milliseconds: delayMs)); } @@ -188,16 +191,20 @@ class DebugSubmitService { if (success) { uploadedCount++; debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: SUCCESS'); - reportProgress('Uploaded $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress('Uploaded $filename', fileProgress + progressPerFile, + currentFile: i + 1); } else { failedCount++; debugError('[BUG REPORT] File ${i + 1}/$totalFiles: FAILED'); - reportProgress('Failed to upload $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress( + 'Failed to upload $filename', fileProgress + progressPerFile, + currentFile: i + 1); } } debugLog('[BUG REPORT] ----------------------------------------'); - debugLog('[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); + debugLog( + '[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); } reportProgress('Finalizing...', 0.95); @@ -205,7 +212,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] ========================================'); debugLog('[BUG REPORT] Bug report submission complete'); debugLog('[BUG REPORT] Issue: #$issueNumber'); - debugLog('[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); + debugLog( + '[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); debugLog('[BUG REPORT] ========================================'); reportProgress('Complete!', 1.0); @@ -250,13 +258,15 @@ class DebugSubmitService { } // File was split into chunks - debugLog('[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); + debugLog( + '[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); bool allSucceeded = true; try { for (int i = 0; i < chunks.length; i++) { final chunkName = chunks[i].path.split('/').last; - debugLog('[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); + debugLog( + '[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); if (i > 0) { // Delay between chunk uploads @@ -274,11 +284,13 @@ class DebugSubmitService { ); if (!success) { - debugError('[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); + debugError( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); allSucceeded = false; break; } - debugLog('[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); + debugLog( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); } } finally { // Always clean up temp chunk files @@ -306,7 +318,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(file); final fileSize = await file.length(); final fileSizeKb = (fileSize / 1024).toStringAsFixed(1); - debugLog('[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL debugLog('[BUG REPORT] Step 2/4: Requesting upload URL...'); @@ -320,10 +333,12 @@ class DebugSubmitService { ); if (session == null) { - debugError('[BUG REPORT] FAILED: Could not get upload URL for: $filename'); + debugError( + '[BUG REPORT] FAILED: Could not get upload URL for: $filename'); return false; } - debugLog('[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); + debugLog( + '[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); // Step 3: Upload the file (with retry logic) debugLog('[BUG REPORT] Step 3/4: Uploading file data...'); @@ -343,19 +358,22 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; // 2s, 4s backoff - debugWarn('[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); + debugError( + '[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); return false; } // Step 4: Complete the upload with GitHub issue reference debugLog('[BUG REPORT] Step 4/4: Confirming upload...'); - final userNotes = issueNumber != null ? 'GitHub Issue: $issueNumber' : null; + final userNotes = + issueNumber != null ? 'GitHub Issue: $issueNumber' : null; if (userNotes != null) { debugLog('[BUG REPORT] User notes: $userNotes'); } @@ -369,8 +387,10 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); - debugWarn('[BUG REPORT] File was uploaded but confirmation failed - treating as success'); + debugWarn( + '[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); + debugWarn( + '[BUG REPORT] File was uploaded but confirmation failed - treating as success'); } else { debugLog('[BUG REPORT] SUCCESS: Upload confirmed'); } @@ -407,20 +427,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] body: ${body.length} chars'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { debugError('[BUG REPORT] HTTP error: ${response.statusCode}'); debugError('[BUG REPORT] Response body: ${response.body}'); - return {'success': false, 'message': 'Server error: ${response.statusCode}'}; + return { + 'success': false, + 'message': 'Server error: ${response.statusCode}' + }; } final data = json.decode(response.body) as Map; @@ -466,21 +492,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] POST $url'); debugLog('[BUG REPORT] Request payload:'); debugLog('[BUG REPORT] device_id: $deviceId'); - debugLog('[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); - debugLog('[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); + debugLog( + '[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); + debugLog( + '[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); debugLog('[BUG REPORT] file_hash: ${fileHash.substring(0, 16)}...'); debugLog('[BUG REPORT] app_version: $appVersion'); debugLog('[BUG REPORT] platform: $platform'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -492,7 +523,8 @@ class DebugSubmitService { final data = json.decode(response.body) as Map; debugLog('[BUG REPORT] Response JSON:'); debugLog('[BUG REPORT] session_id: ${data['session_id']}'); - debugLog('[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); + debugLog( + '[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); debugLog('[BUG REPORT] expires_at: ${data['expires_at']}'); if (data['upload_url'] == null || data['session_id'] == null) { @@ -532,15 +564,19 @@ class DebugSubmitService { )); final stopwatch = Stopwatch()..start(); - final streamedResponse = await request.send().timeout(const Duration(seconds: 120)); + final streamedResponse = + await request.send().timeout(const Duration(seconds: 120)); final response = await http.Response.fromStream(streamedResponse); stopwatch.stop(); - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); final speedKbps = fileSize > 0 - ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)).toStringAsFixed(1) + ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)) + .toStringAsFixed(1) : '0'; - debugLog('[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); + debugLog( + '[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -556,7 +592,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] message: ${data['message']}'); } if (data['stored_hash'] != null) { - debugLog('[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); + debugLog( + '[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); } final success = data['success'] == true; @@ -598,14 +635,17 @@ class DebugSubmitService { } final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -658,7 +698,8 @@ class DebugSubmitService { if (isChunked) { final fileSize = await file.length(); - debugLog('[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); + debugLog( + '[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); } // Progress range: 0.1 to 0.9 divided across chunks @@ -676,9 +717,11 @@ class DebugSubmitService { } void reportChunkProgress(String status, double chunkProgress) { - final overallProgress = chunkBase + (chunkProgress * progressPerChunk); + final overallProgress = + chunkBase + (chunkProgress * progressPerChunk); onProgress?.call(BugReportProgress( - status: isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, + status: + isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, progress: overallProgress.clamp(0.0, 1.0), currentFile: isChunked ? i + 1 : 1, totalFiles: isChunked ? totalChunks : 1, @@ -697,7 +740,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(chunk); final chunkSize = await chunk.length(); final chunkSizeKb = (chunkSize / 1024).toStringAsFixed(1); - debugLog('[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL reportChunkProgress('Requesting upload...', 0.2); @@ -712,7 +756,8 @@ class DebugSubmitService { ); if (session == null) { - debugError('[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); allSucceeded = false; break; } @@ -737,13 +782,15 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; - debugWarn('[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); allSucceeded = false; break; } @@ -760,7 +807,8 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[DEBUG UPLOAD] Confirmation failed but file was uploaded'); + debugWarn( + '[DEBUG UPLOAD] Confirmation failed but file was uploaded'); } debugLog('[DEBUG UPLOAD] Chunk ${i + 1}/$totalChunks complete'); @@ -781,7 +829,8 @@ class DebugSubmitService { totalFiles: totalChunks, )); debugLog('[DEBUG UPLOAD] ========================================'); - debugLog('[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); + debugLog( + '[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); debugLog('[DEBUG UPLOAD] ========================================'); } else { debugLog('[DEBUG UPLOAD] ========================================'); diff --git a/lib/services/device_model_service.dart b/lib/services/device_model_service.dart index c9aecb2a..6e9f9aac 100644 --- a/lib/services/device_model_service.dart +++ b/lib/services/device_model_service.dart @@ -6,7 +6,7 @@ import '../models/device_model.dart'; /// Device model service for auto-power selection /// Ported from parseDeviceModel() and autoSetPowerLevel() in wardrive.js -/// +/// /// CRITICAL: Correct power configuration is essential for PA amplifier models /// to prevent hardware damage. class DeviceModelService { @@ -24,9 +24,10 @@ class DeviceModelService { if (_isLoaded) return; try { - final jsonString = await rootBundle.loadString('assets/device-models.json'); + final jsonString = + await rootBundle.loadString('assets/device-models.json'); final jsonData = json.decode(jsonString) as Map; - + final database = DeviceModelsDatabase.fromJson(jsonData); _models = database.devices; _isLoaded = true; @@ -39,7 +40,7 @@ class DeviceModelService { /// Match device manufacturer string to known model /// Reference: parseDeviceModel() in wardrive.js - /// + /// /// Strips build suffix (e.g., "nightly-e31c46f") and matches against database DeviceModel? matchDevice(String manufacturerString) { if (_models.isEmpty) return null; @@ -68,7 +69,7 @@ class DeviceModelService { final parts = cleanManufacturer.split(RegExp(r'[\s\-_()]+')); for (final model in _models) { final modelParts = model.manufacturer.split(RegExp(r'[\s\-_()]+')); - + // Check if key identifying parts match int matchCount = 0; for (final modelPart in modelParts) { @@ -76,7 +77,7 @@ class DeviceModelService { matchCount++; } } - + // Require at least 2 matching parts if (matchCount >= 2) { return model; diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 6eced5cd..43f951a1 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -15,15 +15,15 @@ import 'gps_simulator_service.dart'; class GpsService { /// Minimum distance (meters) from last ping before allowing new ping static const double minDistanceMeters = 25.0; - + /// Maximum GPS age for manual pings (60 seconds) /// Reference: GPS_WATCH_MAX_AGE_MS in wardrive.js static const Duration maxGpsAgeForManualPing = Duration(seconds: 60); - + /// Maximum GPS accuracy threshold for pings (100 meters) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js docs static const double maxAccuracyMetersForPing = 100.0; - + /// Maximum GPS accuracy threshold for zone checks (50 meters) /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; @@ -36,8 +36,10 @@ class GpsService { /// Set the minimum ping distance (clamped to 25m floor) void setMinPingDistance(double meters) { - _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; - debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + _configuredMinDistance = + meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog( + '[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); } final _statusController = StreamController.broadcast(); @@ -105,7 +107,8 @@ class GpsService { } if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); _updateStatus(GpsStatus.permissionDenied); return false; } @@ -143,7 +146,8 @@ class GpsService { // If denied forever, can't request again - user must go to settings if (current == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); return false; } @@ -176,7 +180,8 @@ class GpsService { // Ensure only one active position stream subscription exists. // startWatching() can be called multiple times (e.g. after permission flow). if (_positionSubscription != null) { - debugLog('[GPS] Existing position subscription found, restarting watcher'); + debugLog( + '[GPS] Existing position subscription found, restarting watcher'); await _positionSubscription?.cancel(); _positionSubscription = null; } @@ -185,7 +190,8 @@ class GpsService { final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); if (!serviceEnabled) { - debugLog('[GPS] Location services DISABLED at system level - user must enable in Settings'); + debugLog( + '[GPS] Location services DISABLED at system level - user must enable in Settings'); _updateStatus(GpsStatus.disabled); return; } @@ -199,18 +205,22 @@ class GpsService { final permission = await Geolocator.checkPermission(); final hasPermission = permission == LocationPermission.always || permission == LocationPermission.whileInUse; - debugLog('[GPS] Permission check: $permission (hasPermission=$hasPermission)'); + debugLog( + '[GPS] Permission check: $permission (hasPermission=$hasPermission)'); if (!hasPermission) { if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); } else { - debugLog('[GPS] Permission not granted - waiting for disclosure flow'); + debugLog( + '[GPS] Permission not granted - waiting for disclosure flow'); } _updateStatus(GpsStatus.permissionDenied); return; } } else { - debugLog('[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); + debugLog( + '[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); } debugLog('[GPS] Starting position stream listener...'); @@ -228,11 +238,13 @@ class GpsService { _positionSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, - distanceFilter: 10, // Trigger every 10m movement (check RX batches at 25m) + distanceFilter: + 10, // Trigger every 10m movement (check RX batches at 25m) ), ).listen( (position) { - debugLog('[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' 'lon=${position.longitude.toStringAsFixed(5)}, accuracy=${position.accuracy.toStringAsFixed(1)}m'); _lastPosition = position; _positionController.add(position); @@ -253,7 +265,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); - debugLog('[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; // Note: Don't emit via _positionController here — the stream listener @@ -261,7 +274,8 @@ class GpsService { // would cause duplicate position events (~0.15ms apart). _updateStatus(GpsStatus.locked); } catch (e) { - debugLog('[GPS] Initial position request failed: $e (will wait for stream updates)'); + debugLog( + '[GPS] Initial position request failed: $e (will wait for stream updates)'); // Will receive updates from stream } } @@ -303,19 +317,19 @@ class GpsService { final age = DateTime.now().difference(position.timestamp); return age <= maxGpsAgeForManualPing; } - + /// Check if GPS position has acceptable accuracy for pings (< 100m) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js bool isAccuracyAcceptableForPing(Position position) { return position.accuracy <= maxAccuracyMetersForPing; } - + /// Check if GPS position has acceptable accuracy for zone checks (< 50m) /// Reference: getValidGpsForZoneCheck() in wardrive.js bool isAccuracyAcceptableForZoneCheck(Position position) { return position.accuracy <= maxAccuracyMetersForZoneCheck; } - + /// Validate position for ping operation /// Checks freshness (< 60s old) and accuracy (< 100m) /// Returns null if valid, error message if invalid @@ -326,17 +340,17 @@ class GpsService { debugWarn('[GPS] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy if (!isAccuracyAcceptableForPing(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] Position too inaccurate: ${accuracy}m (max 100m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } - + /// Validate position for zone check operation /// Checks freshness (< 60s old) and accuracy (< 50m, stricter than ping) /// Returns null if valid, error message if invalid @@ -347,21 +361,22 @@ class GpsService { debugWarn('[GPS] [AUTH] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy (stricter for zone checks) if (!isAccuracyAcceptableForZoneCheck(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] [AUTH] Position too inaccurate: ${accuracy}m (max 50m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } /// Request a fresh GPS position from the hardware for auto-ping accuracy. /// On mobile, this forces a warm-start GPS read (typically < 1 second when /// GPS is already streaming). Falls back to lastPosition on timeout/error. - Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) async { // Simulator provides its own positions — use cached if (_simulatorEnabled) { return _lastPosition; @@ -372,7 +387,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: timeout, ); - debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; return position; diff --git a/lib/services/gps_simulator_service.dart b/lib/services/gps_simulator_service.dart index 92185b81..0c1b4499 100644 --- a/lib/services/gps_simulator_service.dart +++ b/lib/services/gps_simulator_service.dart @@ -10,10 +10,13 @@ import '../utils/debug_logger_io.dart'; enum SimulatorPattern { /// Move in a straight line in the configured direction straight, + /// Move in a circle around the start point circle, + /// Random walk with smooth direction changes randomWalk, + /// Follow a loaded route (KML/GPX) route, } @@ -143,7 +146,8 @@ class GpsSimulatorService { _circleAngle = 0; } - debugLog('[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); + debugLog( + '[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); } /// Start the simulator @@ -151,7 +155,8 @@ class GpsSimulatorService { if (_isRunning) return; _isRunning = true; - debugLog('[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); + debugLog( + '[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); // Emit initial position immediately _emitPosition(); @@ -184,7 +189,8 @@ class GpsSimulatorService { _targetHeading = 45; _routeIndex = 0; _routeProgress = 0; - debugLog('[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); + debugLog( + '[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); } /// Load route from KML file content @@ -236,7 +242,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing KML: $e'); @@ -263,10 +270,12 @@ class GpsSimulatorService { final lat = double.tryParse(pt.getAttribute('lat') ?? ''); final lon = double.tryParse(pt.getAttribute('lon') ?? ''); final eleElement = pt.findElements('ele').firstOrNull; - final alt = eleElement != null ? double.tryParse(eleElement.innerText) : null; + final alt = + eleElement != null ? double.tryParse(eleElement.innerText) : null; if (lat != null && lon != null) { - coordinates.add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); + coordinates + .add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); } } @@ -309,7 +318,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing GPX: $e'); @@ -320,18 +330,30 @@ class GpsSimulatorService { /// Extract route name from GPX document String _extractGpxName(XmlDocument document) { // Try track name first - final trkName = document.findAllElements('trk').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final trkName = document + .findAllElements('trk') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (trkName != null) return trkName; // Try route name - final rteName = document.findAllElements('rte').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final rteName = document + .findAllElements('rte') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (rteName != null) return rteName; // Try metadata name - final metaName = document.findAllElements('metadata').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final metaName = document + .findAllElements('metadata') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (metaName != null) return metaName; return 'Unnamed Route'; @@ -412,8 +434,10 @@ class GpsSimulatorService { // Calculate distance between current and next point final segmentDistanceM = _haversineDistance( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); if (segmentDistanceM < 1) { @@ -461,24 +485,31 @@ class GpsSimulatorService { final nextPoint = _routePoints[nextIndex]; final t = _routeProgress.clamp(0.0, 1.0); - _latitude = currentPoint.latitude + (nextPoint.latitude - currentPoint.latitude) * t; - _longitude = currentPoint.longitude + (nextPoint.longitude - currentPoint.longitude) * t; + _latitude = currentPoint.latitude + + (nextPoint.latitude - currentPoint.latitude) * t; + _longitude = currentPoint.longitude + + (nextPoint.longitude - currentPoint.longitude) * t; // Calculate heading towards next point _heading = _calculateBearing( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); } /// Haversine distance between two points in meters - double _haversineDistance(double lat1, double lon1, double lat2, double lon2) { + double _haversineDistance( + double lat1, double lon1, double lat2, double lon2) { const R = 6371000.0; // Earth radius in meters final dLat = (lat2 - lat1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180; final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * - sin(dLon / 2) * sin(dLon / 2); + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); final c = 2 * atan2(sqrt(a), sqrt(1 - a)); return R * c; } @@ -490,8 +521,8 @@ class GpsSimulatorService { final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; // Normalize to 0-360 } @@ -509,7 +540,8 @@ class GpsSimulatorService { // 1 degree latitude ≈ 111 km // 1 degree longitude ≈ 111 km * cos(latitude) final latChange = (distanceKm / 111) * cos(headingRad); - final lonChange = (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); + final lonChange = + (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); _latitude += latChange; _longitude += lonChange; @@ -530,7 +562,8 @@ class GpsSimulatorService { // Calculate position on circle final angleRad = _circleAngle * pi / 180; _latitude = _circleCenterLat + _circleRadius * cos(angleRad); - _longitude = _circleCenterLon + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); + _longitude = _circleCenterLon + + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); // Update heading to be tangent to circle _heading = (_circleAngle + 90) % 360; diff --git a/lib/services/meshcore/buffer_utils.dart b/lib/services/meshcore/buffer_utils.dart index 57345363..d7f80362 100644 --- a/lib/services/meshcore/buffer_utils.dart +++ b/lib/services/meshcore/buffer_utils.dart @@ -106,7 +106,6 @@ class BufferReader { } return value; } - } /// Buffer writer for creating binary data for MeshCore devices @@ -155,16 +154,16 @@ class BufferWriter { void writeCString(String string, int maxLength) { final encoded = utf8.encode(string); final bytes = Uint8List(maxLength); - + // Copy string bytes up to maxLength - 1 final copyLength = math.min(encoded.length, maxLength - 1); for (int i = 0; i < copyLength; i++) { bytes[i] = encoded[i]; } - + // Ensure last byte is null terminator bytes[maxLength - 1] = 0; - + writeBytes(bytes); } diff --git a/lib/services/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index 44873978..d92573c9 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -38,13 +38,17 @@ class ChannelService { // Always add #wardriving (required for TX) final wardrivingKey = CryptoService.getChannelKey(wardrivingChannelName); final wardrivingHash = CryptoService.computeChannelHash(wardrivingKey); - _allowedChannels[wardrivingChannelName] = _ChannelData(key: wardrivingKey, hash: wardrivingHash); + _allowedChannels[wardrivingChannelName] = + _ChannelData(key: wardrivingKey, hash: wardrivingHash); debugLog('[CHANNEL] Added: $wardrivingChannelName -> hash=$wardrivingHash'); // Add regional channels from API for (final name in channelNames) { - final channelName = name.toLowerCase() == 'public' ? 'Public' : - name.startsWith('#') ? name : '#$name'; + final channelName = name.toLowerCase() == 'public' + ? 'Public' + : name.startsWith('#') + ? name + : '#$name'; // Skip if already added if (_allowedChannels.containsKey(channelName)) continue; @@ -95,7 +99,8 @@ class ChannelService { /// Get all allowed channels for RX validation /// Returns a map of channel hash -> channel info for use with PacketValidator - static Map getAllowedChannelsForValidator() { + static Map + getAllowedChannelsForValidator() { final result = {}; for (final entry in _allowedChannels.entries) { result[entry.value.hash] = ( @@ -114,7 +119,8 @@ class ChannelService { /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the created channel /// @throws Exception if no empty slots or creation fails - static Future createWardrivingChannel(MeshCoreConnection connection) async { + static Future createWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Attempting to create channel: $wardrivingChannelName'); // Get all channels @@ -143,9 +149,11 @@ class ChannelService { final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); // Create the channel - debugLog('[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); + debugLog( + '[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); await connection.setChannel(emptyIdx, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); // Return channel info return ChannelInfo( @@ -161,7 +169,8 @@ class ChannelService { /// /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the wardriving channel - static Future ensureWardrivingChannel(MeshCoreConnection connection) async { + static Future ensureWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Looking up channel: $wardrivingChannelName'); // Scan ALL channels to find #wardriving or first empty slot @@ -179,7 +188,8 @@ class ChannelService { try { channel = await connection.getChannel(channelIdx); } catch (e) { - debugLog('[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); + debugLog( + '[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); await Future.delayed(const Duration(milliseconds: 100)); channel = await connection.getChannel(channelIdx); } @@ -189,7 +199,8 @@ class ChannelService { // Found existing #wardriving channel - return immediately! if (channel.name == wardrivingChannelName) { - debugLog('[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); + debugLog( + '[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); return channel; } @@ -211,16 +222,20 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { - debugError('[CHANNEL] No empty channel slots found in first $channelIdx channels'); + debugError( + '[CHANNEL] No empty channel slots found in first $channelIdx channels'); throw Exception( 'No empty channel slots available. Please free a channel slot on your companion first.', ); } - debugLog('[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); + debugLog( + '[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); - await connection.setChannel(firstEmptySlot, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); + await connection.setChannel( + firstEmptySlot, wardrivingChannelName, channelKey); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); return ChannelInfo( channelIndex: firstEmptySlot, @@ -230,7 +245,7 @@ class ChannelService { } /// Delete #wardriving channel on disconnect - /// + /// /// @param connection - Active MeshCore connection /// @param channelIdx - Index of the channel to delete static Future deleteWardrivingChannel( diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 66bc30c7..1ed79776 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,8 +17,10 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 - final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) - final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) + final String? + firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? + pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, @@ -47,7 +49,10 @@ class SelfInfo { }); /// Get public key as hex string - String get publicKeyHex => publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + String get publicKeyHex => publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// MeshCore connection manager @@ -67,10 +72,13 @@ class MeshCoreConnection { final BluetoothService _bluetooth; bool _disposed = false; final _stepController = StreamController.broadcast(); - final _channelMessageController = StreamController.broadcast(); + final _channelMessageController = + StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); - final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); - final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _logRxDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _controlDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -108,7 +116,8 @@ class MeshCoreConnection { int? _lastBatteryMilliVolts; // millivolts or null if not supported Timer? _batteryTimer; - MeshCoreConnection({required BluetoothService bluetooth}) : _bluetooth = bluetooth { + MeshCoreConnection({required BluetoothService bluetooth}) + : _bluetooth = bluetooth { _dataSubscription = _bluetooth.dataStream.listen(_onFrameReceived); } @@ -116,16 +125,19 @@ class MeshCoreConnection { Stream get stepStream => _stepController.stream; /// Stream of channel messages (for RX pings) - Stream get channelMessageStream => _channelMessageController.stream; + Stream get channelMessageStream => + _channelMessageController.stream; /// Stream of raw data pushes Stream> get rawDataStream => _rawDataController.stream; /// Stream of LogRxData packets (for unified RX handler) - Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => _logRxDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => + _logRxDataController.stream; /// Stream of ControlData packets (for discovery responses) - Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + _controlDataController.stream; /// Stream of TraceData packets (for trace path responses) /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly @@ -173,13 +185,16 @@ class MeshCoreConnection { /// Wardriving channel hash (for echo correlation) - null if not connected int? get wardrivingChannelHash { final channel = _wardrivingChannel; - return channel != null ? CryptoService.computeChannelHash(channel.secret) : null; + return channel != null + ? CryptoService.computeChannelHash(channel.secret) + : null; } void _updateStep(ConnectionStep step) { _currentStep = step; if (_disposed || _stepController.isClosed) { - debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); + debugLog( + '[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -189,7 +204,8 @@ class MeshCoreConnection { /// Execute the full connection workflow /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info - Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect( + String deviceId, List deviceModels) async { if (_disposed) { throw Exception('Connection instance has been disposed'); } @@ -206,7 +222,8 @@ class MeshCoreConnection { // Step 3: Device Query _updateStep(ConnectionStep.deviceQuery); - _deviceInfo = await deviceQuery(ProtocolConstants.supportedCompanionProtocolVersion); + _deviceInfo = await deviceQuery( + ProtocolConstants.supportedCompanionProtocolVersion); // Step 3b: Get Self Info (contains public key) // This is critical for geo-auth API authentication @@ -216,7 +233,8 @@ class MeshCoreConnection { if (pubKeyHex == null) { throw Exception('getSelfInfo() returned null public key'); } - debugLog('[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); } catch (e) { debugError('[CONN] Failed to get self info (public key): $e'); // Public key is REQUIRED for geo-auth API @@ -232,9 +250,11 @@ class MeshCoreConnection { final matchedModel = _deviceModel; if (matchedModel != null) { deviceModelMatched = true; - debugLog('[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); + debugLog( + '[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); } else { - debugLog('[CONN] Device model not recognized - user must manually select power level for reporting'); + debugLog( + '[CONN] Device model not recognized - user must manually select power level for reporting'); } // Step 5: Time Sync @@ -249,20 +269,24 @@ class MeshCoreConnection { if (authResult == null || authResult['success'] != true) { final reason = authResult?['reason'] ?? 'unknown'; final message = authResult?['message'] ?? 'Authentication failed'; - debugError('[CONN] API session acquisition failed: $reason - $message'); + debugError( + '[CONN] API session acquisition failed: $reason - $message'); // Throw with reason code prefix for proper error handling throw Exception('AUTH_FAILED:$reason:$message'); } - debugLog('[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); + debugLog( + '[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); } else { - debugLog('[CONN] No auth callback set, skipping API session acquisition'); + debugLog( + '[CONN] No auth callback set, skipping API session acquisition'); } // Step 7: Channel Setup _updateStep(ConnectionStep.channelSetup); debugLog('[CONN] Creating #wardriving channel'); _wardrivingChannel = await ChannelService.ensureWardrivingChannel(this); - debugLog('[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); + debugLog( + '[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); // Step 8: GPS Init (handled externally) _updateStep(ConnectionStep.gpsInit); @@ -282,7 +306,10 @@ class MeshCoreConnection { // This may fail on older firmware (< v1.11.0) _startNoiseFloorPolling(); - return (deviceModel: _deviceModel, deviceModelMatched: deviceModelMatched); + return ( + deviceModel: _deviceModel, + deviceModelMatched: deviceModelMatched + ); } catch (e) { debugError('[CONN] Connection failed: $e'); _updateStep(ConnectionStep.error); @@ -338,24 +365,25 @@ class MeshCoreConnection { /// Match manufacturer string to device model /// Reference: parseDeviceModel() in wardrive.js - DeviceModel? _matchDeviceModel(String manufacturer, List models) { + DeviceModel? _matchDeviceModel( + String manufacturer, List models) { // Strip build suffix (e.g., "nightly-e31c46f") final cleanManufacturer = manufacturer.split(' ').first; - + for (final model in models) { if (manufacturer.contains(model.manufacturer) || cleanManufacturer.contains(model.manufacturer)) { return model; } } - + // Try partial match on short name for (final model in models) { if (manufacturer.toLowerCase().contains(model.shortName.toLowerCase())) { return model; } } - + return null; } @@ -364,12 +392,14 @@ class MeshCoreConnection { if (frame.isEmpty) return; try { - debugLog('[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); - + debugLog( + '[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); + final reader = BufferReader(frame); final responseCode = reader.readByte(); - - debugLog('[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); + + debugLog( + '[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); switch (responseCode) { case ResponseCodes.ok: @@ -378,14 +408,17 @@ class MeshCoreConnection { _setTimeCompleter = null; break; case ResponseCodes.err: - final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; + final errorCode = + reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success if (_setTimeCompleter != null) { if (errorCode == 6) { - debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + debugLog( + '[CONN] Time sync not needed (error code 6) - treating as success'); } else { - debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + debugWarn( + '[CONN] Time sync error (code $errorCode) - continuing anyway'); } _setTimeCompleter?.complete(); _setTimeCompleter = null; @@ -440,7 +473,8 @@ class MeshCoreConnection { break; default: // Log unhandled response codes (like JS implementation) - debugLog('[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); + debugLog( + '[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); break; } } catch (e, stack) { @@ -490,7 +524,8 @@ class MeshCoreConnection { // path_hash_mode: 1 byte (v10+) if (reader.remainingBytesCount >= 1) { pathHashMode = reader.readByte(); - debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + debugLog( + '[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); } } @@ -513,12 +548,12 @@ class MeshCoreConnection { reader.readBytes(32); // skip public key debugLog('[CONN] Manufacturer: $manufacturer'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturer, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } @@ -539,14 +574,14 @@ class MeshCoreConnection { // Skip additional fields added in newer firmware versions // These fields exist between publicKey and name if (reader.remainingBytesCount >= 22) { - reader.readInt32LE(); // advLat - reader.readInt32LE(); // advLon - reader.readBytes(3); // reserved - reader.readByte(); // manualAddContacts + reader.readInt32LE(); // advLat + reader.readInt32LE(); // advLon + reader.readBytes(3); // reserved + reader.readByte(); // manualAddContacts reader.readUInt32LE(); // radioFreq reader.readUInt32LE(); // radioBw - reader.readByte(); // radioSf - reader.readByte(); // radioCr + reader.readByte(); // radioSf + reader.readByte(); // radioCr } // Read name from remaining bytes @@ -561,7 +596,8 @@ class MeshCoreConnection { ); _selfInfo = selfInfo; - debugLog('[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; @@ -697,7 +733,8 @@ class MeshCoreConnection { // Consume any remaining bytes (firmware may send extended format) if (reader.remainingBytesCount > 0) { final extraBytes = reader.readRemainingBytes(); - debugLog('[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); + debugLog( + '[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); } _batteryController.add(percent); // Emit percentage to stream @@ -719,10 +756,13 @@ class MeshCoreConnection { void _onExportContactResponse(BufferReader reader) { try { final advertPacketBytes = reader.readRemainingBytes(); - final hexString = advertPacketBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + final hexString = advertPacketBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); final contactUri = 'meshcore://$hexString'; - debugLog('[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); + debugLog( + '[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); _exportContactCompleter?.complete(contactUri); _exportContactCompleter = null; @@ -755,7 +795,8 @@ class MeshCoreConnection { /// Get device self info (includes public key) /// Reference: getSelfInfo() in connection.js - Future getSelfInfo({Duration timeout = const Duration(seconds: 5)}) async { + Future getSelfInfo( + {Duration timeout = const Duration(seconds: 5)}) async { _selfInfoCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -845,10 +886,11 @@ class MeshCoreConnection { final future = _channelInfoCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.getChannel); // 31 (0x1F) + data.writeByte(CommandCodes.getChannel); // 31 (0x1F) data.writeByte(channelIdx); final bytes = data.toBytes(); - debugLog('[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); + debugLog( + '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); await _bluetooth.write(bytes); return future.timeout( @@ -926,7 +968,8 @@ class MeshCoreConnection { Future findChannelBySecret(Uint8List secret) async { final channels = await getChannels(); try { - return channels.firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); + return channels + .firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); } catch (e) { return null; // Not found } @@ -943,7 +986,8 @@ class MeshCoreConnection { /// Send channel text message (for TX pings) /// Reference: sendCommandSendChannelTxtMsg in connection.js - Future sendChannelTextMessage(int txtType, int channelIdx, int senderTimestamp, String text) async { + Future sendChannelTextMessage( + int txtType, int channelIdx, int senderTimestamp, String text) async { _sentCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -982,7 +1026,8 @@ class MeshCoreConnection { debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await sendChannelTextMessage(TxtTypes.plain, channel.channelIndex, timestamp, message); + await sendChannelTextMessage( + TxtTypes.plain, channel.channelIndex, timestamp, message); } /// Send discovery request to find nearby repeaters/rooms @@ -1010,11 +1055,12 @@ class MeshCoreConnection { '${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendControlData); // 0x37 - data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ - data.writeByte(DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM - data.writeBytes(tag); // 4-byte random tag - data.writeUInt32LE(0); // timestamp = 0 (discover all) + data.writeByte(CommandCodes.sendControlData); // 0x37 + data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ + data.writeByte( + DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM + data.writeBytes(tag); // 4-byte random tag + data.writeUInt32LE(0); // timestamp = 0 (discover all) await _sendToRadio(data); return tag; @@ -1023,31 +1069,41 @@ class MeshCoreConnection { /// Send trace path to a specific repeater (targeted ping / zero-hop trace) /// Returns the 4-byte tag used for matching the response /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) - Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) async { final random = Random.secure(); final tag = Uint8List.fromList([ - random.nextInt(256), random.nextInt(256), - random.nextInt(256), random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), ]); // Trace uses bitshift encoding: actual_bytes = 1 << path_sz // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 final int pathSz; switch (hopBytes) { - case 4: pathSz = 2; break; - case 2: pathSz = 1; break; - default: pathSz = 0; break; + case 4: + pathSz = 2; + break; + case 2: + pathSz = 1; + break; + default: + pathSz = 0; + break; } final int flags = pathSz & 0x03; - debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + debugLog( + '[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendTracePath); // 0x24 - data.writeBytes(tag); // 4-byte tag - data.writeUInt32LE(0); // auth_code = 0 - data.writeByte(flags); // flags with path_sz in bits 0-1 - data.writeBytes(repeaterIdBytes); // target repeater ID + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID await _sendToRadio(data); return tag; } @@ -1061,12 +1117,13 @@ class MeshCoreConnection { /// Export signed contact URI for API authentication /// Returns meshcore:// URI containing signed ADVERT packet - Future exportContact({Duration timeout = const Duration(seconds: 5)}) async { + Future exportContact( + {Duration timeout = const Duration(seconds: 5)}) async { _exportContactCompleter = Completer(); final future = _exportContactCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.exportContact); // 0x11 + data.writeByte(CommandCodes.exportContact); // 0x11 await _sendToRadio(data); return future.timeout( @@ -1129,7 +1186,8 @@ class MeshCoreConnection { _noiseFloorFailCount++; debugLog('[CONN] Noise floor fetch failed ($_noiseFloorFailCount/3): $e'); if (_noiseFloorFailCount >= 3) { - debugLog('[CONN] Noise floor polling stopped after 3 consecutive failures'); + debugLog( + '[CONN] Noise floor polling stopped after 3 consecutive failures'); _stopNoiseFloorPolling(); } } finally { diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index 30da8862..ea6f559b 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -12,28 +12,43 @@ class CryptoService { /// Fixed key for "Public" channel (non-hashtag channels) /// From MeshCore default: 8b3387e9c5cdea6ac9e5edbaa115cd72 static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a channel name using SHA-256 - /// + /// /// Matches JS implementation: `sha256(channelName).subarray(0, 16)` - /// + /// /// @param channelName - Channel name (must start with # for hashtag channels) /// @returns 16-byte channel key /// @throws FormatException if channel name is invalid static Uint8List deriveChannelKey(String channelName) { debugLog('[CRYPTO] Deriving channel key for: $channelName'); - + // Validate channel name format: must start with # and contain only letters, numbers, and dashes if (!channelName.startsWith('#')) { - throw FormatException('Channel name must start with # (got: "$channelName")'); + throw FormatException( + 'Channel name must start with # (got: "$channelName")'); } - + // Normalize channel name to lowercase (MeshCore convention) final normalizedName = channelName.toLowerCase(); - + // Check that the part after # contains only letters, numbers, and dashes final nameWithoutHash = normalizedName.substring(1); if (!RegExp(r'^[a-z0-9-]+$').hasMatch(nameWithoutHash)) { @@ -42,16 +57,17 @@ class CryptoService { 'Only letters, numbers, and dashes are allowed.', ); } - + // Hash using SHA-256 final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); - + // Take the first 16 bytes of the hash as the channel key final channelKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - - debugLog('[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); - + + debugLog( + '[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); + return channelKey; } @@ -65,12 +81,13 @@ class CryptoService { final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + debugLog( + '[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); return scopeKey; } /// Get channel key for any channel (handles both Public and hashtag channels) - /// + /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") /// @returns 16-byte channel key static Uint8List getChannelKey(String channelName) { @@ -83,9 +100,9 @@ class CryptoService { } /// Compute channel hash from channel secret (first byte of SHA-256) - /// + /// /// Used for identifying echo packets that match our channel - /// + /// /// @param channelSecret - The 16-byte channel secret /// @returns Channel hash (first byte of SHA-256) static int computeChannelHash(Uint8List channelSecret) { @@ -94,9 +111,9 @@ class CryptoService { } /// Decrypt channel message using AES-ECB mode - /// + /// /// MeshCore uses AES-128-ECB for channel message encryption - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decrypted message bytes @@ -105,17 +122,18 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Decrypting message (${encryptedPayload.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(false, params); // false = decrypt mode - + // Decrypt the payload final decrypted = Uint8List(encryptedPayload.length); var offset = 0; @@ -137,7 +155,7 @@ class CryptoService { } /// Encrypt channel message using AES-ECB mode - /// + /// /// @param plaintext - The message bytes to encrypt /// @param channelKey - The 16-byte channel key /// @returns Encrypted message bytes @@ -146,29 +164,30 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Encrypting message (${plaintext.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Add PKCS7 padding final padded = _addPkcs7Padding(plaintext, 16); - + // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(true, params); // true = encrypt mode - + // Encrypt the payload final encrypted = Uint8List(padded.length); var offset = 0; - + while (offset < padded.length) { cipher.processBlock(padded, offset, encrypted, offset); offset += cipher.blockSize; } - + debugLog('[CRYPTO] Encrypted successfully (${encrypted.length} bytes)'); return encrypted; } catch (e) { @@ -189,9 +208,9 @@ class CryptoService { } /// Parse channel message to extract text content - /// + /// /// Decrypts and decodes the message, returning the text if printable - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decoded text or null if not printable @@ -202,15 +221,17 @@ class CryptoService { try { final decrypted = decryptChannelMessage(encryptedPayload, channelKey); final text = utf8.decode(decrypted, allowMalformed: true); - + // Check if text is printable (contains mostly ASCII printable characters) - final printableCount = text.codeUnits.where((c) => c >= 32 && c <= 126).length; + final printableCount = + text.codeUnits.where((c) => c >= 32 && c <= 126).length; final printableRatio = printableCount / text.length; - + if (printableRatio > 0.8) { return text; } else { - debugWarn('[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); + debugWarn( + '[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); return null; } } catch (e) { diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 23dd9d60..688eac4d 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -34,7 +34,10 @@ class DiscTracker { /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. final int hopBytes; - DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); + DiscTracker( + {this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -48,7 +51,8 @@ class DiscTracker { Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); - debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; startTime = DateTime.now(); @@ -58,12 +62,14 @@ class DiscTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking and return collected nodes List stopTracking() { - debugLog('[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); + debugLog( + '[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); final result = nodes.values.toList(); @@ -116,14 +122,16 @@ class DiscTracker { // Check if this is a discovery response (upper nibble = 0x90) if (upperNibble != DiscoveryConstants.discoverRespFlag) { - debugLog('[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); return false; } // Check node type (lower nibble must be REPEATER=0x01 or ROOM=0x02) if (lowerNibble != DiscoveryConstants.nodeTypeRepeater && lowerNibble != DiscoveryConstants.nodeTypeRoom) { - debugLog('[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); + debugLog( + '[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); return false; } @@ -135,28 +143,36 @@ class DiscTracker { // Extract public key (bytes 7-38) final pubkey = rawBytes.sublist(7, 39); - final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + final pubkeyHex = pubkey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); // Get repeater ID (first N hex chars based on hopBytes setting) final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); + debugLog( + '[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // Check RSSI (carpeater failsafe) if (disableRssiFilter) { - debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[DISC] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(localRssi)) { - debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); return false; } - final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater + ? 'REPEATER' + : 'ROOM'; debugLog('[DISC] Received response from $repeaterId ($nodeType): ' 'localSnr=${localSnr.toStringAsFixed(2)}, remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' @@ -212,12 +228,14 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") - final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String + pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) DiscoveredNode({ required this.repeaterId, @@ -229,8 +247,10 @@ class DiscoveredNode { }); /// Get node type as display string - String get nodeTypeName => nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + String get nodeTypeName => + nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; /// Get short display label: "(R)" for REPEATER, "(RM)" for ROOM - String get nodeTypeLabel => nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; + String get nodeTypeLabel => + nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; } diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index 49e1f0a4..6c0f41ab 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -67,14 +67,17 @@ class PacketMetadata { final int rssi = data['lastRssi'] as int; // Dump raw packet for debugging - final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); + final rawHex = raw + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); + debugLog( + '[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 @@ -92,7 +95,8 @@ class PacketMetadata { final int pathHashCount = pathLenRaw & 63; final int pathByteLen = pathHashCount * pathHashSize; - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); // Path data starts after path length byte @@ -105,11 +109,13 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; if (payloadOffset > raw.length) { - throw RangeError('Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + throw RangeError( + 'Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); } final Uint8List encryptedPayload = raw.sublist(payloadOffset); - debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' @@ -155,19 +161,22 @@ class PacketMetadata { /// Check if packet is GROUP_TEXT (channel message, header 0x15) bool get isGroupText { // Extract payload type from header - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.grpTxt; } /// Check if packet is ADVERT (node advertisement, header 0x11) bool get isAdvert { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.advert; } /// Check if packet is TRACE (trace path response, header 0x26) bool get isTrace { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.trace; } @@ -195,12 +204,18 @@ class PacketMetadata { /// Convert N bytes to uppercase hex string String _bytesToHex(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// Static version for use in factory constructor static String _bytesToHexStatic(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } @override diff --git a/lib/services/meshcore/packet_parser.dart b/lib/services/meshcore/packet_parser.dart index 6dfee5b6..84842eea 100644 --- a/lib/services/meshcore/packet_parser.dart +++ b/lib/services/meshcore/packet_parser.dart @@ -253,12 +253,12 @@ class ChannelInfo { final channelIndex = reader.readByte(); final name = reader.readCString(32); final remainingBytes = reader.remainingBytesCount; - + // Protocol v8 uses 16-byte (128-bit) keys, v1 used 32-byte keys if (remainingBytes != 16 && remainingBytes != 32) { throw Exception('ChannelInfo has unexpected key length: $remainingBytes'); } - + return ChannelInfo( channelIndex: channelIndex, name: name, diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index e9cec944..0b6af86f 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -12,7 +12,7 @@ class PacketValidator { /// Packets stronger than this are likely from co-located repeaters /// Reference: MAX_RX_RSSI_THRESHOLD in wardrive.js static const int maxRssiThreshold = -30; - + /// Minimum printable character ratio (60%) /// Lowered from 90% to allow emojis and Unicode in messages /// Still filters out completely corrupted data @@ -24,33 +24,40 @@ class PacketValidator { /// When true, skip RSSI carpeater check (user setting) final bool disableRssiFilter; - PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); + PacketValidator( + {required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) - Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { + Future validate(PacketMetadata metadata, + {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' '); debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); - debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); - debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' + debugLog( + '[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); + debugLog( + '[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) if (skipRssiCheck) { debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); } else if (isCarpeater(metadata.rssi)) { - debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' + debugLog( + '[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); } else { - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); + debugLog( + '[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } // VALIDATION 2: Check packet type @@ -83,7 +90,8 @@ class PacketValidator { // Extract channel hash final channelHash = metadata.channelHash!; - debugLog('[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); // Check if channel is in allowed list final channelInfo = allowedChannels[channelHash]; @@ -109,7 +117,8 @@ class PacketValidator { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); + debugLog( + '[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); return ValidationResult.failed('decrypted too short'); } @@ -122,21 +131,24 @@ class PacketValidator { // Remove trailing nulls and trim plaintext = plaintext.replaceAll(RegExp(r'\x00+$'), '').trim(); } catch (e) { - debugLog('[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); + debugLog( + '[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); return ValidationResult.failed('decode failed'); } // Sanitize for logging: remove replacement characters to avoid Flutter UTF-8 warnings final sanitizedForLog = plaintext - .replaceAll('\uFFFD', '') // Remove replacement characters - .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII - final logPreview = sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); + .replaceAll('\uFFFD', '') // Remove replacement characters + .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII + final logPreview = + sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); debugLog('[RX FILTER] Decrypted message (${plaintext.length} chars): ' '"$logPreview${sanitizedForLog.length > 60 ? '...' : ''}"'); // Check printable ratio final printableRatio = getPrintableRatio(plaintext); - debugLog('[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' + debugLog( + '[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' '(threshold: ${(minPrintableRatio * 100).toFixed(1)}%)'); if (printableRatio < minPrintableRatio) { @@ -163,7 +175,8 @@ class PacketValidator { return ValidationResult.failed(nameResult.reason); } - debugLog('[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); + debugLog( + '[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); return ValidationResult.success(); } @@ -199,7 +212,6 @@ class PacketValidator { return printableCount / text.length; } - /// Parse ADVERT packet name field /// Reference: parseAdvertName() in wardrive.js lines 3353-3419 static AdvertNameResult parseAdvertName(Uint8List payload) { @@ -221,7 +233,8 @@ class PacketValidator { // Read flags byte from appData final flags = payload[appDataOffset]; - debugLog('[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); // Flag masks (from advert.js) const advNameMask = 0x80; @@ -259,7 +272,8 @@ class PacketValidator { // Remove trailing nulls and whitespace name = name.replaceAll(RegExp(r'\x00+$'), '').trim(); - debugLog('[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); + debugLog( + '[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); if (name.isEmpty) { return const AdvertNameResult( @@ -271,7 +285,8 @@ class PacketValidator { // Check if name is printable (use same threshold as messages) final printableRatio = getPrintableRatio(name); - debugLog('[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); + debugLog( + '[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); if (printableRatio < minPrintableRatio) { return AdvertNameResult( diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 3dee89f4..1e2de5ed 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -18,12 +18,14 @@ class BleUuids { /// Nordic UART Service UUID static const String serviceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E'; - + /// RX Characteristic (we write to this, device reads from it) - static const String characteristicRxUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; - + static const String characteristicRxUuid = + '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; + /// TX Characteristic (device writes to this, we read from it) - static const String characteristicTxUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; + static const String characteristicTxUuid = + '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; } /// Command codes sent to device @@ -63,7 +65,8 @@ class CommandCodes { static const int signData = 34; static const int signFinish = 35; static const int sendTracePath = 36; - static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) + static const int sendControlData = + 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE @@ -115,7 +118,8 @@ class PushCodes { static const int newAdvert = 0x8A; static const int telemetryResponse = 0x8B; static const int binaryResponse = 0x8C; - static const int controlData = 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) + static const int controlData = + 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) } /// Text message types @@ -140,11 +144,11 @@ class StatsTypes { class PacketHeader { PacketHeader._(); - static const int routeMask = 0x03; // 2-bits + static const int routeMask = 0x03; // 2-bits static const int typeShift = 2; - static const int typeMask = 0x0F; // 4-bits + static const int typeMask = 0x0F; // 4-bits static const int verShift = 6; - static const int verMask = 0x03; // 2-bits + static const int verMask = 0x03; // 2-bits } /// Route types diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 0451fffc..c78829a2 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -9,14 +9,14 @@ import 'packet_validator.dart'; /// Reference: handleRxLogging() + handleRxBatching() in wardrive.js (lines 3812-4140) class RxLogger { bool isWardriving = false; - + /// Map of repeaterId (hex) -> RxBatch final Map _batchBuffer = {}; - + /// Configuration constants static const int batchDistanceMeters = 25; static const Duration batchTimeout = Duration(seconds: 30); - + /// Callback for batched/finalized RX entries (API queue posting) final Future Function(RxApiEntry) onRxEntry; @@ -67,14 +67,15 @@ class RxLogger { PacketValidator validator, ) async { if (!isWardriving) return false; - + try { debugLog('[RX LOG] Processing packet for passive logging'); - + // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info if (metadata.pathHashCount == 0) { - debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); + debugLog( + '[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } @@ -88,7 +89,8 @@ class RxLogger { // CARpeater check: the carpeater is co-located with us, so it only // appears as the last hop (the delivery repeater) on RX packets - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); return false; @@ -98,7 +100,8 @@ class RxLogger { carpeaterStripped = true; reportedSnr = null; reportedRssi = null; - debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + debugLog( + '[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); } else { repeaterId = lastHopHex; } @@ -114,14 +117,18 @@ class RxLogger { // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore // Skip for CARpeater pass-through (CARpeater itself was already handled) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(repeaterId)) { + debugLog( + '[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging // Skip RSSI check for CARpeater pass-through - final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); + final validation = + await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -131,12 +138,14 @@ class RxLogger { // Log carpeater drops to error log (without auto-switching) if (validation.reason == 'carpeater-rssi') { - onCarpeaterDrop?.call(repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); + onCarpeaterDrop?.call( + repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); } return false; } - debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + debugLog( + '[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); @@ -172,15 +181,17 @@ class RxLogger { // IMPORTANT: Use the batch's bestObservation which has the FIRST location // where we heard this repeater, not the current GPS location. // This ensures map pins stay at the original location. - final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; + final batchedObservation = + _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { - debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' + debugLog( + '[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } - + return true; } catch (error, stackTrace) { debugError('[RX LOG] Error processing passive RX: $error'); @@ -223,7 +234,8 @@ class RxLogger { ); _batchBuffer[repeaterId] = buffer; wasKept = true; // New repeater, observation is kept - debugLog('[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); + debugLog( + '[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); // Start 30-second timeout timer for this repeater buffer.timeoutTimer = Timer(batchTimeout, () { @@ -250,8 +262,8 @@ class RxLogger { rssi: rssi, pathLength: pathLength, header: header, - lat: buffer.firstLocation.lat, // Keep original location - lon: buffer.firstLocation.lon, // Keep original location + lat: buffer.firstLocation.lat, // Keep original location + lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, ); @@ -276,7 +288,8 @@ class RxLogger { '(threshold=${batchDistanceMeters}m)'); if (distance >= batchDistanceMeters) { - debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); + debugLog( + '[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); await _flushRepeater(repeaterId); } @@ -285,43 +298,45 @@ class RxLogger { /// Check all active RX batches for distance threshold on GPS position update /// Called from GPS service when position changes - Future checkDistanceTriggers(({double lat, double lon}) currentLocation) async { + Future checkDistanceTriggers( + ({double lat, double lon}) currentLocation) async { if (_batchBuffer.isEmpty) { return; // No active batches to check } - debugLog('[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); - + debugLog( + '[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); + final repeatersToFlush = []; - + // Check each active batch for (final entry in _batchBuffer.entries) { final repeaterId = entry.key; final buffer = entry.value; - + final distance = _calculateHaversineDistance( currentLocation.lat, currentLocation.lon, buffer.firstLocation.lat, buffer.firstLocation.lon, ); - + debugLog('[RX BATCH] Distance check for repeater $repeaterId: ' '${distance.toStringAsFixed(2)}m from first observation ' '(threshold=${batchDistanceMeters}m)'); - + if (distance >= batchDistanceMeters) { debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, ' 'marking for flush'); repeatersToFlush.add(repeaterId); } } - + // Flush all repeaters that met the distance threshold for (final repeaterId in repeatersToFlush) { await _flushRepeater(repeaterId); } - + if (repeatersToFlush.isNotEmpty) { debugLog('[RX BATCH] Flushed ${repeatersToFlush.length} repeater(s) ' 'due to GPS movement'); @@ -331,20 +346,20 @@ class RxLogger { /// Flush a single repeater's batch - post best observation to API Future _flushRepeater(String repeaterId) async { debugLog('[RX BATCH] Flushing repeater $repeaterId'); - + final buffer = _batchBuffer[repeaterId]; if (buffer == null) { debugLog('[RX BATCH] No buffer to flush for repeater $repeaterId'); return; } - + // Clear timeout timer if it exists buffer.timeoutTimer?.cancel(); buffer.timeoutTimer = null; debugLog('[RX BATCH] Cleared timeout timer for repeater $repeaterId'); - + final best = buffer.bestObservation; - + // Build API entry using BEST observation's location final entry = RxApiEntry( repeaterId: repeaterId, @@ -357,13 +372,13 @@ class RxLogger { timestamp: best.timestamp, metadata: best.metadata, ); - + debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' 'location=${best.lat.toStringAsFixed(5)},${best.lon.toStringAsFixed(5)}'); - + // Queue for API posting await onRxEntry(entry); - + // Remove from buffer _batchBuffer.remove(repeaterId); debugLog('[RX BATCH] Repeater $repeaterId removed from buffer'); @@ -373,18 +388,18 @@ class RxLogger { Future flushAllBatches({String trigger = 'session_end'}) async { debugLog('[RX BATCH] Flushing all repeaters, trigger=$trigger, ' 'active_repeaters=${_batchBuffer.length}'); - + if (_batchBuffer.isEmpty) { debugLog('[RX BATCH] No repeaters to flush'); return; } - + // Iterate all repeaters and flush each one final repeaterIds = _batchBuffer.keys.toList(); for (final repeaterId in repeaterIds) { await _flushRepeater(repeaterId); } - + debugLog('[RX BATCH] All repeaters flushed: ${repeaterIds.length} total'); } @@ -397,18 +412,18 @@ class RxLogger { double lon2, ) { const earthRadiusM = 6371000.0; - + final dLat = _degreesToRadians(lat2 - lat1); final dLon = _degreesToRadians(lon2 - lon1); - + final a = sin(dLat / 2) * sin(dLat / 2) + cos(_degreesToRadians(lat1)) * cos(_degreesToRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2); - + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - + return earthRadiusM * c; } @@ -427,12 +442,12 @@ class RxLogger { /// Dispose of resources void dispose() { debugLog('[RX LOG] Disposing RX Logger'); - + // Cancel all timeout timers for (final buffer in _batchBuffer.values) { buffer.timeoutTimer?.cancel(); } - + _batchBuffer.clear(); isWardriving = false; } @@ -454,8 +469,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -481,8 +496,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart index ad7b5297..265c6e42 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -6,9 +6,9 @@ import '../../utils/debug_logger_io.dart'; /// Result of a trace path probe to a specific repeater class TraceResult { final String targetRepeaterId; - final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) - final int localRssi; // RSSI from BLE event metadata - final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) final bool success; const TraceResult({ @@ -52,7 +52,8 @@ class TraceTracker { Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); - debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; _expectedTag = tag; @@ -65,7 +66,8 @@ class TraceTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); } /// Handle incoming trace data packet (0x89) @@ -86,7 +88,8 @@ class TraceTracker { try { // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes if (rawBytes.length < 11) { - debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + debugLog( + '[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); return false; } @@ -99,7 +102,8 @@ class TraceTracker { final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; - debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + debugLog( + '[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); // Extract tag (bytes 3-6) final tag = rawBytes.sublist(3, 7); @@ -127,7 +131,8 @@ class TraceTracker { final pathEnd = pathStart + (hopCount * hashSize); if (rawBytes.length < pathEnd) { - debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + debugLog( + '[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); return false; } @@ -135,7 +140,10 @@ class TraceTracker { String repeaterId = ''; if (hopCount > 0) { final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); - repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + repeaterId = idBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } // Extract path SNRs (hopCount+1 bytes after path hashes) @@ -179,7 +187,8 @@ class TraceTracker { /// Stop tracking and return result TraceResult? stopTracking() { - debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + debugLog( + '[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); final result = _result; isListening = false; @@ -192,7 +201,8 @@ class TraceTracker { /// Handle trace window completion void _endWindow() { - debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + debugLog( + '[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); final result = _result; isListening = false; diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 575536ee..b4090e26 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -29,7 +29,8 @@ class TxTracker { /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) - void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? + onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -43,7 +44,7 @@ class TxTracker { bool disableRssiFilter = false; /// Start tracking echoes for a sent ping - /// + /// /// @param payload - The message text sent (for content verification) /// @param channelIdx - Channel index where ping was sent /// @param channelHash - Expected channel hash for validation @@ -58,8 +59,9 @@ class TxTracker { }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); - debugLog('[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); - + debugLog( + '[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + isListening = true; sentTimestamp = DateTime.now(); sentPayload = payload; @@ -67,26 +69,29 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); - + // Start window timer _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, stopTracking); - - debugLog('[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); + + debugLog( + '[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking echoes void stopTracking() { - debugLog('[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); - + debugLog( + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); + isListening = false; _windowTimer?.cancel(); _windowTimer = null; - + // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + debugLog( + '[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); } } } @@ -95,12 +100,13 @@ class TxTracker { /// Returns true if packet was an echo and tracked Future handlePacket(PacketMetadata metadata) async { if (!isListening) return false; - + final originalPayload = sentPayload; final expectedHash = expectedChannelHash; - + try { - debugLog('[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog( + '[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); // VALIDATION STEP 1: Header validation (must be GROUP_TEXT) if (!metadata.isGroupText) { @@ -108,12 +114,14 @@ class TxTracker { '(header=0x${metadata.header.toRadixString(16).padLeft(2, '0')})'); return false; } - debugLog('[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops if (metadata.pathHashCount == 0) { - debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); + debugLog( + '[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } @@ -125,14 +133,16 @@ class TxTracker { double? reportedSnr = metadata.snr; int? reportedRssi = metadata.rssi; - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); return false; } // Multi-hop: strip CARpeater, report underlying repeater (second hop) final underlyingHex = metadata.getHopHex(1)!; - debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + debugLog( + '[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); pathHex = underlyingHex; carpeaterStripped = true; reportedSnr = null; @@ -143,15 +153,19 @@ class TxTracker { // that heard our TX: the radio reports last-hop link quality, so for any // multi-hop relay the metrics describe a different link entirely. if (!carpeaterStripped && metadata.pathHashCount > 1) { - debugLog('[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' + debugLog( + '[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); return false; } // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { - debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(pathHex.toUpperCase())) { + debugLog( + '[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } @@ -160,20 +174,26 @@ class TxTracker { if (carpeaterStripped) { debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[TX LOG] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(metadata.rssi)) { - debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); - debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); - onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); + debugLog( + '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); + onCarpeaterDrop?.call( + pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) } else { - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); + debugLog( + '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { - debugLog('[TX LOG] Ignoring: payload too short to contain channel hash'); + debugLog( + '[TX LOG] Ignoring: payload too short to contain channel hash'); return false; } @@ -186,11 +206,13 @@ class TxTracker { debugLog('[TX LOG] Ignoring: channel hash mismatch'); return false; } - debugLog('[TX LOG] Channel hash match confirmed - this is a message on our channel'); + debugLog( + '[TX LOG] Channel hash match confirmed - this is a message on our channel'); // VALIDATION STEP 3: Message content verification if (channelKey != null && originalPayload != null) { - debugLog('[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); + debugLog( + '[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); try { // Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] @@ -204,18 +226,24 @@ class TxTracker { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); return false; } final messageBytes = decryptedBytes.sublist(5); // Convert bytes to string and strip null terminators - var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); - decryptedMessage = decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); - - debugLog('[MESSAGE_CORRELATION] Decryption successful, comparing content...'); - debugLog('[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); - debugLog('[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); + var decryptedMessage = + utf8.decode(messageBytes, allowMalformed: true); + decryptedMessage = + decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); + + debugLog( + '[MESSAGE_CORRELATION] Decryption successful, comparing content...'); + debugLog( + '[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); + debugLog( + '[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); // Check if our expected message is contained in the decrypted text // This handles both exact matches and messages with sender prefixes @@ -223,29 +251,37 @@ class TxTracker { decryptedMessage.contains(originalPayload); if (!messageMatches) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); - debugLog('[MESSAGE_CORRELATION] This is a different message on the same channel'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); + debugLog( + '[MESSAGE_CORRELATION] This is a different message on the same channel'); return false; } if (decryptedMessage == originalPayload) { - debugLog('[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); + debugLog( + '[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); } else { - debugLog('[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' + debugLog( + '[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' '- this is an echo of our ping!'); } } catch (e) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); return false; } } else { - debugWarn('[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); - debugWarn('[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); + debugWarn( + '[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); + debugWarn( + '[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); } // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + debugLog( + '[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater @@ -260,7 +296,8 @@ class TxTracker { ? reportedSnr > existing.snr! : reportedSnr != null && existing.snr == null; if (shouldUpdate) { - debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' + debugLog( + '[PING] Deduplication decision: updating path $pathHex with better SNR: ' '${existing.snr} -> $reportedSnr'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, @@ -269,7 +306,8 @@ class TxTracker { seenCount: existing.seenCount + 1, ); } else { - debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' + debugLog( + '[PING] Deduplication decision: keeping existing SNR for path $pathHex ' '(existing ${existing.snr} >= new $reportedSnr)'); // Still increment seen count existing.seenCount++; @@ -277,7 +315,8 @@ class TxTracker { } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + debugLog( + '[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, @@ -289,7 +328,8 @@ class TxTracker { // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; final bestRssi = repeaters[pathHex]!.rssi; - debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); @@ -312,10 +352,10 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { - final String repeaterId; // Hex string - double? snr; // Best SNR seen (null for CARpeater pass-through) - int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) - int seenCount; // Times observed + final String repeaterId; // Hex string + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) + int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 1c95dd2c..2f5e71df 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -41,7 +41,7 @@ class UnifiedRxHandler { /// Start unified RX listening void startListening() { if (isListening) return; - + debugLog('[UNIFIED RX] Starting unified RX listening'); isListening = true; debugLog('[UNIFIED RX] ✅ Unified listening started successfully'); @@ -50,7 +50,7 @@ class UnifiedRxHandler { /// Stop unified RX listening void stopListening() { if (!isListening) return; - + debugLog('[UNIFIED RX] Stopping unified RX listening'); isListening = false; debugLog('[UNIFIED RX] ✅ Unified listening stopped'); @@ -62,17 +62,18 @@ class UnifiedRxHandler { try { // Defensive check: ensure listener is marked as active if (!isListening) { - debugWarn('[UNIFIED RX] Received event but listener marked inactive - reactivating'); + debugWarn( + '[UNIFIED RX] Received event but listener marked inactive - reactivating'); isListening = true; } - + // Parse metadata ONCE final metadata = PacketMetadata.fromRawPacket( raw: rawPacket, snr: snr, rssi: rssi, ); - + debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); @@ -83,7 +84,8 @@ class UnifiedRxHandler { if (metadata.isTrace) { final tt = traceTracker; if (tt != null && tt.isListening) { - debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + debugLog( + '[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); tt.pendingBleSnr = metadata.snr; tt.pendingBleRssi = metadata.rssi; } @@ -99,16 +101,15 @@ class UnifiedRxHandler { return; } } - + // Route to RX wardriving if active if (rxLogger.isWardriving) { debugLog('[UNIFIED RX] RX wardriving active - logging observation'); await rxLogger.handlePacket(metadata, validator); } - + // If neither active, packet is received but ignored // Listener stays on, just not processing for wardriving - } catch (error, stackTrace) { debugError('[UNIFIED RX] Error processing rx_log entry: $error'); debugError('[UNIFIED RX] Stack trace: $stackTrace'); diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index d37cd8db..761a315f 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -10,10 +10,10 @@ class OfflineSession { final DateTime createdAt; final int pingCount; final Map data; - final String? devicePublicKey; // Device public key for auth during upload - final String? deviceName; // Device name for display - final String? contactUri; // Signed contact URI for registration during upload - final bool uploaded; // Track upload status + final String? devicePublicKey; // Device public key for auth during upload + final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload + final bool uploaded; // Track upload status OfflineSession({ required this.filename, @@ -106,14 +106,18 @@ class OfflineSessionService { /// Load sessions from storage Future _loadSessions() async { final sessionsJson = _prefs?.getStringList(_sessionsKey) ?? []; - _sessions = sessionsJson.map((json) { - try { - return OfflineSession.fromJson(jsonDecode(json) as Map); - } catch (e) { - debugError('[OFFLINE] Failed to parse session: $e'); - return null; - } - }).whereType().toList(); + _sessions = sessionsJson + .map((json) { + try { + return OfflineSession.fromJson( + jsonDecode(json) as Map); + } catch (e) { + debugError('[OFFLINE] Failed to parse session: $e'); + return null; + } + }) + .whereType() + .toList(); // Sort by date, newest first _sessions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); @@ -129,10 +133,12 @@ class OfflineSessionService { /// Generate filename for new session String _generateFilename() { final now = DateTime.now(); - final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; // Check if we already have sessions for today - final todaySessions = _sessions.where((s) => s.filename.startsWith(dateStr)).length; + final todaySessions = + _sessions.where((s) => s.filename.startsWith(dateStr)).length; if (todaySessions == 0) { return '$dateStr.json'; @@ -183,7 +189,8 @@ class OfflineSessionService { _sessions.insert(0, session); // Add at beginning (newest first) await _saveSessions(); - debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); + debugLog( + '[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } /// Update the current in-progress session with the latest pings snapshot. @@ -202,7 +209,8 @@ class OfflineSessionService { // If we have a tracked session, update it in-place if (_currentSessionFilename != null) { - final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + final index = + _sessions.indexWhere((s) => s.filename == _currentSessionFilename); if (index != -1) { final existing = _sessions[index]; final updatedData = Map.from(existing.data); @@ -219,11 +227,13 @@ class OfflineSessionService { contactUri: contactUri ?? existing.contactUri, ); await _saveSessions(); - debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + debugLog( + '[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); return; } // Session was deleted externally — fall through to create new - debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + debugWarn( + '[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); _currentSessionFilename = null; } @@ -237,7 +247,8 @@ class OfflineSessionService { // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { _currentSessionFilename = _sessions.first.filename; - debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + debugLog( + '[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); } } diff --git a/lib/services/permission_disclosure_service.dart b/lib/services/permission_disclosure_service.dart index 1fd5d8ad..c6ca111a 100644 --- a/lib/services/permission_disclosure_service.dart +++ b/lib/services/permission_disclosure_service.dart @@ -44,7 +44,8 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Track where you send pings on the mesh network'), + _BulletPoint( + text: 'Track where you send pings on the mesh network'), _BulletPoint(text: 'Map coverage areas for the community'), _BulletPoint(text: 'Record which repeaters hear your device'), SizedBox(height: 16), @@ -79,7 +80,8 @@ class PermissionDisclosureService { /// Show the background location disclosure (for "Always" permission) /// Returns true if user accepts, false if they decline - static Future showBackgroundLocationDisclosure(BuildContext context) async { + static Future showBackgroundLocationDisclosure( + BuildContext context) async { final result = await showDialog( context: context, barrierDismissible: false, @@ -103,8 +105,12 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Continue tracking coverage while the app is minimized'), - _BulletPoint(text: 'Send automatic pings during extended wardriving sessions'), + _BulletPoint( + text: + 'Continue tracking coverage while the app is minimized'), + _BulletPoint( + text: + 'Send automatic pings during extended wardriving sessions'), SizedBox(height: 16), Text( 'This grants "always on" location access, but we only collect what\'s needed: tagging pings while wardriving and checking if you\'re in a supported zone.', diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 50bc46eb..0482f818 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -42,12 +42,16 @@ import 'wakelock_service.dart'; class PingService { /// RX listening window duration (5 seconds - matches cooldown duration) static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); + /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); @@ -102,7 +106,7 @@ class PingService { bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; bool _targetedModeEnabled = false; - bool _nextPingIsDiscovery = true; // Start hybrid with discovery + bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; // Targeted mode tracking @@ -128,7 +132,8 @@ class PingService { StreamSubscription? _controlDataSubscription; Timer? _discoveryTimer; Position? _discoveryStartPosition; - Position? _lastDiscoveryPosition; // Track last discovery position for 25m check + Position? + _lastDiscoveryPosition; // Track last discovery position for 25m check // Validation callbacks bool Function()? checkExternalAntennaConfigured; @@ -150,6 +155,7 @@ class PingService { void Function(TxPing)? onTxPing; void Function(RxPing)? onRxPing; void Function(PingStats)? onStatsUpdated; + /// Called in real-time when each echo is received during tracking window /// Parameters: (TxPing txPing, HeardRepeater repeater, bool isNew) void Function(TxPing, HeardRepeater, bool isNew)? onEchoReceived; @@ -160,7 +166,8 @@ class PingService { /// Called in real-time when each node is discovered during tracking window /// Parameters: (DiscLogEntry discPing, DiscoveredNodeEntry nodeEntry, bool isNew) - void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? onDiscNodeDiscovered; + void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? + onDiscNodeDiscovered; /// Callback when TX window ends (for noise floor graph) /// Parameters: (bool success) - true if any repeaters heard, false if none @@ -252,7 +259,8 @@ class PingService { String? get skipReason => _skipReason; /// Get the manual ping cooldown timer (for UI display) - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; /// Set auto-ping interval (15000, 30000, or 60000 ms) /// Reference: getSelectedIntervalMs() in wardrive.js @@ -477,7 +485,8 @@ class PingService { // Guard: don't send pings if connection is not in connected state // Handles race where timer callback fires after reconnect started if (_connection.currentStep != ConnectionStep.connected) { - debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + debugLog( + '[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); return false; } @@ -502,7 +511,8 @@ class PingService { // Manual ping: 15-second cooldown, no distance check if (isInManualCooldown()) { final remainingSec = getRemainingManualCooldownSeconds(); - debugLog('[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -519,7 +529,8 @@ class PingService { // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -530,7 +541,8 @@ class PingService { if (_autoPingEnabled && !_passiveModeEnabled) { if (validation == PingValidation.tooCloseToLastPing) { _skipReason = 'too close'; - debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); + debugLog( + '[PING] Auto ping blocked: too close to last ping, scheduling next'); } if (_hybridModeEnabled) { _scheduleNextHybridPing(); @@ -556,7 +568,8 @@ class PingService { // Build ping message (same format used for TxTracker correlation) // Power is no longer included in the mesh message — sent per-ping in API payload - final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; + final coordsStr = + '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time @@ -586,13 +599,17 @@ class PingService { final channelHash = _connection.wardrivingChannelHash; final channelKey = _connection.wardrivingChannelKey; - if (_txTracker != null && channelIndex != null && channelHash != null && channelKey != null) { + if (_txTracker != null && + channelIndex != null && + channelHash != null && + channelKey != null) { debugLog('[PING] Starting TX echo tracking for: "$pingMessage"'); // Wire up real-time echo callback before starting tracking final txTracker = _txTracker; txTracker.onEchoReceived = (repeaterId, snr, rssi, isNew) { - debugLog('[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); + debugLog( + '[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); final txPing = _lastTxPing; if (txPing != null) { final repeater = HeardRepeater( @@ -605,18 +622,22 @@ class PingService { if (isNew) { // Add new repeater to the list txPing.heardRepeaters.add(repeater); - debugLog('[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); + debugLog( + '[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); } else { // Update existing repeater's SNR if better - final idx = txPing.heardRepeaters.indexWhere((r) => r.repeaterId == repeaterId); + final idx = txPing.heardRepeaters + .indexWhere((r) => r.repeaterId == repeaterId); if (idx >= 0) { txPing.heardRepeaters[idx] = repeater; - debugLog('[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); + debugLog( + '[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); } } // Notify for real-time UI updates - debugLog('[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); onEchoReceived?.call(txPing, repeater, isNew); debugLog('[PING] onEchoReceived callback completed'); } else { @@ -632,7 +653,8 @@ class PingService { windowDuration: _rxListeningWindow, ); } else { - debugWarn('[PING] TX tracking not available - channel info missing or no tracker'); + debugWarn( + '[PING] TX tracking not available - channel info missing or no tracker'); } // Play transmit sound immediately before sending @@ -706,7 +728,8 @@ class PingService { final txTracker = _txTracker; final txSuccess = txTracker != null && txTracker.repeaters.isNotEmpty; if (txSuccess) { - debugLog('[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); + debugLog( + '[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); // Format heard_repeats: "repeaterId(snr),repeaterId(snr)" // Reference: buildHeardRepeatsString() in wardrive.js @@ -723,7 +746,8 @@ class PingService { heardRepeats = repeaterStrings.join(','); // Update RX count stat for the echoes heard - _stats = _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); + _stats = + _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); onStatsUpdated?.call(_stats); } else { debugLog('[PING] No repeater echoes detected during listening window'); @@ -782,7 +806,7 @@ class PingService { debugLog('[PING] Pending disable complete, cooldown started'); // Notify AppStateProvider to update its state and cleanup await onPendingDisableComplete?.call(); - return; // Don't schedule next auto ping + return; // Don't schedule next auto ping } // Schedule next ping based on mode @@ -791,10 +815,12 @@ class PingService { // Reference: scheduleNextAutoPing() called after RX window in wardrive.js if (_autoPingEnabled && !isInCooldown()) { if (_hybridModeEnabled) { - debugLog('[HYBRID] Scheduling next hybrid ping after RX window completion'); + debugLog( + '[HYBRID] Scheduling next hybrid ping after RX window completion'); _scheduleNextHybridPing(); } else if (!_passiveModeEnabled) { - debugLog('[ACTIVE MODE] Scheduling next auto ping after RX window completion'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping after RX window completion'); _scheduleNextAutoPing(); } } else if (isInCooldown()) { @@ -808,7 +834,8 @@ class PingService { /// Reference: scheduleNextAutoPing() in wardrive.js void _scheduleNextAutoPing() { if (!_autoPingEnabled || _passiveModeEnabled) { - debugLog('[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); + debugLog( + '[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); return; } @@ -817,7 +844,8 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; - debugLog('[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); // Start countdown display (with skip reason if applicable) // The AutoPingTimer in countdown_timer_service.dart handles the display @@ -887,7 +915,8 @@ class PingService { bool targetedMode = false, String? targetRepeaterId, }) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); + debugLog( + '[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); @@ -895,7 +924,8 @@ class PingService { } // Targeted mode requires a repeater ID - if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + if (targetedMode && + (targetRepeaterId == null || targetRepeaterId.isEmpty)) { debugLog('[AUTO] Targeted mode requires a repeater ID'); return false; } @@ -920,7 +950,7 @@ class PingService { _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; _targetedModeEnabled = targetedMode; - _nextPingIsDiscovery = true; // Always start hybrid with discovery + _nextPingIsDiscovery = true; // Always start hybrid with discovery if (targetedMode) { _targetRepeaterId = targetRepeaterId; @@ -933,17 +963,20 @@ class PingService { if (targetedMode) { // Targeted Mode: send trace path to specific repeater - debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + debugLog( + '[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); await _startTargetedMode(); } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery - debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); + debugLog( + '[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); // First ping was discovery, so next should be TX _nextPingIsDiscovery = false; } else if (passiveMode) { // Passive Mode: send discovery requests instead of TX pings - debugLog('[PASSIVE MODE] Passive Mode started - using discovery protocol'); + debugLog( + '[PASSIVE MODE] Passive Mode started - using discovery protocol'); await _startDiscoveryMode(); } else { // Active Mode: send first ping immediately, then schedule timer @@ -970,14 +1003,15 @@ class PingService { if (_pingInProgress) { debugLog('[PING] Ping in progress, queuing disable for after RX window'); _pendingDisable = true; - return true; // Return true to indicate disable was accepted (pending) + return true; // Return true to indicate disable was accepted (pending) } // Check cooldown before stopping (unless forced) // Reference: isInCooldown() check in stopAutoPing() in wardrive.js if (!_passiveModeEnabled && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); return false; } @@ -1015,7 +1049,7 @@ class PingService { /// Force disable auto-ping (ignores cooldown, used for disconnect) Future forceDisableAutoPing() async { debugLog('[PING] Force disabling auto-ping'); - _pendingDisable = false; // Clear any pending disable + _pendingDisable = false; // Clear any pending disable _autoTimer?.cancel(); _autoTimer = null; _skipReason = null; @@ -1052,7 +1086,8 @@ class PingService { _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { - debugLog('[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); + debugLog( + '[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); final discPing = _lastDiscPing; if (discPing != null) { final nodeEntry = DiscoveredNodeEntry( @@ -1066,7 +1101,8 @@ class PingService { if (isNew) { discPing.discoveredNodes.add(nodeEntry); } else { - final idx = discPing.discoveredNodes.indexWhere((n) => n.repeaterId == node.repeaterId); + final idx = discPing.discoveredNodes + .indexWhere((n) => n.repeaterId == node.repeaterId); if (idx >= 0) discPing.discoveredNodes[idx] = nodeEntry; } onDiscNodeDiscovered?.call(discPing, nodeEntry, isNew); @@ -1099,7 +1135,8 @@ class PingService { _discTracker?.dispose(); _discTracker = null; _discoveryStartPosition = null; - _lastDiscoveryPosition = null; // Reset so first discovery always sends on next start + _lastDiscoveryPosition = + null; // Reset so first discovery always sends on next start _lastDiscPing = null; } @@ -1107,7 +1144,8 @@ class PingService { Future _sendDiscoveryRequest() async { // Guard: don't send discovery during reconnect (race with timer queue) if (_connection.currentStep != ConnectionStep.connected) { - debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + debugLog( + '[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); return; } @@ -1135,7 +1173,8 @@ class PingService { position.longitude, ); if (distance < _gpsService.configuredMinDistance) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + debugLog( + '[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1171,7 +1210,8 @@ class PingService { debugLog('[DISC] Created DiscLogEntry, ready for node tracking'); onDiscPing?.call(discPing); - debugLog('[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound immediately before sending @@ -1194,7 +1234,6 @@ class PingService { // Update last discovery position for 25m check _lastDiscoveryPosition = position; - } catch (e) { _pingInProgress = false; debugError('[DISC] Failed to send discovery request: $e'); @@ -1264,7 +1303,8 @@ class PingService { // Fire noise floor callback (entry already in _discLogEntries via onDiscPing) onDiscoveryWindowComplete?.call(discoverySuccess); - debugLog('[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); + debugLog( + '[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); _lastDiscPing = null; _scheduleNextDiscovery(); @@ -1295,7 +1335,8 @@ class PingService { // Notify callback for countdown display (30 seconds hardcoded for discovery) onAutoPingScheduled?.call(_discoveryInterval.inMilliseconds, _skipReason); - debugLog('[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); + debugLog( + '[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); } /// Schedule next hybrid ping (alternates discovery ↔ TX) @@ -1311,10 +1352,12 @@ class PingService { final listenMs = _nextPingIsDiscovery ? _discoveryListeningWindow.inMilliseconds : _rxListeningWindow.inMilliseconds; - final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); + final waitMs = + (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; - debugLog('[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); + debugLog( + '[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); onAutoPingScheduled?.call(waitMs, _skipReason); @@ -1353,10 +1396,12 @@ class PingService { final tracker = TraceTracker(); _traceTracker = tracker; tracker.onTraceReceived = (result) { - debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + debugLog( + '[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); }; tracker.onWindowComplete = (result) { - debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + debugLog( + '[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); _handleTraceWindowComplete(result); }; @@ -1416,11 +1461,14 @@ class PingService { final lastPos = _lastTargetedPosition; if (lastPos != null) { final distance = Geolocator.distanceBetween( - lastPos.latitude, lastPos.longitude, - position.latitude, position.longitude, + lastPos.latitude, + lastPos.longitude, + position.latitude, + position.longitude, ); if (distance < _gpsService.configuredMinDistance) { - debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + debugLog( + '[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextTargetedPing(); @@ -1450,7 +1498,8 @@ class PingService { ); onTracePing?.call(traceEntry); - debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound @@ -1460,11 +1509,13 @@ class PingService { final traceBytes = _traceHopBytes; final repeaterIdBytes = Uint8List(traceBytes); for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { - repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + repeaterIdBytes[i] = + int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); } // Send trace path and get tag - final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); + final tag = await _connection.sendTracePath(repeaterIdBytes, + hopBytes: traceBytes); // Start tracking with the tag _traceTracker?.startTracking( @@ -1481,7 +1532,6 @@ class PingService { // Update last targeted position for 25m check _lastTargetedPosition = position; - } catch (e) { _pingInProgress = false; debugError('[TRACE] Failed to send trace: $e'); @@ -1496,7 +1546,8 @@ class PingService { final targetId = _targetRepeaterId ?? ''; if (result != null && result.success && position != null) { - debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + debugLog( + '[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); // Queue to API (only successful traces) _apiQueue.enqueueTrace( @@ -1556,7 +1607,8 @@ class PingService { // Notify callback for countdown display onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); - debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + debugLog( + '[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); } /// Stop any active TX echo tracking window @@ -1590,32 +1642,32 @@ class PingService { enum PingValidation { /// All conditions met, can ping valid, - + /// Not connected to device notConnected, - + /// External antenna not configured externalAntennaRequired, - + /// Power level not set (unknown device model) powerLevelRequired, - + /// No GPS lock noGpsLock, - + /// GPS data too old (> 60 seconds) gpsDataStale, - + /// GPS accuracy too low (> 100 meters) gpsInaccurate, - + /// Outside service area (zone validation handled by API) /// Reserved for future use with dynamic zone boundaries outsideGeofence, - + /// Too close to last ping (< 25m) tooCloseToLastPing, - + /// Cooldown period active (< 5s since last ping) cooldownActive, diff --git a/lib/utils/debug_logger.dart b/lib/utils/debug_logger.dart index 2b4d3990..f5d5cd5e 100644 --- a/lib/utils/debug_logger.dart +++ b/lib/utils/debug_logger.dart @@ -9,10 +9,10 @@ import 'package:flutter/foundation.dart'; import 'package:web/web.dart' as web; /// Debug logging utility that mirrors MeshMapper_WebClient debug system. -/// +/// /// Logs are only output when DEBUG_ENABLED is true (set via `?debug=1` URL param). /// All log messages should use tagged format: `[TAG] message` -/// +/// /// Common tags: [BLE], [GPS], [PING], [API], [RX], [UI], [CONN] class DebugLogger { static bool _debugEnabled = false; @@ -30,7 +30,7 @@ class DebugLogger { final uri = Uri.base; final debugParam = uri.queryParameters['debug']; _debugEnabled = debugParam == '1' || debugParam == 'true'; - + if (_debugEnabled) { _consoleLog('[DEBUG] Debug logging ENABLED via URL param'); } @@ -56,9 +56,14 @@ class DebugLogger { /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleLog(args.join(' ')); } else { @@ -70,9 +75,15 @@ class DebugLogger { /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleWarn(args.join(' ')); } else { @@ -82,11 +93,18 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleError(args.join(' ')); } else { diff --git a/lib/utils/debug_logger_io.dart b/lib/utils/debug_logger_io.dart index d26799da..21fa3070 100644 --- a/lib/utils/debug_logger_io.dart +++ b/lib/utils/debug_logger_io.dart @@ -10,6 +10,4 @@ // debugError('[TAG] error'); // ``` -export 'debug_logger_stub.dart' - if (dart.library.html) 'debug_logger.dart'; - +export 'debug_logger_stub.dart' if (dart.library.html) 'debug_logger.dart'; diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index 4dc5a98d..c702fc74 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -22,7 +22,7 @@ class DebugLogger { // Enable debug logging by default on all builds _debugEnabled = true; - + if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); } @@ -39,7 +39,12 @@ class DebugLogger { /// Log a general info message to the console. /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -54,7 +59,13 @@ class DebugLogger { /// Log a warning message to the console. /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -68,8 +79,15 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart index ec8f0d91..001da568 100644 --- a/lib/utils/ping_colors.dart +++ b/lib/utils/ping_colors.dart @@ -7,11 +7,11 @@ import '../utils/debug_logger_io.dart'; /// The app adapts all semantic colors (ping types, signal quality, /// repeater status, noise floor) to a distinguishable palette. enum ColorVisionType { - none, // Default — current palette - protanopia, // Red-blind (~1% males) - deuteranopia, // Green-blind (~1% males) - tritanopia, // Blue-blind (~0.003%) - achromatopsia, // Total color blindness (monochrome) + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) } /// Immutable palette holding every semantic color the app uses. @@ -119,20 +119,20 @@ class ColorPalettes { /// Protanopia (red-blind) — replaces red/green axis with blue/orange. /// Also used for deuteranopia since both are red-green CVD. static const protanopia = ColorPalette( - txSuccess: Color(0xFF0072B2), // Wong blue + txSuccess: Color(0xFF0072B2), // Wong blue txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue - txFail: Color(0xFFD55E00), // Wong vermillion - rx: Color(0xFFCC79A7), // Wong reddish purple - discSuccess: Color(0xFF56B4E9), // Wong sky blue - discFail: Color(0xFF9E9E9E), // Grey (unchanged) - traceSuccess: Color(0xFF009E73), // Wong bluish green - noResponse: Color(0xFF9E9E9E), // Grey (unchanged) - signalGood: Color(0xFF0072B2), // Blue - signalMedium: Color(0xFFF0E442), // Wong yellow - signalBad: Color(0xFFD55E00), // Vermillion - repeaterActive: Color(0xFFCC79A7), // Reddish purple - repeaterNew: Color(0xFFF0E442), // Yellow - repeaterDead: Color(0xFF9E9E9E), // Grey + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey repeaterDuplicate: Color(0xFFD55E00), // Vermillion noiseFloorGood: Color(0xFF0072B2), noiseFloorMedium: Color(0xFFF0E442), @@ -148,20 +148,20 @@ class ColorPalettes { /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. /// Red/green distinction is preserved since tritan users can see those. static const tritanopia = ColorPalette( - txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccess: Color(0xFF009E73), // Wong bluish green txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) - txFail: Color(0xFFD55E00), // Wong vermillion - rx: Color(0xFFCC79A7), // Wong reddish purple - discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) - discFail: Color(0xFF9E9E9E), // Grey (unchanged) - traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) - noResponse: Color(0xFF9E9E9E), // Grey (unchanged) - signalGood: Color(0xFF009E73), // Bluish green - signalMedium: Color(0xFFE69F00), // Orange - signalBad: Color(0xFFD55E00), // Vermillion - repeaterActive: Color(0xFFCC79A7), // Reddish purple - repeaterNew: Color(0xFFE69F00), // Orange - repeaterDead: Color(0xFF9E9E9E), // Grey + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey repeaterDuplicate: Color(0xFFD55E00), // Vermillion noiseFloorGood: Color(0xFF009E73), noiseFloorMedium: Color(0xFFE69F00), @@ -178,20 +178,20 @@ class ColorPalettes { /// Relies on maximum brightness contrast between categories. /// Secondary indicators (icons, text) are essential with this palette. static const achromatopsia = ColorPalette( - txSuccess: Color(0xFFE0E0E0), // Light + txSuccess: Color(0xFFE0E0E0), // Light txSuccessLegend: Color(0xFFE0E0E0), - txFail: Color(0xFF616161), // Dark - rx: Color(0xFF9E9E9E), // Medium - discSuccess: Color(0xFFBDBDBD), // Medium-light - discFail: Color(0xFF757575), // Medium-dark - traceSuccess: Color(0xFF757575), // Medium-dark - noResponse: Color(0xFF616161), // Dark - signalGood: Color(0xFFE0E0E0), // Light - signalMedium: Color(0xFF9E9E9E), // Medium - signalBad: Color(0xFF424242), // Very dark - repeaterActive: Color(0xFFE0E0E0), // Light - repeaterNew: Color(0xFFBDBDBD), // Medium-light - repeaterDead: Color(0xFF616161), // Dark + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark repeaterDuplicate: Color(0xFF424242), // Very dark noiseFloorGood: Color(0xFFE0E0E0), noiseFloorMedium: Color(0xFF9E9E9E), diff --git a/lib/widgets/bug_report_dialog.dart b/lib/widgets/bug_report_dialog.dart index 9c34d2ba..ecf1c020 100644 --- a/lib/widgets/bug_report_dialog.dart +++ b/lib/widgets/bug_report_dialog.dart @@ -165,7 +165,8 @@ class _BugReportSheetState extends State { 'not-connected'; // Use last connected device name (companion name without MeshCore- prefix) - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; // Format description with username if provided final username = _usernameController.text.trim(); @@ -193,7 +194,8 @@ class _BugReportSheetState extends State { if (!mounted) return; if (result.success) { - debugLog('[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); + debugLog( + '[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); Navigator.of(context).pop(result); } else { setState(() { @@ -245,13 +247,15 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submit Feedback', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -270,272 +274,295 @@ class _BugReportSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - // Ticket type selector - SegmentedButton - _buildSectionLabel(theme, Icons.category, 'Report Type'), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'bug', - label: Text('Bug'), - icon: Icon(Icons.bug_report, size: 18), - ), - ButtonSegment( - value: 'enhancement', - label: Text('Feature'), - icon: Icon(Icons.lightbulb_outline, size: 18), - ), - ], - selected: {_ticketType}, - onSelectionChanged: _isSubmitting - ? null - : (selected) => setState(() => _ticketType = selected.first), - showSelectedIcon: false, - ), - const SizedBox(height: 24), - - // Username field (optional, auto-populated from remembered device) - _buildSectionLabel(theme, Icons.person, 'Username (optional)'), - const SizedBox(height: 8), - TextFormField( - controller: _usernameController, - textCapitalization: TextCapitalization.words, - decoration: _buildInputDecoration( - theme, - hintText: 'Your MeshCore companion name', - ), - maxLength: 50, - enabled: !_isSubmitting, - ), - const SizedBox(height: 16), - - // Title field - _buildSectionLabel(theme, Icons.title, 'Title'), - const SizedBox(height: 8), - TextFormField( - controller: _titleController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Brief summary of the issue', + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Ticket type selector - SegmentedButton + _buildSectionLabel(theme, Icons.category, 'Report Type'), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'bug', + label: Text('Bug'), + icon: Icon(Icons.bug_report, size: 18), + ), + ButtonSegment( + value: 'enhancement', + label: Text('Feature'), + icon: Icon(Icons.lightbulb_outline, size: 18), + ), + ], + selected: {_ticketType}, + onSelectionChanged: _isSubmitting + ? null + : (selected) => + setState(() => _ticketType = selected.first), + showSelectedIcon: false, ), - maxLength: 100, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Title is required'; - } - if (value.trim().length < 5) { - return 'Title must be at least 5 characters'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Description field - _buildSectionLabel(theme, Icons.description, 'Description'), - const SizedBox(height: 8), - TextFormField( - controller: _descriptionController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Describe the issue or feature request...', - alignLabelWithHint: true, + const SizedBox(height: 24), + + // Username field (optional, auto-populated from remembered device) + _buildSectionLabel( + theme, Icons.person, 'Username (optional)'), + const SizedBox(height: 8), + TextFormField( + controller: _usernameController, + textCapitalization: TextCapitalization.words, + decoration: _buildInputDecoration( + theme, + hintText: 'Your MeshCore companion name', + ), + maxLength: 50, + enabled: !_isSubmitting, ), - maxLines: 5, - maxLength: 2000, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Description is required'; - } - if (value.trim().length < 20) { - return 'Please provide more detail (at least 20 characters)'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Platform selector - _buildSectionLabel(theme, Icons.devices, 'Platform'), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _buildPlatformChip(theme, 'App', 'app', Icons.phone_android), - _buildPlatformChip(theme, 'Map', 'map', Icons.map), - _buildPlatformChip(theme, 'Other', 'other', Icons.more_horiz), - ], - ), + const SizedBox(height: 16), - // Debug logs section (mobile only) - if (!kIsWeb && _isLoadingFiles) ...[ - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), + // Title field + _buildSectionLabel(theme, Icons.title, 'Title'), + const SizedBox(height: 8), + TextFormField( + controller: _titleController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Brief summary of the issue', ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Text( - 'Preparing log files...', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + maxLength: 100, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + if (value.trim().length < 5) { + return 'Title must be at least 5 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Description field + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Describe the issue or feature request...', + alignLabelWithHint: true, ), + maxLines: 5, + maxLength: 2000, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Description is required'; + } + if (value.trim().length < 20) { + return 'Please provide more detail (at least 20 characters)'; + } + return null; + }, ), - ], - // Debug logs section - always visible when files available - if (!kIsWeb && !_isLoadingFiles && _availableLogFiles.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 16), + + // Platform selector + _buildSectionLabel(theme, Icons.devices, 'Platform'), const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + Wrap( + spacing: 8, + children: [ + _buildPlatformChip( + theme, 'App', 'app', Icons.phone_android), + _buildPlatformChip(theme, 'Map', 'map', Icons.map), + _buildPlatformChip( + theme, 'Other', 'other', Icons.more_horiz), + ], + ), + + // Debug logs section (mobile only) + if (!kIsWeb && _isLoadingFiles) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), - ), - child: Column( - children: [ - // Header with attach toggle - SwitchListTile( - title: const Text('Include with feedback'), - subtitle: Text( - 'Select logs to attach to this report', - style: theme.textTheme.bodySmall?.copyWith( + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - value: _uploadLogs, - onChanged: _isSubmitting - ? null - : (value) { - setState(() { - _uploadLogs = value; - if (!_uploadLogs) { - _selectedLogFiles.clear(); - } - }); - }, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - ), - Divider( - height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + ], + ), + ), + ], + // Debug logs section - always visible when files available + if (!kIsWeb && + !_isLoadingFiles && + _availableLogFiles.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), - // Log file list - only shown when toggle is on - if (_uploadLogs) - ...List.generate(_availableLogFiles.length, (index) { - final file = _availableLogFiles[index]; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - - return ListTile( - dense: true, - leading: Checkbox( - value: isSelected, - onChanged: _isSubmitting - ? null - : (_) => _toggleFile(file.path), - ), - title: Text( - filename, - style: const TextStyle(fontSize: 13), + ), + child: Column( + children: [ + // Header with attach toggle + SwitchListTile( + title: const Text('Include with feedback'), + subtitle: Text( + 'Select logs to attach to this report', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), + ), + value: _uploadLogs, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _uploadLogs = value; + if (!_uploadLogs) { + _selectedLogFiles.clear(); + } + }); + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + ), + Divider( + height: 1, + color: theme.colorScheme.outline + .withValues(alpha: 0.3), + ), + // Log file list - only shown when toggle is on + if (_uploadLogs) + ...List.generate(_availableLogFiles.length, + (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = + _selectedLogFiles.contains(file.path); + + // Format size and show part count for oversized files + String sizeDisplay; + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024) + .toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), ), - child: Text( - sizeDisplay, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme + .colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), - ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), - ); - }), - ], - ), - ), - ], - - // Error message - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.3), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), + ); + }), + ], ), ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme.colorScheme.error, + ], + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), ), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle(color: theme.colorScheme.error), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), ), - ), - ], + ], - // Bottom padding for safe area - SizedBox(height: MediaQuery.of(context).padding.bottom + 80), - ], + // Bottom padding for safe area + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), ), ), ), - ), // Sticky bottom action bar Container( @@ -557,7 +584,8 @@ class _BugReportSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -613,7 +641,8 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submitting...', style: theme.textTheme.titleLarge), ], @@ -635,7 +664,8 @@ class _BugReportSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -653,7 +683,9 @@ class _BugReportSheetState extends State { // Status text Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -680,7 +712,8 @@ class _BugReportSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), diff --git a/lib/widgets/connection_panel.dart b/lib/widgets/connection_panel.dart index 490d5e87..47c2c9d6 100644 --- a/lib/widgets/connection_panel.dart +++ b/lib/widgets/connection_panel.dart @@ -31,7 +31,8 @@ class ConnectionPanel extends StatelessWidget { return _buildAntennaSelector(context, appState, prefs); } - Widget _buildAntennaSelector(BuildContext context, AppStateProvider appState, prefs) { + Widget _buildAntennaSelector( + BuildContext context, AppStateProvider appState, prefs) { final isSet = prefs.externalAntennaSet; final hasExternal = prefs.externalAntenna; final colorScheme = Theme.of(context).colorScheme; @@ -64,7 +65,8 @@ class ConnectionPanel extends StatelessWidget { child: Icon( Icons.settings_input_antenna, size: 20, - color: isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, + color: + isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, ), ), const SizedBox(width: 12), @@ -84,7 +86,8 @@ class ConnectionPanel extends StatelessWidget { if (appState.antennaRestoredFromDevice) Text( 'Remembered for ${appState.displayDeviceName}', - style: TextStyle(fontSize: 11, color: colorScheme.onSurfaceVariant), + style: TextStyle( + fontSize: 11, color: colorScheme.onSurfaceVariant), ), ], ), @@ -108,7 +111,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: No'); appState.updatePreferences( - prefs.copyWith(externalAntenna: false, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: false, externalAntennaSet: true), ); }, ), @@ -119,7 +123,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: Yes'); appState.updatePreferences( - prefs.copyWith(externalAntenna: true, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: true, externalAntennaSet: true), ); }, ), @@ -153,7 +158,12 @@ class ConnectionPanel extends StatelessWidget { : Colors.transparent, borderRadius: BorderRadius.circular(6), boxShadow: isSelected && !isDark - ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2, offset: const Offset(0, 1))] + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(0, 1)) + ] : null, ), child: Text( @@ -162,8 +172,12 @@ class ConnectionPanel extends StatelessWidget { fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected - ? (isDark ? Colors.white : const Color(0xFF1E293B)) // slate-800 for light - : (isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B)), // slate-400/500 + ? (isDark + ? Colors.white + : const Color(0xFF1E293B)) // slate-800 for light + : (isDark + ? const Color(0xFF94A3B8) + : const Color(0xFF64748B)), // slate-400/500 ), ), ), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 76d2edac..ab359955 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -98,14 +98,16 @@ extension MapStyleExtension on MapStyle { /// Custom tile provider that silently handles HTTP errors (404, 503, etc.) /// instead of flooding the console with exceptions -final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileProvider { - SilentCancellableNetworkTileProvider() : super( - dioClient: Dio( - BaseOptions( - validateStatus: (status) => true, // Accept all status codes - ), - ), - ); +final class SilentCancellableNetworkTileProvider + extends CancellableNetworkTileProvider { + SilentCancellableNetworkTileProvider() + : super( + dioClient: Dio( + BaseOptions( + validateStatus: (status) => true, // Accept all status codes + ), + ), + ); } /// Resolved repeater with SNR and ambiguity info for ping focus mode. @@ -157,11 +159,14 @@ class _MapWidgetState extends State with TickerProviderStateMixin { bool _prefsApplied = false; // Guard to load saved prefs only once bool _isMapReady = false; LatLng? _lastGpsPosition; - bool _hasInitialZoomed = false; // Track if we've done the one-time initial zoom to GPS - bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _hasInitialZoomed = + false; // Track if we've done the one-time initial zoom to GPS + bool _hasZoomedToLastKnown = + false; // Track if we've zoomed to last known position (before GPS) // Map rotation mode - bool _alwaysNorth = true; // true = north always up, false = rotate with heading + bool _alwaysNorth = + true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation // MeshMapper overlay toggle (on by default) @@ -213,7 +218,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && _autoFollow && _isMapReady && _lastGpsPosition != null) { @@ -238,7 +243,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final currentCenter = _mapController.camera.center; // Skip if already at target (within small threshold) - final distance = const Distance().as(LengthUnit.Meter, currentCenter, target); + final distance = + const Distance().as(LengthUnit.Meter, currentCenter, target); if (distance < 1) return; // Less than 1 meter, don't animate // Cancel any running animation @@ -263,14 +269,22 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _animationEndPosition = target; _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; + if (!mounted || + _animationStartPosition == null || + _animationEndPosition == null) { + return; + } // Interpolate between start and end positions final t = _animation!.value; final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); + ((_animationEndPosition!.latitude - + _animationStartPosition!.latitude) * + t); final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); + ((_animationEndPosition!.longitude - + _animationStartPosition!.longitude) * + t); _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); }); @@ -307,14 +321,22 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _animationEndPosition = target; _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; + if (!mounted || + _animationStartPosition == null || + _animationEndPosition == null) { + return; + } // Interpolate between start and end positions final t = _animation!.value; final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); + ((_animationEndPosition!.latitude - + _animationStartPosition!.latitude) * + t); final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); + ((_animationEndPosition!.longitude - + _animationStartPosition!.longitude) * + t); // Interpolate zoom final zoom = currentZoom + ((targetZoom - currentZoom) * t); @@ -326,15 +348,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Zoom to fit a focused ping and its connected repeaters on screen - void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + void _zoomToFocusBounds( + LatLng pingLocation, List<_ResolvedRepeater> repeaters) { if (!_isMapReady || !mounted) return; - final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; + final points = [ + pingLocation, + ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) + ]; if (points.length < 2) return; final fitted = CameraFit.coordinates( coordinates: points, - padding: EdgeInsets.fromLTRB(60, 60, 60, MediaQuery.of(context).size.height * 0.4), + padding: EdgeInsets.fromLTRB( + 60, 60, 60, MediaQuery.of(context).size.height * 0.4), maxZoom: 15, ).fit(_mapController.camera); @@ -395,7 +422,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationEndAngle = currentRotation + delta; _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } // Interpolate between start and end angles final t = _rotationAnimation!.value; @@ -412,14 +443,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Shifts the map center to keep the GPS marker centered in the visible map area /// - bottomPadding: shifts center down (portrait mode with bottom panel) /// - rightPadding: shifts center left (landscape mode with side panel) - LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, [double rightPadding = 0, double? atZoom]) { + LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, + [double rightPadding = 0, double? atZoom]) { if (!_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; // Get meters per pixel at current zoom (or at a specific zoom if provided) // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) final zoom = atZoom ?? _mapController.camera.zoom; - final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * + final metersPerPixel = 40075000 / + (256 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); double latOffset = 0; @@ -435,7 +468,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; // Longitude degrees per meter varies with latitude - lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); + lonOffset = -(meterOffset / + (111000 * math.cos(position.latitude * math.pi / 180))); } // When the map is rotated (heading mode), geographic "south" no longer maps @@ -452,7 +486,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { lonOffset = rotatedLon; } - return LatLng(position.latitude + latOffset, position.longitude + lonOffset); + return LatLng( + position.latitude + latOffset, position.longitude + lonOffset); } @override @@ -513,9 +548,14 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (_autoFollow) { // Auto-follow is on and panel may be open — apply panel offset so // the marker appears centered in the visible map area. - final adjustedPosition = _offsetPositionForPadding(initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0); + final adjustedPosition = _offsetPositionForPadding( + initialPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + 16.0); _animateToPositionWithZoom(adjustedPosition, 16.0); - debugLog('[MAP] Initial zoom to GPS position (with panel offset)'); + debugLog( + '[MAP] Initial zoom to GPS position (with panel offset)'); } else { _animateToPositionWithZoom(initialPosition, 16.0); debugLog('[MAP] Initial zoom to GPS position'); @@ -536,8 +576,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(newPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPosition(adjustedPosition); // Smooth animation instead of jump + final adjustedPosition = _offsetPositionForPadding(newPosition, + widget.bottomPaddingPixels, widget.rightPaddingPixels); + _animateToPosition( + adjustedPosition); // Smooth animation instead of jump } }); } @@ -552,7 +594,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // panel offset was computed). Heading mode will begin rotating // on the next GPS update when heading changes. _lastHeading = heading; - debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); + debugLog( + '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; // Use post frame callback to avoid build-during-build issues @@ -567,15 +610,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { + if (_isMapReady && + appState.mapNavigationTrigger != _lastNavigationTrigger) { _lastNavigationTrigger = appState.mapNavigationTrigger; final target = appState.mapNavigationTarget; if (target != null) { // Reset map controls to default state - _autoFollow = false; // Disable center on GPS - _alwaysNorth = true; // Set to north-up mode - _rotationLocked = false; // Unlock rotation - _lastHeading = null; // Reset heading tracking + _autoFollow = false; // Disable center on GPS + _alwaysNorth = true; // Set to north-up mode + _rotationLocked = false; // Unlock rotation + _lastHeading = null; // Reset heading tracking // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle @@ -596,7 +640,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; final topPadding = isLandscape ? 16.0 : 8.0; @@ -637,7 +682,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + final onToggle = widget.onMapControlsToggle ?? + () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); return Column( mainAxisSize: MainAxisSize.min, @@ -661,8 +707,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), + if (isExpanded) _buildMapControls(appState), ], ); } @@ -695,13 +740,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (appState.preferences.mapTilesEnabled) Builder( builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); return TileLayer( urlTemplate: mapStyle.urlTemplate, subdomains: mapStyle.subdomains ?? const [], userAgentPackageName: 'com.meshmapper.app', maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + retinaMode: mapStyle.supportsRetina && + RetinaMode.isHighDensity(context), tileDisplay: const TileDisplay.fadeIn( reloadStartOpacity: 1.0, ), @@ -711,9 +758,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) - if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) + if (appState.preferences.mapTilesEnabled && + appState.zoneCode != null && + _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', + urlTemplate: + 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', userAgentPackageName: 'com.meshmapper.app', minZoom: 3, maxZoom: 17, @@ -725,102 +775,106 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top // During focus mode, the focused marker is excluded and rendered in its own top layer - MarkerLayer( - markers: _buildCoverageMarkers( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - excludeFocused: _focusedPingLocation != null, - ), - ), - - // Focus mode: polylines from focused ping to each connected repeater - // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) - PolylineLayer( - polylines: _focusedRepeaters.map((r) { - final lineColor = r.snr != null - ? PingColors.snrColor(r.snr!) - : Colors.grey; - return Polyline( - points: [_focusedPingLocation!, LatLng(r.repeater.lat, r.repeater.lon)], - color: lineColor.withValues(alpha: 0.9), - strokeWidth: 3.5, - isDotted: true, - borderStrokeWidth: r.ambiguous ? 1.5 : 0, - borderColor: r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, - ); - }).toList(), - ), - - // Repeater markers (magenta with ID, rotate with map) - // During focus mode, split into two layers: faded repeaters below, connected on top - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ - // Faded non-connected repeaters (below) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyFaded: true, - ), - ), - // Distance labels (middle) - MarkerLayer( - rotate: true, - markers: _buildFocusDistanceLabels(appState), - ), - // Connected repeaters (on top) MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyConnected: true, - ), - ), - // Focused ping marker (above everything except GPS) - MarkerLayer( - markers: _buildFocusedPingMarker( + markers: _buildCoverageMarkers( txPings: appState.txPings, rxPings: appState.rxPings, discEntries: appState.discLogEntries, discDropEnabled: appState.discDropEnabled, traceEntries: appState.traceLogEntries, + excludeFocused: _focusedPingLocation != null, ), ), - ] else - // Normal mode: single layer with all repeaters - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, + + // Focus mode: polylines from focused ping to each connected repeater + // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) + PolylineLayer( + polylines: _focusedRepeaters.map((r) { + final lineColor = + r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + return Polyline( + points: [ + _focusedPingLocation!, + LatLng(r.repeater.lat, r.repeater.lon) + ], + color: lineColor.withValues(alpha: 0.9), + strokeWidth: 3.5, + isDotted: true, + borderStrokeWidth: r.ambiguous ? 1.5 : 0, + borderColor: + r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, + ); + }).toList(), ), - ), - // Current position marker - if (appState.currentPosition != null) - MarkerLayer( - // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) - rotate: appState.preferences.gpsMarkerStyle != 'arrow' && - appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'chomper', - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker(appState.currentPosition!.heading), + // Repeater markers (magenta with ID, rotate with map) + // During focus mode, split into two layers: faded repeaters below, connected on top + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ + // Faded non-connected repeaters (below) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyFaded: true, ), - ], - ), + ), + // Distance labels (middle) + MarkerLayer( + rotate: true, + markers: _buildFocusDistanceLabels(appState), + ), + // Connected repeaters (on top) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyConnected: true, + ), + ), + // Focused ping marker (above everything except GPS) + MarkerLayer( + markers: _buildFocusedPingMarker( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), + ), + ] else + // Normal mode: single layer with all repeaters + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), + ), + + // Current position marker + if (appState.currentPosition != null) + MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk' && + appState.preferences.gpsMarkerStyle != 'chomper', + markers: [ + Marker( + point: LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ), + width: 48, + height: 48, + child: _buildCurrentPositionMarker( + appState.currentPosition!.heading), + ), + ], + ), ], ), ); @@ -926,14 +980,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { columnWidths: const { 0: IntrinsicColumnWidth(), // dot 1: IntrinsicColumnWidth(), // ID - 2: FixedColumnWidth(8), // spacer + 2: FixedColumnWidth(8), // spacer 3: IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + _overlayRow(rxSlot.repeaterId, rxSlot.snr, + _overlayTypeColor(OverlayPingType.rx)), ], ), ], @@ -966,11 +1021,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps + ? formatMeters(position.accuracy, + isImperial: appState.preferences.isImperial) + : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, + color: + hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), // Distance since last TX ping (like wardrive.js) @@ -983,7 +1042,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, + isImperial: appState.preferences.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -1004,7 +1064,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Map controls (always vertical, used inside collapsible wrapper) Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); return Container( decoration: BoxDecoration( @@ -1026,7 +1087,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _buildControlDivider(), _buildControlButton( icon: Icons.layers, - tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay', + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', onPressed: _toggleMeshMapperOverlay, isActive: _showMeshMapperOverlay, ), @@ -1036,14 +1099,17 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _buildControlButton( icon: _autoFollow ? Icons.my_location : Icons.location_searching, tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: appState.currentPosition != null ? _centerOnPosition : null, + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, isActive: _autoFollow, ), _buildControlDivider(), // Always North toggle _buildControlButton( icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth ? 'Always North (Click to Rotate with Heading)' : 'Rotating with Heading (Click for Always North)', + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', onPressed: _toggleNorthMode, isActive: !_alwaysNorth, ), @@ -1105,7 +1171,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { void _cycleMapStyle(AppStateProvider appState) { const styles = MapStyle.values; - final currentStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final currentStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); final currentIndex = styles.indexOf(currentStyle); final newStyle = styles[(currentIndex + 1) % styles.length]; appState.setMapStyle(newStyle.name); @@ -1134,8 +1201,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }); appState.setMapAutoFollow(true); // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(targetPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPositionWithZoom(adjustedPosition, 17.0); // Street level zoom when enabling follow + final adjustedPosition = _offsetPositionForPadding(targetPosition, + widget.bottomPaddingPixels, widget.rightPaddingPixels); + _animateToPositionWithZoom( + adjustedPosition, 17.0); // Street level zoom when enabling follow } } @@ -1177,7 +1246,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationEndAngle = 0.0; // North _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } final t = _rotationAnimation!.value; final rotation = _rotationStartAngle! + @@ -1231,7 +1304,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationEndAngle = 0.0; // North _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } final t = _rotationAnimation!.value; final rotation = _rotationStartAngle! + @@ -1271,7 +1348,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), child: const Icon(Icons.map, color: Colors.blue, size: 24), ), @@ -1280,8 +1358,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Text( 'Legend & Info', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -1303,246 +1381,374 @@ class _MapWidgetState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Map Markers section - Text( - 'Map Markers', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLegendItem( - context: context, - color: PingColors.txSuccessLegend, - label: 'TX', - description: 'Location where you sent a ping and heard a repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.txFail, - label: 'TX', - description: 'Location where you sent a ping but no repeater was heard', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.rx, - label: 'RX', - description: 'Location where you received a message from the mesh', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.discSuccess, - label: 'DISC', - description: 'Location where you sent a discovery request and a repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.traceSuccess, - label: 'TRC', - description: 'Location where a trace reached the repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.discFail, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.noResponse, - label: 'TRC', - description: 'Location where a trace got no response', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Coverage Layer section - Text( - 'Coverage Layer', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLayerItem( - context: context, - color: PingColors.coverageBidir, - label: 'BIDIR', - description: 'Heard repeats from the mesh AND successfully routed through it', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDisc, - label: 'DISC', - description: 'Wardriving app sent a discovery packet and heard a reply', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageTx, - label: 'TX', - description: 'Successfully routed through, but no repeats heard back', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageRx, - label: 'RX', - description: 'Heard mesh traffic but did not transmit', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDead, - label: 'DEAD', - description: 'Repeater heard it, but no other radio received the repeat', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDrop, - label: 'DROP', - description: 'No repeats heard AND no successful route', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Sound Notifications section - Text( - 'Sound Notifications', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildSoundItem( - context: context, - icon: Icons.cell_tower, - label: 'TX Sound', - description: 'Plays when sending a ping or discovery request', - onPlay: () { - final appState = context.read(); - appState.audioService.playTransmitSound(); - }, - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildSoundItem( - context: context, - icon: Icons.hearing, - label: 'RX Sound', - description: 'Plays when a repeater echo or mesh message is received', - onPlay: () { - final appState = context.read(); - appState.audioService.playReceiveSound(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), - - // Map Controls section - Text( - 'Map Controls', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildHelpItem( - context: context, - icon: Icons.dark_mode, - label: 'Map Style', - description: 'Cycle between Dark, Light, and Satellite map styles', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.layers, - label: 'Coverage Overlay', - description: 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.my_location, - label: 'Center/Follow', - description: 'Center map on GPS position. Tap again to toggle auto-follow mode', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.navigation, - label: 'Always North', - description: 'Toggle between always-north orientation or rotate with heading', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.sync_disabled, - label: 'Lock Rotation', - description: 'Prevent accidental rotation of the map', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.info_outline, - label: 'Legend & Info', - description: 'Show this help popup with legend and control explanations', - ), - ], - ), - ), + Text( + 'Map Markers', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLegendItem( + context: context, + color: PingColors.txSuccessLegend, + label: 'TX', + description: + 'Location where you sent a ping and heard a repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.txFail, + label: 'TX', + description: + 'Location where you sent a ping but no repeater was heard', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.rx, + label: 'RX', + description: + 'Location where you received a message from the mesh', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.discSuccess, + label: 'DISC', + description: + 'Location where you sent a discovery request and a repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.traceSuccess, + label: 'TRC', + description: + 'Location where a trace reached the repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.discFail, + label: 'DISC', + description: + 'Location where you sent a discovery request but no repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.noResponse, + label: 'TRC', + description: + 'Location where a trace got no response', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Coverage Layer section + Text( + 'Coverage Layer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLayerItem( + context: context, + color: PingColors.coverageBidir, + label: 'BIDIR', + description: + 'Heard repeats from the mesh AND successfully routed through it', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDisc, + label: 'DISC', + description: + 'Wardriving app sent a discovery packet and heard a reply', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageTx, + label: 'TX', + description: + 'Successfully routed through, but no repeats heard back', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageRx, + label: 'RX', + description: + 'Heard mesh traffic but did not transmit', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDead, + label: 'DEAD', + description: + 'Repeater heard it, but no other radio received the repeat', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDrop, + label: 'DROP', + description: + 'No repeats heard AND no successful route', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Sound Notifications section + Text( + 'Sound Notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildSoundItem( + context: context, + icon: Icons.cell_tower, + label: 'TX Sound', + description: + 'Plays when sending a ping or discovery request', + onPlay: () { + final appState = + context.read(); + appState.audioService.playTransmitSound(); + }, + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildSoundItem( + context: context, + icon: Icons.hearing, + label: 'RX Sound', + description: + 'Plays when a repeater echo or mesh message is received', + onPlay: () { + final appState = + context.read(); + appState.audioService.playReceiveSound(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Map Controls section + Text( + 'Map Controls', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildHelpItem( + context: context, + icon: Icons.dark_mode, + label: 'Map Style', + description: + 'Cycle between Dark, Light, and Satellite map styles', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.layers, + label: 'Coverage Overlay', + description: + 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.my_location, + label: 'Center/Follow', + description: + 'Center map on GPS position. Tap again to toggle auto-follow mode', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.navigation, + label: 'Always North', + description: + 'Toggle between always-north orientation or rotate with heading', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.sync_disabled, + label: 'Lock Rotation', + description: + 'Prevent accidental rotation of the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.info_outline, + label: 'Legend & Info', + description: + 'Show this help popup with legend and control explanations', + ), + ], + ), + ), ], ), ), @@ -1559,8 +1765,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0), - Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, ], ), ), @@ -1763,7 +1974,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { color: color, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2.0), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) + ], ), ); case 'pin': @@ -1782,8 +1996,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: color, shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + border: Border.all( + color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) + ], ), ); } @@ -1801,16 +2019,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }) { final timestamped = <(DateTime, Marker)>[ for (final ping in txPings) - if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + if (!excludeFocused || + !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) (ping.timestamp, _buildTxMarker(ping)), for (final ping in rxPings) - if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + if (!excludeFocused || + !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) (ping.timestamp, _buildRxMarker(ping)), for (final entry in discEntries) - if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + if (!excludeFocused || + !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), for (final entry in traceEntries) - if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + if (!excludeFocused || + !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) (entry.timestamp, _buildTraceMarker(entry)), ]; @@ -1867,8 +2089,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Marker _buildTxMarker(TxPing ping) { - final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final color = ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; + final isFocused = + _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final color = + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; final size = isFocused ? 24.0 : 20.0; return Marker( point: LatLng(ping.latitude, ping.longitude), @@ -1882,7 +2106,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Marker _buildRxMarker(RxPing ping) { - final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final isFocused = + _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); final size = isFocused ? 24.0 : 20.0; return Marker( point: LatLng(ping.latitude, ping.longitude), @@ -1890,13 +2115,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { height: size, child: GestureDetector( onTap: () => _showRxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(PingColors.rx, isFocused)), + child: _buildCoverageMarkerChild( + _applyFocusFade(PingColors.rx, isFocused)), ), ); } Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { - final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final isFocused = + _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); final color = entry.nodeCount == 0 ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) : _discMarkerColor; @@ -1913,7 +2140,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Marker _buildTraceMarker(TraceLogEntry entry) { - final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final isFocused = + _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); final color = entry.success ? Colors.cyan : Colors.grey; final size = isFocused ? 24.0 : 20.0; return Marker( @@ -1935,7 +2163,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { snrValues: [entry.localSnr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -1953,7 +2182,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1966,9 +2196,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Colors.cyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + border: Border.all( + color: Colors.cyan.withValues(alpha: 0.4)), ), - child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + child: const Icon(Icons.gps_fixed, + color: Colors.cyan, size: 24), ), const SizedBox(width: 12), Expanded( @@ -1977,15 +2209,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ Text( 'Trace', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -2003,15 +2240,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -2048,13 +2293,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -2064,7 +2314,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2075,7 +2327,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2086,7 +2340,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2097,14 +2353,17 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data row Builder(builder: (context) { final localSnr = entry.localSnr ?? 0; @@ -2113,15 +2372,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final rxSnrColor = PingColors.snrColor(localSnr); final rssiColor = PingColors.rssiColor(localRssi); - final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // RX SNR Expanded( child: Center( @@ -2179,7 +2447,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final midLon = (ping.longitude + repeaterPos.longitude) / 2; // Distance in meters — use GpsService for consistency with repeater popup final meters = GpsService.distanceBetween( - ping.latitude, ping.longitude, repeaterPos.latitude, repeaterPos.longitude, + ping.latitude, + ping.longitude, + repeaterPos.latitude, + repeaterPos.longitude, ); final label = meters < 1000 ? formatMeters(meters, isImperial: isImperial) @@ -2243,7 +2514,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ? fullHex.substring(0, 8) : hexIds[i]; final matches = allRepeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); final ambiguous = matches.length > 1; resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); @@ -2252,7 +2524,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Activate ping focus mode — draw lines, fade markers, zoom to fit. - void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { _preFocusCenter = _mapController.camera.center; _preFocusZoom = _mapController.camera.zoom; _wasAutoFollowBeforeFocus = _autoFollow; @@ -2331,10 +2604,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { for (final repeater in repeaters) { idCounts[repeater.id] = (idCounts[repeater.id] ?? 0) + 1; } - return idCounts.entries - .where((e) => e.value > 1) - .map((e) => e.key) - .toSet(); + return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } /// Get marker color for a repeater based on status priority: @@ -2360,7 +2630,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return repeaters.where((repeater) { if (!hasFocus) return true; // No focus — include all - final isConnected = _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + final isConnected = + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); if (onlyConnected) return isConnected; if (onlyFaded) return !isConnected; return true; @@ -2369,7 +2640,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); // During focus mode, fade repeaters not connected to the focused ping - final isConnected = hasFocus && _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + final isConnected = hasFocus && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); final effectiveColor = (hasFocus && !isConnected) ? markerColor.withValues(alpha: 0.15) : markerColor; @@ -2381,10 +2653,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { : Colors.white; // Display hex ID based on per-repeater hop_bytes (or regional admin override) - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final displayId = + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; final isLongId = displayId.length > 2; - final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; + final markerWidth = displayId.length > 4 + ? 48.0 + : isLongId + ? 40.0 + : 28.0; // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded final borderRadius = effectiveBytes >= 3 @@ -2398,7 +2675,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { width: markerWidth, height: 28, child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), + onTap: () => _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride), child: Container( padding: isLongId ? const EdgeInsets.symmetric(horizontal: 4) @@ -2407,19 +2686,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { color: effectiveColor, borderRadius: borderRadius, border: Border.all(color: effectiveBorderColor, width: 2), - boxShadow: (hasFocus && !isConnected) ? null : const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], + boxShadow: (hasFocus && !isConnected) + ? null + : const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], ), alignment: Alignment.center, child: Text( displayId, style: TextStyle( - fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, + fontSize: displayId.length > 4 + ? 8 + : isLongId + ? 9 + : 10, fontWeight: FontWeight.bold, color: effectiveTextColor, fontFamily: 'monospace', @@ -2438,7 +2723,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final style = context.read().preferences.gpsMarkerStyle; // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk' || style == 'chomper'; + final shouldRotate = + style == 'arrow' || style == 'walk' || style == 'chomper'; final CustomPainter painter; switch (style) { @@ -2458,14 +2744,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } final child = CustomPaint(size: const Size(24, 24), painter: painter); - return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; + return shouldRotate + ? Transform.rotate(angle: headingRadians, child: child) + : child; } /// Compute node column width based on hop byte count. /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { final appState = context.read(); - final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + final hopBytes = appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes; switch (hopBytes) { case 2: return 70 + extraPadding; @@ -2488,7 +2778,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { snrValues: heardRepeaters.map((r) => r.snr).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } } @@ -2506,7 +2797,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2520,9 +2812,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), + child: Icon(Icons.arrow_upward, + color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2531,15 +2825,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ Text( 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -2557,15 +2856,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -2584,7 +2891,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Repeaters section header Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + heardRepeaters.isEmpty + ? 'No repeaters heard' + : 'Heard Repeaters (${heardRepeaters.length})', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -2600,13 +2909,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -2616,7 +2930,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2627,7 +2943,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2638,32 +2956,49 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; - final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', + value: + repeater.snr?.toStringAsFixed(1) ?? + '-', color: snrColor, ), ), @@ -2672,7 +3007,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Expanded( child: Center( child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', color: rssiColor, ), ), @@ -2705,7 +3042,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { snrValues: [ping.snr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -2716,7 +3054,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2730,9 +3069,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, color: Colors.blue, size: 24), + child: const Icon(Icons.arrow_downward, + color: Colors.blue, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2742,8 +3083,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Text( 'RX Ping', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), @@ -2771,11 +3112,17 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -2809,13 +3156,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -2825,7 +3177,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2836,7 +3190,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2847,7 +3203,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -2857,13 +3215,19 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data row InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -2878,12 +3242,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Center( child: _buildStatChip( value: '${ping.rssi}', - color: rssiColor, + color: rssiColor, + ), ), ), - ), - ], - ), + ], + ), ), ), ], @@ -2902,10 +3266,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -2923,7 +3289,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2937,9 +3304,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: _discMarkerColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + border: Border.all( + color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: + Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2948,15 +3317,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ Text( 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -2974,15 +3348,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3019,13 +3401,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -3035,7 +3422,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3046,7 +3435,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3057,7 +3448,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3068,24 +3461,36 @@ class _MapWidgetState extends State with TickerProviderStateMixin { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { final rxSnrColor = PingColors.snrColor(node.localSnr); - final rssiColor = PingColors.rssiColor(node.localRssi); - final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Node ID with type @@ -3093,7 +3498,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { width: _nodeColumnWidth(extraPadding: 20), child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), Text( node.nodeTypeLabel, style: TextStyle( @@ -3127,7 +3534,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Expanded( child: Center( child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), + value: + node.remoteSnr.toStringAsFixed(1), color: txSnrColor, ), ), @@ -3170,7 +3578,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + void _showRepeaterDetails(Repeater repeater, + {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -3196,7 +3605,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3206,7 +3616,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); final isLongId = displayId.length > 2; return Container( constraints: const BoxConstraints(minWidth: 44), @@ -3236,8 +3647,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Text( repeater.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -3257,7 +3668,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Row( children: [ if (isDuplicate) ...[ - _buildRepeaterStatusChip('Duplicate', _repeaterDuplicateColor), + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), const SizedBox(width: 8), ], _buildRepeaterStatusChip(statusLabel, statusColor), @@ -3271,14 +3683,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Location row Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3296,7 +3715,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Last heard row Row( children: [ - Icon(Icons.access_time, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.access_time, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3476,7 +3898,13 @@ class _BikeMarkerPainter extends CustomPainter { ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear ..moveTo(cx, cy - 5) ..lineTo(cx + 2, cy - 7); // Handlebar - canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + canvas.drawPath( + framePath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round); // Blue wheels canvas.drawCircle(leftWheel, wheelR, bikePaint); @@ -3530,11 +3958,21 @@ class _BoatMarkerPainter extends CustomPainter { canvas.drawPath(hull, fillPaint); // Mast outline - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round); // Mast - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = const Color(0xFF2196F3) + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round); // Sail outline final sailOutline = ui.Path() @@ -3550,7 +3988,11 @@ class _BoatMarkerPainter extends CustomPainter { ..lineTo(cx + 6, cy - 0.5) ..lineTo(cx + 1, cy - 0.5) ..close(); - canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + canvas.drawPath( + sail, + Paint() + ..color = const Color(0xFF64B5F6) + ..style = PaintingStyle.fill); } @override @@ -3583,7 +4025,12 @@ class _WalkMarkerPainter extends CustomPainter { ..style = PaintingStyle.fill; // Head outline + fill - canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle( + Offset(cx, cy - 7), + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); // Body outline @@ -3592,9 +4039,11 @@ class _WalkMarkerPainter extends CustomPainter { canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); // Arms outline - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); // Arms - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); // Left leg outline canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); @@ -3674,7 +4123,9 @@ class _PinMarkerPainter extends CustomPainter { final cx = size.width / 2; final cy = size.height / 2; - final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; final outlinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke @@ -3710,11 +4161,13 @@ class _PinMarkerPainter extends CustomPainter { canvas.drawCircle(headCenter, headRadius, outlinePaint); // Inner dot - canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + canvas.drawCircle( + headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); } @override - bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a diamond marker for coverage dots @@ -3754,7 +4207,8 @@ class _DiamondMarkerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// A stateful widget for sound item with play button visual feedback @@ -3807,7 +4261,9 @@ class _SoundItemWidgetState extends State<_SoundItemWidget> { : Colors.blue.withValues(alpha: 0.2), shape: BoxShape.circle, border: Border.all( - color: _isPlaying ? Colors.blue : Colors.blue.withValues(alpha: 0.5), + color: _isPlaying + ? Colors.blue + : Colors.blue.withValues(alpha: 0.5), width: _isPlaying ? 2 : 1, ), ), diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 568807cf..77510bc3 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -12,13 +12,16 @@ class InteractiveNoiseFloorChart extends StatefulWidget { final NoiseFloorSession session; final bool isLive; - const InteractiveNoiseFloorChart({super.key, required this.session, this.isLive = false}); + const InteractiveNoiseFloorChart( + {super.key, required this.session, this.isLive = false}); @override - State createState() => InteractiveNoiseFloorChartState(); + State createState() => + InteractiveNoiseFloorChartState(); } -class InteractiveNoiseFloorChartState extends State { +class InteractiveNoiseFloorChartState + extends State { // View window in seconds late double _viewStart; late double _viewEnd; @@ -68,7 +71,8 @@ class InteractiveNoiseFloorChartState extends State final effectiveTotal = newTotal < 60 ? 60.0 : newTotal; // Detect if user is at full (unzoomed) view: start near 0 and end near total - final isFullView = _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; + final isFullView = + _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; _totalDuration = effectiveTotal; @@ -92,14 +96,18 @@ class InteractiveNoiseFloorChartState extends State double get _visibleDuration => _viewEnd - _viewStart; double get _zoomLevel => _totalDuration / _visibleDuration; - void _handleScaleStart(ScaleStartDetails details, double chartWidth, double chartLeft) { + void _handleScaleStart( + ScaleStartDetails details, double chartWidth, double chartLeft) { _gestureStartViewStart = _viewStart; _gestureStartViewEnd = _viewEnd; _gestureStartFocalX = details.localFocalPoint.dx; } - void _handleScaleUpdate(ScaleUpdateDetails details, double chartWidth, double chartLeft) { - if (_gestureStartViewStart == null || _gestureStartViewEnd == null || _gestureStartFocalX == null) { + void _handleScaleUpdate( + ScaleUpdateDetails details, double chartWidth, double chartLeft) { + if (_gestureStartViewStart == null || + _gestureStartViewEnd == null || + _gestureStartFocalX == null) { return; } @@ -110,7 +118,8 @@ class InteractiveNoiseFloorChartState extends State newDuration = newDuration.clamp(_minVisibleSeconds, _totalDuration); // Calculate focal point ratio in chart space - final focalRatio = ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); + final focalRatio = + ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); // Time at focal point in original view final focalTime = _gestureStartViewStart! + (startDuration * focalRatio); @@ -150,7 +159,8 @@ class InteractiveNoiseFloorChartState extends State } /// Check if tap hit a marker and show popup if so - void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, double chartHeight, double chartTop) { + void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, + double chartHeight, double chartTop) { final session = widget.session; if (session.markers.isEmpty || session.samples.isEmpty) return; @@ -161,7 +171,8 @@ class InteractiveNoiseFloorChartState extends State // Find if tap is within any marker for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < _viewStart || elapsed > _viewEnd) continue; @@ -176,7 +187,8 @@ class InteractiveNoiseFloorChartState extends State final tapX = details.localPosition.dx; final tapY = details.localPosition.dy; - final distance = ((tapX - markerX) * (tapX - markerX) + (tapY - markerY) * (tapY - markerY)); + final distance = ((tapX - markerX) * (tapX - markerX) + + (tapY - markerY) * (tapY - markerY)); if (distance <= _markerTapRadius * _markerTapRadius) { _showMarkerDetails(marker, noiseFloorOnLine.round()); return; @@ -185,9 +197,14 @@ class InteractiveNoiseFloorChartState extends State } /// Interpolate noise floor at given elapsed time - double _interpolateNoiseFloor(double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + double _interpolateNoiseFloor( + double elapsedSeconds, NoiseFloorSession session) { + if (session.samples.isEmpty) { + return widget.session.noiseFloorRange.min.toDouble(); + } + if (session.samples.length == 1) { + return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -195,7 +212,8 @@ class InteractiveNoiseFloorChartState extends State double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -210,8 +228,10 @@ class InteractiveNoiseFloorChartState extends State if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } /// Show marker details popup as a modern bottom sheet @@ -260,7 +280,10 @@ class InteractiveNoiseFloorChartState extends State width: 40, height: 4, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), @@ -282,8 +305,8 @@ class InteractiveNoiseFloorChartState extends State Text( eventTypeLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), Text( @@ -325,7 +348,8 @@ class InteractiveNoiseFloorChartState extends State context, icon: Icons.location_on, label: 'Location', - value: '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', + value: + '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', compact: true, ), ), @@ -334,19 +358,26 @@ class InteractiveNoiseFloorChartState extends State ), // Repeaters section (table format like TX log) - if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ + if (marker.repeaters != null && + marker.repeaters!.isNotEmpty) ...[ const SizedBox(height: 16), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ SizedBox( @@ -356,7 +387,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -367,7 +400,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -378,16 +413,20 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows - ...marker.repeaters!.map((r) => _buildRepeaterRow(context, r)), + ...marker.repeaters! + .map((r) => _buildRepeaterRow(context, r)), ], ), ), @@ -401,7 +440,8 @@ class InteractiveNoiseFloorChartState extends State child: FilledButton.icon( onPressed: () { // Get references before popping - final appState = Provider.of(context, listen: false); + final appState = Provider.of(context, + listen: false); final navigator = Navigator.of(context); // Pop the bottom sheet first @@ -412,7 +452,8 @@ class InteractiveNoiseFloorChartState extends State navigator.popUntil((route) => route.isFirst); // Navigate to map and center on location - appState.navigateToMapCoordinates(marker.latitude!, marker.longitude!); + appState.navigateToMapCoordinates( + marker.latitude!, marker.longitude!); }, icon: const Icon(Icons.map, size: 18), label: const Text('View on Map'), @@ -444,7 +485,10 @@ class InteractiveNoiseFloorChartState extends State return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -487,17 +531,21 @@ class InteractiveNoiseFloorChartState extends State final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fullHexId: repeater.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ // Node ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip( + repeaterId: repeater.repeaterId, fontSize: 11, width: 50), // SNR chip Expanded( child: Center( - child: _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), + child: + _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), ), ), // RSSI chip @@ -575,24 +623,32 @@ class InteractiveNoiseFloorChartState extends State Expanded( child: LayoutBuilder( builder: (context, constraints) { - final chartWidth = constraints.maxWidth - leftPadding - rightPadding; + final chartWidth = + constraints.maxWidth - leftPadding - rightPadding; - final chartHeight = constraints.maxHeight - topPadding - 36.0; // 36 = bottom axis reserved + final chartHeight = constraints.maxHeight - + topPadding - + 36.0; // 36 = bottom axis reserved return RawGestureDetector( gestures: { - ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + ScaleGestureRecognizer>( () => ScaleGestureRecognizer(), (ScaleGestureRecognizer instance) { - instance.onStart = (details) => _handleScaleStart(details, chartWidth, leftPadding); - instance.onUpdate = (details) => _handleScaleUpdate(details, chartWidth, leftPadding); + instance.onStart = (details) => + _handleScaleStart(details, chartWidth, leftPadding); + instance.onUpdate = (details) => + _handleScaleUpdate(details, chartWidth, leftPadding); instance.onEnd = _handleScaleEnd; }, ), - TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + TapGestureRecognizer>( () => TapGestureRecognizer(), (TapGestureRecognizer instance) { - instance.onTapUp = (details) => _handleTap(details, chartWidth, leftPadding, chartHeight, topPadding); + instance.onTapUp = (details) => _handleTap(details, + chartWidth, leftPadding, chartHeight, topPadding); }, ), }, @@ -601,7 +657,8 @@ class InteractiveNoiseFloorChartState extends State children: [ // Line chart - wrapped in IgnorePointer so it doesn't steal gestures Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: LineChart( LineChartData( @@ -622,7 +679,8 @@ class InteractiveNoiseFloorChartState extends State ), // Marker overlay Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: CustomPaint( size: Size.infinite, @@ -664,13 +722,15 @@ class InteractiveNoiseFloorChartState extends State LineChartBarData _buildLineData(NoiseFloorSession session) { // Return cached data if session hasn't changed (prevents rebuilding during zoom) - if (_cachedLineData != null && _cachedSession == session && + if (_cachedLineData != null && + _cachedSession == session && _cachedSampleCount == session.samples.length) { return _cachedLineData!; } final spots = session.samples.map((s) { - final elapsed = s.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + s.timestamp.difference(session.startTime).inSeconds.toDouble(); return FlSpot(elapsed, s.noiseFloor.toDouble()); }).toList(); @@ -703,9 +763,9 @@ class InteractiveNoiseFloorChartState extends State ]; final stops = [ 0.0, - yToStop(-100), // Start fading from green - yToStop(-90), // Orange in middle - yToStop(-80), // Fade to red + yToStop(-100), // Start fading from green + yToStop(-90), // Orange in middle + yToStop(-80), // Fade to red 1.0, ]; @@ -919,7 +979,8 @@ class _MarkerPainter extends CustomPainter { if (visibleRange <= 0 || chartWidth <= 0 || chartHeight <= 0) return; for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < minX || elapsed > maxX) continue; @@ -948,7 +1009,9 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + if (session.samples.length == 1) { + return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -956,7 +1019,8 @@ class _MarkerPainter extends CustomPainter { double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -971,8 +1035,10 @@ class _MarkerPainter extends CustomPainter { if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } @override diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart index b54aec58..af3ed03c 100644 --- a/lib/widgets/offline_mode_toggle.dart +++ b/lib/widgets/offline_mode_toggle.dart @@ -84,16 +84,18 @@ class OfflineModeToggle extends StatelessWidget { } /// Show confirmation dialog explaining what the mode does - static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { - final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + static Future _showConfirmDialog( + BuildContext context, bool switchingToOffline) { + final title = + switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; final iconColor = switchingToOffline ? Colors.orange : Colors.green; final description = switchingToOffline ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' - 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' - 'You can upload saved data later from the Settings tab.' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' - 'This requires an active internet connection.'; + 'This requires an active internet connection.'; final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; return showDialog( @@ -147,7 +149,8 @@ class OfflineModeToggle extends StatelessWidget { return Material( color: Colors.transparent, child: InkWell( - onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + onTap: () => handleOfflineModeToggle( + context, appState, offlineMode, isConnected), borderRadius: BorderRadius.circular(10), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index c05b96f0..b20aee8c 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -15,29 +15,44 @@ class PingControls extends StatelessWidget { Widget build(BuildContext context) { final appState = context.watch(); final validation = appState.pingValidation; - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete - final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode + final isPendingDisable = appState + .isPendingDisable; // Disable pending, waiting for RX window to complete + final cooldownActive = appState + .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; // RX listening window after ping + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = + appState.rxWindowTimer.isRunning; // RX listening window after ping final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; // True immediately when manual ping button clicked - final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) - final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping + final isPingSending = appState + .isPingSending; // True immediately when manual ping button clicked + final isPingInProgress = appState + .isPingInProgress; // True during entire ping + RX window (includes auto pings) + final autoPingWaiting = + appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) + final autoPingSkipped = appState.autoPingTimer.skipReason != + null; // Last ping was skipped (e.g. distance) + final discoveryWindowActive = appState.discoveryWindowTimer + .isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; // TX is blocked when offline mode is active and connected @@ -53,7 +68,9 @@ class PingControls extends StatelessWidget { Color? blockingColor; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; if (!appState.isConnected) { // Don't show hint when disconnected - buttons are obviously disabled @@ -87,89 +104,135 @@ class PingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // Send Ping button - // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" - // Manual pings use 15-second cooldown, no distance requirement - // When Active/Passive Mode is running, just shows "Send Ping" (disabled) - Expanded( - child: _ActionButton( - icon: Icons.cell_tower, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isTxModeRunning - ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running - : isPingSending - ? 'Sending...' - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) - : manualCooldownActive - ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown - : discoveryWindowActive - ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow - onPressed: () => _sendPing(context, appState), - showCooldown: false, // No longer needed - countdown shown in label - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : null, // No "Move Xm" - manual pings have no distance requirement - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : null, + // Send Ping button + // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" + // Manual pings use 15-second cooldown, no distance requirement + // When Active/Passive Mode is running, just shows "Send Ping" (disabled) + Expanded( + child: _ActionButton( + icon: Icons.cell_tower, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isTxModeRunning + ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running + : isPingSending + ? 'Sending...' + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) + : manualCooldownActive + ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown + : discoveryWindowActive + ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: (isPingSending || rxWindowActive) && + !isTxModeRunning, // Only active during manual ping flow + onPressed: () => _sendPing(context, appState), + showCooldown: + false, // No longer needed - countdown shown in label + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : null, // No "Move Xm" - manual pings have no distance requirement + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : null, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), - // Active/Hybrid Mode button (toggle) - // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon - // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle - // When OFF after being ON: shows "Cooldown Xs" like other buttons - // During manual ping: shows "Cooldown Xs" (disabled) - Expanded( - child: _ActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isPendingDisable - ? (rxWindowActive - ? 'Stopping ${rxWindowRemaining}s' - : discoveryWindowActive - ? 'Stopping ${discoveryWindowRemaining}s' - : 'Stopping...') - : isTxModeRunning - ? (isPingInProgress && !rxWindowActive && !discoveryWindowActive - ? 'Sending...' - : discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // TX RX window - : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode', - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - showCooldown: false, - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : (isPendingDisable ? 'Stopping' : null), - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : Colors.orange, + // Active/Hybrid Mode button (toggle) + // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon + // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle + // When OFF after being ON: shows "Cooldown Xs" like other buttons + // During manual ping: shows "Cooldown Xs" (disabled) + Expanded( + child: _ActionButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isPendingDisable + ? (rxWindowActive + ? 'Stopping ${rxWindowRemaining}s' + : discoveryWindowActive + ? 'Stopping ${discoveryWindowRemaining}s' + : 'Stopping...') + : isTxModeRunning + ? (isPingInProgress && + !rxWindowActive && + !discoveryWindowActive + ? 'Sending...' + : discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // TX RX window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next ping ${autoPingRemaining}s') + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode') + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode', + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + showCooldown: false, + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : (isPendingDisable ? 'Stopping' : null), + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : Colors.orange, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), ], // Passive Mode button (toggle) @@ -182,24 +245,35 @@ class PingControls extends StatelessWidget { icon: Icons.hearing, label: isPassiveModeRunning ? (discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window + ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery - : 'Passive Mode') // Initial state before first discovery + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable - ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping + ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening + ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Passive Mode', color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || + autoPingWaiting), // Active during listening/waiting phases onPressed: () => _toggleRxAuto(context, appState), ), ), @@ -231,7 +305,9 @@ class PingControls extends StatelessWidget { // Targeted Ping controls _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, ), @@ -239,7 +315,8 @@ class PingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -249,17 +326,20 @@ class PingControls extends StatelessWidget { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -274,8 +354,8 @@ class _ActionButton extends StatefulWidget { final bool isActive; final bool showCooldown; final VoidCallback onPressed; - final String? subtitle; // Optional subtitle text (e.g., "Move 5m") - final Color? subtitleColor; // Optional subtitle color + final String? subtitle; // Optional subtitle text (e.g., "Move 5m") + final Color? subtitleColor; // Optional subtitle color const _ActionButton({ required this.icon, @@ -338,7 +418,8 @@ class _ActionButtonState extends State<_ActionButton> // Use color when enabled, active (RX listening), or during cooldown // This prevents the button from going grey during cooldown final showColor = widget.enabled || widget.isActive || widget.showCooldown; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; return AnimatedBuilder( @@ -378,7 +459,8 @@ class _ActionButtonState extends State<_ActionButton> size: 26, color: showColor ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Active indicator dot if (widget.isActive) @@ -407,9 +489,12 @@ class _ActionButtonState extends State<_ActionButton> widget.label, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, color: showColor - ? (widget.isActive ? effectiveColor : colorScheme.onSurface) + ? (widget.isActive + ? effectiveColor + : colorScheme.onSurface) : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), @@ -431,7 +516,8 @@ class _ActionButtonState extends State<_ActionButton> style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: widget.subtitleColor ?? Colors.orange.shade600, + color: widget.subtitleColor ?? + Colors.orange.shade600, ), ) : null, @@ -475,7 +561,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { WidgetsBinding.instance.addPostFrameCallback((_) { final appState = context.read(); final existing = appState.targetRepeaterId; - if (existing != null && existing.isNotEmpty && _controller.text != existing) { + if (existing != null && + existing.isNotEmpty && + _controller.text != existing) { _controller.text = existing; } }); @@ -545,14 +633,17 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { final buttonColor = (isTargetedRunning || _isStarting) ? const Color(0xFF22C55E) // green-500 when running/starting : Colors.cyan; - final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + final effectiveColor = + isEnabled ? buttonColor : colorScheme.onSurfaceVariant; return Container( decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), border: Border.all( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), width: isTargetedRunning ? 1.5 : 1, ), ), @@ -567,7 +658,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { HapticFeedback.lightImpact(); if (!isTargetedRunning) { setState(() => _isStarting = true); - appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + appState.setTargetRepeaterId( + _controller.text.trim().toUpperCase()); } await appState.toggleAutoPing(AutoMode.targeted); if (mounted) setState(() => _isStarting = false); @@ -594,8 +686,13 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : 'Trace Mode', style: TextStyle( fontSize: 13, - fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, - color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: isTargetedRunning + ? FontWeight.w600 + : FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), overflow: TextOverflow.ellipsis, ), @@ -622,14 +719,16 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : colorScheme.onSurface, ), decoration: InputDecoration( - hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintText: + 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', hintStyle: TextStyle( fontSize: 12, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), counterText: '', isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), @@ -705,21 +804,28 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -737,12 +843,17 @@ class _CompactPingControlsState extends State { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Determine which button is currently active (not during cooldown) - final sendPingCurrentlyActive = (isPingSending || rxWindowActive || manualCooldownActive) && !isTxModeRunning; + final sendPingCurrentlyActive = + (isPingSending || rxWindowActive || manualCooldownActive) && + !isTxModeRunning; final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; - final passiveModeCurrentlyActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeCurrentlyActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); // Track the last active button for cooldown if (sendPingCurrentlyActive) { @@ -755,14 +866,20 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { + if (!cooldownActive && + !manualCooldownActive && + !sendPingCurrentlyActive && + !activeModeCurrentlyActive && + !passiveModeCurrentlyActive && + !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } // Determine which button should be expanded // During cooldown, the last active button stays expanded final sendPingExpanded = sendPingCurrentlyActive || - (manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing) || + (manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing) || (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); final activeModeExpanded = activeModeCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.activeMode); @@ -770,36 +887,80 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; - final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; + final sendPingEnabled = canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable; + final sendPingActive = (isPingSending || rxWindowActive) && + !isTxModeRunning && + !cooldownActive && + !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet); - final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeEnabled = isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet); + final passiveModeActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; // Trace Mode (only relevant when a repeater ID has been entered) - final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final hasTargetRepeaterId = appState.targetRepeaterId != null && + appState.targetRepeaterId!.isNotEmpty; final targetedCurrentlyActive = isTargetedRunning; final traceModeExpanded = targetedCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); - final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && - !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && - !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeEnabled = hasTargetRepeaterId && + !isTxModeRunning && + !isPassiveModeRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + !manualCooldownActive && + appState.isConnected && + prefs.externalAntennaSet && + isPowerSet; final traceModeActive = isTargetedRunning; final traceModeShowColor = traceModeEnabled || traceModeActive; // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; + final anyExpanded = sendPingExpanded || + activeModeExpanded || + passiveModeExpanded || + traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); + final allDisabled = !sendPingShowColor && + !activeModeShowColor && + !passiveModeShowColor && + (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -822,9 +983,11 @@ class _CompactPingControlsState extends State { isExpanded: sendPingExpanded, progress: rxWindowActive && !isTxModeRunning ? appState.rxWindowTimer.progress - : manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.manualPingCooldownTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : cooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.cooldownTimer.progress : null, onPressed: () => _sendPing(context, appState), @@ -857,13 +1020,18 @@ class _CompactPingControlsState extends State { isActive: activeModeActive, isExpanded: activeModeExpanded, progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning - ? (discoveryWindowActive ? appState.discoveryWindowTimer.progress : appState.rxWindowTimer.progress) + ? (discoveryWindowActive + ? appState.discoveryWindowTimer.progress + : appState.rxWindowTimer.progress) : autoPingWaiting && isTxModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.activeMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode ? appState.cooldownTimer.progress : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), ); final passiveModeButton = _CompactActionButton( @@ -890,7 +1058,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isPassiveModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode ? appState.cooldownTimer.progress : null, onPressed: () => _toggleRxAuto(context, appState), @@ -921,7 +1090,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isTargetedRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + : cooldownActive && + _lastActiveButton == _LastActiveButton.targeted ? appState.cooldownTimer.progress : null, onPressed: () { @@ -937,23 +1107,23 @@ class _CompactPingControlsState extends State { return Row( children: [ if (!txNotAllowed) ...[ - // Send Ping - expanded buttons stay big even when grey (cooldown) - if (sendPingExpanded) - Expanded(child: sendPingButton) - else if (!anyExpanded && (sendPingShowColor || allDisabled)) - Expanded(child: sendPingButton) - else - sendPingButton, - const SizedBox(width: 6), - - // Active Mode - if (activeModeExpanded) - Expanded(child: activeModeButton) - else if (!anyExpanded && (activeModeShowColor || allDisabled)) - Expanded(child: activeModeButton) - else - activeModeButton, - const SizedBox(width: 6), + // Send Ping - expanded buttons stay big even when grey (cooldown) + if (sendPingExpanded) + Expanded(child: sendPingButton) + else if (!anyExpanded && (sendPingShowColor || allDisabled)) + Expanded(child: sendPingButton) + else + sendPingButton, + const SizedBox(width: 6), + + // Active Mode + if (activeModeExpanded) + Expanded(child: activeModeButton) + else if (!anyExpanded && (activeModeShowColor || allDisabled)) + Expanded(child: activeModeButton) + else + activeModeButton, + const SizedBox(width: 6), ], // Passive Mode @@ -993,10 +1163,26 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + if (rxWindowActive) { + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (manualCooldownActive) { + return showFullText + ? 'Cooldown ${manualCooldownRemaining}s' + : '${manualCooldownRemaining}s'; + } + if (discoveryWindowActive) { + return showFullText + ? 'Cooldown ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (cooldownActive) { + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; + } return null; } @@ -1019,19 +1205,45 @@ class _CompactPingControlsState extends State { int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (rxWindowActive) { + return showFullText + ? 'Stopping ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (discoveryWindowActive) { + return showFullText + ? 'Stopping ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (isPingInProgress && !rxWindowActive) { + return showFullText ? 'Sending...' : '...'; + } + if (rxWindowActive) { + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1051,12 +1263,24 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1076,18 +1300,31 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isTargetedRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -1097,17 +1334,20 @@ class _CompactPingControlsState extends State { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1123,21 +1363,28 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -1153,7 +1400,9 @@ class LandscapePingControls extends StatelessWidget { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1172,58 +1421,85 @@ class LandscapePingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // TX Ping button - Expanded( - child: _LandscapeIconButton( - icon: Icons.cell_tower, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, - countdown: isPingSending - ? null - : rxWindowActive && !isTxModeRunning - ? rxWindowRemaining - : manualCooldownActive - ? manualCooldownRemaining - : discoveryWindowActive - ? discoveryWindowRemaining - : cooldownActive - ? cooldownRemaining - : null, - onPressed: () => _sendPing(context, appState), + // TX Ping button + Expanded( + child: _LandscapeIconButton( + icon: Icons.cell_tower, + tooltip: + txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: + (isPingSending || rxWindowActive) && !isTxModeRunning, + countdown: isPingSending + ? null + : rxWindowActive && !isTxModeRunning + ? rxWindowRemaining + : manualCooldownActive + ? manualCooldownRemaining + : discoveryWindowActive + ? discoveryWindowRemaining + : cooldownActive + ? cooldownRemaining + : null, + onPressed: () => _sendPing(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), - // Active/Hybrid Mode button - Expanded( - child: _LandscapeIconButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - countdown: isTxModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : rxWindowActive - ? rxWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : isPendingDisable && (rxWindowActive || discoveryWindowActive) - ? (rxWindowActive ? rxWindowRemaining : discoveryWindowRemaining) - : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + // Active/Hybrid Mode button + Expanded( + child: _LandscapeIconButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + countdown: isTxModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : rxWindowActive + ? rxWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : isPendingDisable && + (rxWindowActive || discoveryWindowActive) + ? (rxWindowActive + ? rxWindowRemaining + : discoveryWindowRemaining) + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), ], // Passive Mode button @@ -1234,10 +1510,18 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || autoPingWaiting), countdown: isPassiveModeRunning ? (discoveryWindowActive ? discoveryWindowRemaining @@ -1254,7 +1538,9 @@ class LandscapePingControls extends StatelessWidget { // Targeted Ping controls (Trace Mode) _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, compact: true, @@ -1263,22 +1549,26 @@ class LandscapePingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); await appState.sendPing(); } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1321,20 +1611,26 @@ class _LandscapeAntennaSelector extends StatelessWidget { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: externalAntennaSet ? colorScheme.onSurfaceVariant : notSetColor, + color: externalAntennaSet + ? colorScheme.onSurfaceVariant + : notSetColor, ), ), if (!externalAntennaSet) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: notSetColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text( 'Required', - style: TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: notSetColor), + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: notSetColor), ), ), ], @@ -1347,7 +1643,8 @@ class _LandscapeAntennaSelector extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), + border: + Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), ), child: Row( children: [ @@ -1361,22 +1658,30 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (!externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(left: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'Internal', style: TextStyle( fontSize: 11, - fontWeight: (!externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (!externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (!externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (!externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), ), ), // Divider - Container(width: 1, height: 18, color: colorScheme.outline.withValues(alpha: 0.3)), + Container( + width: 1, + height: 18, + color: colorScheme.outline.withValues(alpha: 0.3)), // External option Expanded( child: GestureDetector( @@ -1387,15 +1692,20 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(right: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'External', style: TextStyle( fontSize: 11, - fontWeight: (externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), @@ -1475,7 +1785,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; return AnimatedBuilder( animation: _pulseAnimation, @@ -1495,7 +1806,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.25), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.25), width: widget.isActive ? 1.5 : 1, ), ), @@ -1506,7 +1818,9 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Icon( widget.icon, size: 24, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), // Countdown badge (bottom right) if (widget.countdown != null) @@ -1514,7 +1828,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> bottom: 4, right: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), decoration: BoxDecoration( color: effectiveColor, borderRadius: BorderRadius.circular(6), @@ -1540,7 +1855,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> decoration: BoxDecoration( color: const Color(0xFF22C55E), shape: BoxShape.circle, - border: Border.all(color: colorScheme.surface, width: 1.5), + border: Border.all( + color: colorScheme.surface, width: 1.5), ), ), ), @@ -1566,7 +1882,8 @@ class _CompactActionButton extends StatefulWidget { final bool isActive; final bool isExpanded; // When true, show icon + label with wider width final VoidCallback onPressed; - final double? progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar + final double? + progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar const _CompactActionButton({ required this.icon, @@ -1625,7 +1942,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); @@ -1647,7 +1965,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(16), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.3), width: widget.isActive ? 1.5 : 1, ), ), @@ -1683,7 +2002,10 @@ class _CompactActionButtonState extends State<_CompactActionButton> Icon( widget.icon, size: 18, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Animated label - show when label is provided AnimatedSize( @@ -1698,8 +2020,13 @@ class _CompactActionButtonState extends State<_CompactActionButton> widget.label!, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), ), ], diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 10a909e1..b149cde9 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -27,7 +27,8 @@ class RegionalConfigCard extends StatelessWidget { } // When offline mode is enabled, show "-" for zone fields - final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); + final displayZoneName = + isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; return Card( @@ -41,19 +42,22 @@ class RegionalConfigCard extends StatelessWidget { children: [ Icon( isOfflineMode ? Icons.cloud_off : Icons.public, - color: isOfflineMode ? Colors.orange : Theme.of(context).colorScheme.primary, + color: isOfflineMode + ? Colors.orange + : Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( 'Regional Configuration', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), if (isOfflineMode) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -137,16 +141,16 @@ class RegionalConfigCard extends StatelessWidget { Text( 'Regional Settings', style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), if (displayZone != null) Text( displayZone, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), @@ -172,7 +176,8 @@ class RegionalConfigCard extends StatelessWidget { } /// Compact labeled row: small label on left, chips on right - Widget _buildCompactRow(BuildContext context, String label, List chips) { + Widget _buildCompactRow( + BuildContext context, String label, List chips) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -201,7 +206,8 @@ class RegionalConfigCard extends StatelessWidget { ); } - Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, + Widget _buildInfoRow( + BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( children: [ @@ -211,20 +217,26 @@ class RegionalConfigCard extends StatelessWidget { if (value != null) ...[ const SizedBox(width: 8), Expanded( - child: Text(value, style: TextStyle( - color: isOffline - ? Colors.orange.shade700 - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - )), + child: Text(value, + style: TextStyle( + color: isOffline + ? Colors.orange.shade700 + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + )), ), ], ], ); } - Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { + Widget _buildChannelChip(BuildContext context, String name, + {bool isDefault = false}) { // Public channel doesn't use # prefix; scope/plain values pass through as-is - final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + final displayName = + name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); // If it doesn't look like a channel name, show raw value (e.g. scope "Global") final isChannel = name.startsWith('#') || name == 'Public'; final label = isChannel ? displayName : name; @@ -247,7 +259,9 @@ class RegionalConfigCard extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, + color: isDefault + ? Colors.grey + : Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 43a4ca42..2144f049 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -34,10 +34,10 @@ class RepeaterIdChip extends StatelessWidget { Widget build(BuildContext context) { // Scale font size down for longer IDs final effectiveFontSize = repeaterId.length > 4 - ? fontSize - 2.0 // 6-char IDs (3-byte) + ? fontSize - 2.0 // 6-char IDs (3-byte) : repeaterId.length > 2 - ? fontSize - 1.0 // 4-char IDs (2-byte) - : fontSize; // 2-char IDs (1-byte) + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) final child = Row( mainAxisSize: MainAxisSize.min, @@ -57,7 +57,10 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.info_outline, size: fontSize - 1, - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ), ], ); @@ -80,7 +83,8 @@ class RepeaterIdChip extends StatelessWidget { /// /// When [fromLatLng] is provided, distances are measured from that point /// (e.g. the ping's GPS location) instead of the user's current position. - static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId, ({double lat, double lon})? fromLatLng}) { + static void showRepeaterPopup(BuildContext context, String repeaterId, + {String? fullHexId, ({double lat, double lon})? fromLatLng}) { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; @@ -106,7 +110,8 @@ class RepeaterIdChip extends StatelessWidget { ? fullHexId.substring(0, 8) : repeaterId; final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); if (matches.isEmpty) { @@ -130,17 +135,23 @@ class RepeaterIdChip extends StatelessWidget { // Sort by distance (closest first) when a reference point is available if (refLat != null && refLon != null) { matches.sort((a, b) { - final distA = GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); - final distB = GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); + final distA = + GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = + GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); return distA.compareTo(distB); }); } - final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, refLat: refLat, refLon: refLon, regionHopBytesOverride: regionOverride)) + .map((r) => _buildRepeaterRow(context, r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -205,7 +216,8 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; @@ -213,7 +225,10 @@ class RepeaterIdChip extends StatelessWidget { String? distanceText; if (refLat != null && refLon != null) { final meters = GpsService.distanceBetween( - refLat, refLon, repeater.lat, repeater.lon, + refLat, + refLon, + repeater.lat, + repeater.lon, ); debugLog('[UI] Distance to ${repeater.name}: ' 'from (${refLat.toStringAsFixed(5)}, ${refLon.toStringAsFixed(5)}) ' @@ -225,8 +240,7 @@ class RepeaterIdChip extends StatelessWidget { if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -235,7 +249,9 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), + _buildHexBadge( + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), + badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -268,7 +284,8 @@ class RepeaterIdChip extends StatelessWidget { distanceText, style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -314,9 +331,8 @@ class RepeaterIdChip extends StatelessWidget { return Container( constraints: const BoxConstraints(minWidth: 28), height: 28, - padding: isLong - ? const EdgeInsets.symmetric(horizontal: 5) - : EdgeInsets.zero, + padding: + isLong ? const EdgeInsets.symmetric(horizontal: 5) : EdgeInsets.zero, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(14), diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index f78950b5..4bc45fa8 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -69,10 +69,16 @@ class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { // By distance if GPS available if (position != null) { final distA = GpsService.distanceBetween( - position.latitude, position.longitude, a.lat, a.lon, + position.latitude, + position.longitude, + a.lat, + a.lon, ); final distB = GpsService.distanceBetween( - position.latitude, position.longitude, b.lat, b.lon, + position.latitude, + position.longitude, + b.lat, + b.lon, ); return distA.compareTo(distB); } @@ -227,20 +233,23 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; if (position != null) { final meters = GpsService.distanceBetween( - position!.latitude, position!.longitude, repeater.lat, repeater.lon, + position!.latitude, + position!.longitude, + repeater.lat, + repeater.lon, ); if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -323,8 +332,7 @@ class _RepeaterTile extends StatelessWidget { decoration: BoxDecoration( color: badgeColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: - Border.all(color: badgeColor.withValues(alpha: 0.4)), + border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 403b6d99..9b139b7a 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -58,7 +58,8 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -119,51 +120,102 @@ class _StatusBarState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); + return ( + 'Discovery Requests', + 'Discovery requests we have heard a response for.', + Icons.radar, + PingColors.discSuccess + ); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -180,7 +232,11 @@ class _StatusBarState extends State { icon = Icons.flight; color = Colors.grey; text = '-'; - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } // Show GPS region (e.g., "YOW") when locked and inside a zone @@ -191,7 +247,8 @@ class _StatusBarState extends State { icon = Icons.flight; color = appState.isConnected ? (appState.txAllowed ? Colors.green : Colors.red) - : Colors.grey; // Grey when not connected, red when zone is at TX capacity + : Colors + .grey; // Grey when not connected, red when zone is at TX capacity text = appState.zoneCode!; } else if (appState.inZone == false) { // GPS locked but outside any zone @@ -229,7 +286,11 @@ class _StatusBarState extends State { break; } - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } Widget _buildStatsIndicator(BuildContext context, AppStateProvider appState) { @@ -392,7 +453,8 @@ class _AnimatedStatChipState extends State<_AnimatedStatChip> child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: widget.color.withValues(alpha: _highlightAnimation.value), + color: + widget.color.withValues(alpha: _highlightAnimation.value), borderRadius: BorderRadius.circular(8), border: Border.all(color: widget.color.withValues(alpha: 0.4)), ), diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart index 4ba980ea..99660b15 100644 --- a/lib/widgets/upload_logs_dialog.dart +++ b/lib/widgets/upload_logs_dialog.dart @@ -162,15 +162,16 @@ class _UploadLogsSheetState extends State { // Build the upload list using the user's selection applied to the freshly rotated files. // Selected paths from before rotation still match, plus any newly rotated file is included. final selectedPaths = Set.from(_selectedLogFiles); - final filesToUpload = freshFiles - .where((f) => selectedPaths.contains(f.path)) - .toList(); + final filesToUpload = + freshFiles.where((f) => selectedPaths.contains(f.path)).toList(); // If the rotation produced a new file that wasn't in the original selection // (i.e. the previously-active log that just got rotated), include it too // since the user selected "all" initially and this file has new content. - final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); - if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + final newFiles = + freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && + selectedPaths.length == _availableLogFiles.length) { filesToUpload.addAll(newFiles); } @@ -191,7 +192,8 @@ class _UploadLogsSheetState extends State { final publicKey = widget.appState.devicePublicKey ?? widget.appState.lastConnectedPublicKey ?? 'not-connected'; - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; final userNotes = _descriptionController.text.trim(); int uploadedCount = 0; @@ -220,7 +222,8 @@ class _UploadLogsSheetState extends State { onProgress: (p) { _onProgressUpdate(BugReportProgress( status: p.status, - progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + progress: + (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), currentFile: i + 1, totalFiles: totalFiles, )); @@ -242,7 +245,8 @@ class _UploadLogsSheetState extends State { success: uploadedCount > 0, uploadedCount: uploadedCount, failedCount: failedCount, - errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + errorMessage: + failedCount > 0 ? '$failedCount file(s) failed to upload' : null, ); Navigator.of(context).pop(result); @@ -287,13 +291,15 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Upload Logs', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -312,13 +318,15 @@ class _UploadLogsSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, children: [ // Explanation text Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), @@ -355,7 +363,8 @@ class _UploadLogsSheetState extends State { textCapitalization: TextCapitalization.sentences, decoration: _buildInputDecoration( theme, - hintText: 'Briefly describe why you\'re uploading these logs...', + hintText: + 'Briefly describe why you\'re uploading these logs...', alignLabelWithHint: true, ), maxLines: 3, @@ -381,10 +390,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -411,10 +422,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -437,17 +450,20 @@ class _UploadLogsSheetState extends State { else Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Column( children: [ // Select all / deselect all header Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), child: Row( children: [ Text( @@ -460,7 +476,8 @@ class _UploadLogsSheetState extends State { TextButton( onPressed: () { setState(() { - if (_selectedLogFiles.length == _availableLogFiles.length) { + if (_selectedLogFiles.length == + _availableLogFiles.length) { _selectedLogFiles.clear(); } else { _selectedLogFiles.clear(); @@ -471,7 +488,8 @@ class _UploadLogsSheetState extends State { }); }, child: Text( - _selectedLogFiles.length == _availableLogFiles.length + _selectedLogFiles.length == + _availableLogFiles.length ? 'Deselect All' : 'Select All', ), @@ -481,22 +499,28 @@ class _UploadLogsSheetState extends State { ), Divider( height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: theme.colorScheme.outline + .withValues(alpha: 0.3), ), // File list ...List.generate(_availableLogFiles.length, (index) { final file = _availableLogFiles[index]; final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); + final isSelected = + _selectedLogFiles.contains(file.path); String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } return ListTile( @@ -512,9 +536,11 @@ class _UploadLogsSheetState extends State { style: const TextStyle(fontSize: 13), ), trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, + color: + theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( @@ -524,7 +550,9 @@ class _UploadLogsSheetState extends State { ), ), ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), ); }), ], @@ -589,7 +617,8 @@ class _UploadLogsSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -642,7 +671,8 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Uploading...', style: theme.textTheme.titleLarge), ], @@ -663,7 +693,8 @@ class _UploadLogsSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -678,16 +709,16 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 32), - Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), - if (_totalFiles != null && _currentFile != null) Text( 'File $_currentFile of $_totalFiles', @@ -696,7 +727,6 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 24), - SizedBox( width: 250, child: Column( @@ -705,7 +735,8 @@ class _UploadLogsSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), From 2cd9c4736ead84e841963d7bcf870091cbf4a3a7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 14 Apr 2026 22:23:00 -0400 Subject: [PATCH 066/360] -update Podfile.lock and add Package.resolved for new dependencies; refactor repeater popup logic - Fixed iOS App Store upload rejection (ITMS-90683) caused by unused permission macros being compiled into the binary. The build now only includes location and notification permissions. No user-facing behavior change. - Tapping a repeater ID chip no longer gets stuck on "Repeater data not available." Now fetches on demand with a loading indicator and tap-to-retry. Works in offline mode too, where repeater names were previously never populated --- ios/Podfile | 22 ++ ios/Podfile.lock | 111 ------- ios/Runner.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/swiftpm/Package.resolved | 59 ++++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++ .../xcshareddata/swiftpm/Package.resolved | 59 ++++ lib/providers/app_state_provider.dart | 18 ++ lib/widgets/repeater_id_chip.dart | 288 +++++++++++++----- 8 files changed, 407 insertions(+), 190 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/Podfile b/ios/Podfile index 620e46eb..c84fc8a9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,5 +39,27 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_EVENTS=0', + 'PERMISSION_EVENTS_FULL_ACCESS=0', + 'PERMISSION_REMINDERS=0', + 'PERMISSION_CONTACTS=0', + 'PERMISSION_CAMERA=0', + 'PERMISSION_MICROPHONE=0', + 'PERMISSION_SPEECH_RECOGNIZER=0', + 'PERMISSION_PHOTOS=0', + 'PERMISSION_LOCATION=1', + 'PERMISSION_NOTIFICATIONS=1', + 'PERMISSION_MEDIA_LIBRARY=0', + 'PERMISSION_SENSORS=0', + 'PERMISSION_BLUETOOTH=0', + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', + 'PERMISSION_CRITICAL_ALERTS=0', + 'PERMISSION_ASSISTANT=0', + ] + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a4c7131..ebb426d3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,144 +1,33 @@ PODS: - - audio_session (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter - - geolocator_apple (1.2.0): - - Flutter - - FlutterMacOS - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - package_info_plus (0.4.5): - - Flutter - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftyGif (5.4.5) - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - SDWebImage - - SwiftyGif EXTERNAL SOURCES: - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" - geolocator_apple: - :path: ".symlinks/plugins/geolocator_apple/darwin" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 60334cff..d045a55f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ 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 */ @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -120,6 +123,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -187,6 +191,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -752,6 +762,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..4d7193ee --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d42..c3fedb29 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + refetchRepeatersIfPossible() async { + if (_repeaters.isNotEmpty) return; + final iata = (zoneCode?.isNotEmpty == true) + ? zoneCode + : _preferences.iataCode; + if (iata == null || iata.isEmpty) { + debugLog('[MAP] refetchRepeatersIfPossible: no IATA available, skipping'); + return; + } + _repeatersLoaded = false; + _repeatersLoadedForIata = null; + await _fetchRepeatersForZone(iata); + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 2144f049..90296d0b 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -85,86 +85,18 @@ class RepeaterIdChip extends StatelessWidget { /// (e.g. the ping's GPS location) instead of the user's current position. static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId, ({double lat, double lon})? fromLatLng}) { - final appState = Provider.of(context, listen: false); - final repeaters = appState.repeaters; - - final Widget content; - - if (repeaters.isEmpty) { - content = Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - 'Repeater data not available', - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ); - } else { - // DISC pings provide the full public key — match first 8 hex chars - // (4 bytes) against repeater hexId for exact identification. - // TX/RX pings only have 1-byte IDs so fall back to prefix matching. - final matchKey = fullHexId != null && fullHexId.length >= 8 - ? fullHexId.substring(0, 8) - : repeaterId; - final matches = repeaters - .where( - (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) - .toList(); - - if (matches.isEmpty) { - content = Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - 'Unknown repeater', - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ); - } else { - // Use explicit origin point if provided, otherwise fall back to current GPS - final position = appState.currentPosition; - final refLat = fromLatLng?.lat ?? position?.latitude; - final refLon = fromLatLng?.lon ?? position?.longitude; - - // Sort by distance (closest first) when a reference point is available - if (refLat != null && refLon != null) { - matches.sort((a, b) { - final distA = - GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); - final distB = - GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); - return distA.compareTo(distB); - }); - } - - final regionOverride = - appState.enforceHopBytes ? appState.effectiveHopBytes : null; - content = Column( - mainAxisSize: MainAxisSize.min, - children: matches - .map((r) => _buildRepeaterRow(context, r, - refLat: refLat, - refLon: refLon, - regionHopBytesOverride: regionOverride)) - .toList(), - ); - } - } - showDialog( context: context, builder: (dialogContext) => Dialog( - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundColor: + Theme.of(dialogContext).colorScheme.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + color: Theme.of(dialogContext) + .colorScheme + .outline + .withValues(alpha: 0.3), ), ), child: ConstrainedBox( @@ -181,7 +113,7 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.cell_tower, size: 18, - color: Theme.of(context).colorScheme.primary, + color: Theme.of(dialogContext).colorScheme.primary, ), const SizedBox(width: 8), Text( @@ -190,16 +122,21 @@ class RepeaterIdChip extends StatelessWidget { fontSize: 15, fontWeight: FontWeight.w600, fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + color: Theme.of(dialogContext).colorScheme.onSurface, ), ), ], ), const SizedBox(height: 12), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(dialogContext).dividerColor), const SizedBox(height: 12), - // Content - content, + // Content (lazy-fetches repeaters if cache is empty) + _RepeaterPopupContent( + repeaterId: repeaterId, + fullHexId: fullHexId, + fromLatLng: fromLatLng, + ), ], ), ), @@ -324,6 +261,23 @@ class RepeaterIdChip extends StatelessWidget { ); } + /// Caption-style italic message used inside the popup (e.g. "Unknown + /// repeater", "Repeater data not available"). Lifted out of the static + /// builder so `_RepeaterPopupContent` can reuse it. + static Widget _buildCaptionMessage(BuildContext context, String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + text, + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ); + } + /// Build a hex ID badge — circle for 2-char, pill for longer IDs static Widget _buildHexBadge(String displayId, Color color) { final isLong = displayId.length > 2; @@ -350,3 +304,179 @@ class RepeaterIdChip extends StatelessWidget { ); } } + +/// Stateful popup body that lazily re-fetches the repeater list if the cache +/// is empty when the user opens the dialog (e.g. offline session, startup +/// race, or a transient fetch failure). See +/// `AppStateProvider.refetchRepeatersIfPossible` for the trigger. +class _RepeaterPopupContent extends StatefulWidget { + final String repeaterId; + final String? fullHexId; + final ({double lat, double lon})? fromLatLng; + + const _RepeaterPopupContent({ + required this.repeaterId, + this.fullHexId, + this.fromLatLng, + }); + + @override + State<_RepeaterPopupContent> createState() => _RepeaterPopupContentState(); +} + +class _RepeaterPopupContentState extends State<_RepeaterPopupContent> { + bool _loading = false; + bool _hasTriedRefetch = false; + + @override + void initState() { + super.initState(); + final appState = Provider.of(context, listen: false); + if (appState.repeaters.isEmpty && _hasIataAvailable(appState)) { + _kickOffRefetch(appState); + } + } + + bool _hasIataAvailable(AppStateProvider appState) { + if (appState.zoneCode?.isNotEmpty == true) return true; + final prefIata = appState.preferences.iataCode; + return prefIata != null && prefIata.isNotEmpty; + } + + void _kickOffRefetch(AppStateProvider appState) { + final iata = (appState.zoneCode?.isNotEmpty == true) + ? appState.zoneCode + : appState.preferences.iataCode; + debugLog( + '[MAP] Repeater popup opened with empty cache, refetching (iata=$iata)'); + setState(() { + _loading = true; + _hasTriedRefetch = true; + }); + appState.refetchRepeatersIfPossible().whenComplete(() { + if (!mounted) return; + setState(() => _loading = false); + }); + } + + @override + Widget build(BuildContext context) { + // Watch so the dialog rebuilds automatically when the fetch populates + // _repeaters (refetchRepeatersIfPossible calls notifyListeners on success). + final appState = context.watch(); + final repeaters = appState.repeaters; + + if (repeaters.isNotEmpty) { + return _buildMatches(context, appState, repeaters); + } + + if (_loading) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text( + 'Loading repeaters…', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ); + } + + // Not loading and no data. If we tried and got nothing, offer retry. + // Otherwise we have no IATA at all — show the terminal "not available". + if (_hasTriedRefetch) { + return InkWell( + onTap: () { + final appState = + Provider.of(context, listen: false); + _kickOffRefetch(appState); + }, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.refresh, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + "Couldn't load repeaters — tap to retry", + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + ], + ), + ), + ); + } + + return RepeaterIdChip._buildCaptionMessage( + context, 'Repeater data not available'); + } + + Widget _buildMatches( + BuildContext context, AppStateProvider appState, List repeaters) { + // DISC pings provide the full public key — match first 8 hex chars + // (4 bytes) against repeater hexId for exact identification. TX/RX pings + // only have 1-byte IDs so fall back to prefix matching. + final matchKey = widget.fullHexId != null && widget.fullHexId!.length >= 8 + ? widget.fullHexId!.substring(0, 8) + : widget.repeaterId; + final matches = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .toList(); + + if (matches.isEmpty) { + return RepeaterIdChip._buildCaptionMessage(context, 'Unknown repeater'); + } + + final position = appState.currentPosition; + final refLat = widget.fromLatLng?.lat ?? position?.latitude; + final refLon = widget.fromLatLng?.lon ?? position?.longitude; + + if (refLat != null && refLon != null) { + matches.sort((a, b) { + final distA = GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); + return distA.compareTo(distB); + }); + } + + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + return Column( + mainAxisSize: MainAxisSize.min, + children: matches + .map((r) => RepeaterIdChip._buildRepeaterRow( + context, + r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride, + )) + .toList(), + ); + } +} From 582f17593925cabaab1fcb6e6ed32a59fede5890 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 14 Apr 2026 22:40:19 -0400 Subject: [PATCH 067/360] - Fixed the power level hint on the Map tab incorrectly directing users to Settings instead of the Connect tab. - Apple info.plist attempt 2 --- Build.sh | 1 + ios/Podfile.lock | 2 +- ios/Runner/Info.plist | 2 ++ lib/widgets/ping_controls.dart | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Build.sh b/Build.sh index 202b62c0..f6828162 100755 --- a/Build.sh +++ b/Build.sh @@ -153,6 +153,7 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." +(cd ios && pod install) flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" cp build/ios/ipa/mesh_mapper.ipa "$IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "✓ Built: MeshMapper-$FILE_TAG.ipa" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ebb426d3..21b95770 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -29,6 +29,6 @@ SPEC CHECKSUMS: flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 5f6d31cc7a922ccb43b951411657266fcae3377c COCOAPODS: 1.16.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bdea0622..251402f2 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -32,6 +32,8 @@ NSBluetoothAlwaysUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving + NSCameraUsageDescription + This app does not use the camera. This entry is required by a file picker library. NSBluetoothPeripheralUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index b20aee8c..f080ac02 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -79,7 +79,7 @@ class PingControls extends StatelessWidget { blockingIcon = Icons.settings_input_antenna; blockingColor = Colors.orange; } else if (!isPowerSet) { - blockingHint = 'Select power level in Settings'; + blockingHint = 'Select power level in Connect tab'; blockingIcon = Icons.bolt; blockingColor = Colors.orange; } else if (validation == PingValidation.noGpsLock) { From 190cb62ea0b716a40e434d1e5598873a2a3a40b6 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 12:09:38 -0700 Subject: [PATCH 068/360] add offline management --- android/app/build.gradle.kts | 2 +- lib/main.dart | 9 + lib/screens/offline_maps_screen.dart | 1116 +++++++++++++++++++++++++ lib/screens/settings_screen.dart | 18 +- lib/services/offline_map_service.dart | 534 ++++++++++++ lib/widgets/map_widget.dart | 35 +- 6 files changed, 1706 insertions(+), 8 deletions(-) create mode 100644 lib/screens/offline_maps_screen.dart create mode 100644 lib/services/offline_map_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f0529bac..d7c6a8db 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "net.meshmapper.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 // MapLibre GL requires 23+ + minSdk = flutter.minSdkVersion // MapLibre GL requires 23+ targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/lib/main.dart b/lib/main.dart index 08ef6efb..2745c9c8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; +import 'services/offline_map_service.dart'; import 'utils/debug_logger_io.dart'; void main() async { @@ -69,6 +70,11 @@ void main() async { await BackgroundServiceManager.cleanupOrphanedService(); } + // Clean up any stale offline map download notification + if (!kIsWeb) { + await OfflineMapService().cleanupOrphanedNotification(); + } + runApp(MeshMapperApp(initialThemeMode: initialThemeMode)); } @@ -215,6 +221,9 @@ class MeshMapperApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => AppStateProvider(bluetoothService: bluetoothService), ), + ChangeNotifierProvider( + create: (_) => OfflineMapService()..initialize(), + ), ], child: _ThemedApp(initialThemeMode: initialThemeMode), ); diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart new file mode 100644 index 00000000..ca4ac1e1 --- /dev/null +++ b/lib/screens/offline_maps_screen.dart @@ -0,0 +1,1116 @@ +import 'dart:math' show Point; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/offline_map_service.dart'; +import '../widgets/app_toast.dart'; + +/// Available map styles for offline download. +/// Satellite uses inline raster JSON which doesn't work well with the offline +/// region downloader, so we only offer the vector tile styles. +const _downloadStyles = { + 'Liberty': 'https://tiles.openfreemap.org/styles/liberty', + 'Dark': 'https://tiles.openfreemap.org/styles/dark', + 'Light': 'https://tiles.openfreemap.org/styles/bright', +}; + +/// Screen for managing offline map tile downloads. +/// +/// Accessible from the Settings screen. The underlying [OfflineMapService] +/// lives at the app level (via Provider), so downloads continue even after +/// navigating away from this screen. A system notification shows progress. +class OfflineMapsScreen extends StatefulWidget { + const OfflineMapsScreen({super.key}); + + @override + State createState() => _OfflineMapsScreenState(); +} + +class _OfflineMapsScreenState extends State { + @override + void initState() { + super.initState(); + // Listen for background download completions to show a toast. + final service = context.read(); + service.addListener(_onServiceUpdate); + } + + @override + void dispose() { + // Use try-catch in case the provider is already disposed during app teardown. + try { + context.read().removeListener(_onServiceUpdate); + } catch (_) {} + super.dispose(); + } + + void _onServiceUpdate() { + if (!mounted) return; + final service = context.read(); + final completed = service.consumeLastCompletedName(); + if (completed != null) { + AppToast.success(context, '"$completed" downloaded'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final service = context.watch(); + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Offline Maps', style: TextStyle(fontSize: 18)), + ), + body: !service.initialized + ? const Center(child: CircularProgressIndicator()) + : kIsWeb + ? _buildWebUnsupported(theme) + : ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _buildStorageCard(context, service, theme, isDark), + const SizedBox(height: 8), + _buildDownloadedRegionsCard( + context, service, theme, isDark), + const SizedBox(height: 8), + if (service.isDownloading) + _buildDownloadProgressCard( + context, service, theme, isDark), + ], + ), + floatingActionButton: + (service.initialized && !kIsWeb && !service.isDownloading) + ? FloatingActionButton.extended( + onPressed: () => _showDownloadDialog(context), + icon: const Icon(Icons.download), + label: const Text('Download Region'), + ) + : null, + ); + } + + Widget _buildWebUnsupported(ThemeData theme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Offline maps are not available on web', + style: theme.textTheme.titleMedium + ?.copyWith(color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Use the mobile app to download map regions for offline use', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage usage card + // ────────────────────────────────────────────── + + Widget _buildStorageCard(BuildContext context, OfflineMapService service, + ThemeData theme, bool isDark) { + final usageRatio = service.usageRatio; + final barColor = usageRatio > 0.9 + ? Colors.red + : usageRatio > 0.7 + ? Colors.orange + : theme.colorScheme.primary; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Storage', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: () => _showStorageLimitDialog(context, service), + icon: const Icon(Icons.tune, size: 16), + label: const Text('Limit'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Usage bar + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: usageRatio, + minHeight: 20, + backgroundColor: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.shade200, + color: barColor, + ), + ), + const SizedBox(height: 8), + + // Usage text + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${service.totalUsedDisplay} used', + style: theme.textTheme.bodySmall?.copyWith( + color: barColor, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${service.storageLimitDisplay} limit', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade500, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Downloaded regions list + // ────────────────────────────────────────────── + + Widget _buildDownloadedRegionsCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return 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, 8, 0), + child: Row( + children: [ + Text( + 'Downloaded Regions', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (service.regions.isNotEmpty) + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: () => service.refreshRegions(), + tooltip: 'Refresh', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + if (service.regions.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + children: [ + Icon(Icons.map_outlined, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'No offline regions downloaded', + style: TextStyle(color: Colors.grey.shade500), + ), + const SizedBox(height: 4), + Text( + 'Tap "Download Region" to save map tiles for offline use', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ) + else + ...service.regions.map( + (region) => _RegionTile( + region: region, + onDelete: () => _confirmDeleteRegion(context, service, region), + ), + ), + if (service.regions.length > 1) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: OutlinedButton.icon( + onPressed: () => _confirmDeleteAll(context, service), + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 0.5), + minimumSize: const Size.fromHeight(36), + visualDensity: VisualDensity.compact, + ), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + } + + // ────────────────────────────────────────────── + // Download progress card + // ────────────────────────────────────────────── + + Widget _buildDownloadProgressCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Downloading', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + service.downloadingRegionName ?? 'Region', + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: service.downloadProgress ?? 0, + minHeight: 8, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${((service.downloadProgress ?? 0) * 100).round()}%', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Download continues in the background if you leave this screen', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage limit dialog + // ────────────────────────────────────────────── + + Future _showStorageLimitDialog( + BuildContext context, OfflineMapService service) async { + int currentLimit = service.storageLimitMb; + + final result = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Storage Limit'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$currentLimit MB', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Slider( + value: currentLimit.toDouble(), + min: OfflineMapService.minStorageLimitMb.toDouble(), + max: OfflineMapService.maxStorageLimitMb.toDouble(), + divisions: (OfflineMapService.maxStorageLimitMb - + OfflineMapService.minStorageLimitMb) ~/ + 50, + label: '$currentLimit MB', + onChanged: (value) { + setDialogState(() => currentLimit = value.round()); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${OfflineMapService.minStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '${OfflineMapService.maxStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Currently using ${service.totalUsedDisplay}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, currentLimit), + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (result != null) { + await service.setStorageLimit(result); + if (mounted) { + AppToast.success(context, 'Storage limit set to $result MB'); + } + } + } + + // ────────────────────────────────────────────── + // Download new region dialog + // ────────────────────────────────────────────── + + Future _showDownloadDialog(BuildContext context) async { + final started = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const _DownloadRegionPage(), + ), + ); + + // Toast is handled by the _onServiceUpdate listener when the download + // completes (which may happen long after this page returns). + if (started == true && mounted) { + AppToast.simple( + context, 'Download started — check notifications for progress'); + } + } + + // ────────────────────────────────────────────── + // Delete confirmations + // ────────────────────────────────────────────── + + Future _confirmDeleteRegion(BuildContext context, + OfflineMapService service, OfflineMapRegion region) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Region?'), + content: Text( + 'Delete "${region.name}"? This will free approximately ' + '${region.sizeDisplay} of storage.\n\n' + 'Note: shared tiles used by other regions may not be freed immediately.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + final success = await service.deleteRegion(region.id); + if (mounted) { + if (success) { + AppToast.success(context, '"${region.name}" deleted'); + } else { + AppToast.error( + context, service.lastError ?? 'Failed to delete region'); + } + } + } + } + + Future _confirmDeleteAll( + BuildContext context, OfflineMapService service) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete All Regions?'), + content: Text( + 'Delete all ${service.regions.length} downloaded regions? ' + 'This will free approximately ${service.totalUsedDisplay}.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete All'), + ), + ], + ), + ); + + if (confirmed == true) { + await service.deleteAllRegions(); + if (mounted) { + AppToast.success(context, 'All regions deleted'); + } + } + } +} + +// ═══════════════════════════════════════════════ +// Region list tile +// ═══════════════════════════════════════════════ + +class _RegionTile extends StatelessWidget { + final OfflineMapRegion region; + final VoidCallback onDelete; + + const _RegionTile({required this.region, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _styleIcon(region.styleName), + title: Text( + region.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${region.styleName} · z${region.minZoom.round()}-${region.maxZoom.round()} · ${region.sizeDisplay}\n' + '${region.boundsDisplay}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + isThreeLine: true, + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + onPressed: onDelete, + tooltip: 'Delete', + ), + ); + } + + Widget _styleIcon(String styleName) { + switch (styleName.toLowerCase()) { + case 'dark': + return const Icon(Icons.dark_mode); + case 'light': + return const Icon(Icons.light_mode); + case 'satellite': + return const Icon(Icons.satellite_alt); + case 'liberty': + default: + return const Icon(Icons.map); + } + } +} + +// ═══════════════════════════════════════════════ +// Download region flow (full-page) +// ═══════════════════════════════════════════════ + +class _DownloadRegionPage extends StatefulWidget { + const _DownloadRegionPage(); + + @override + State<_DownloadRegionPage> createState() => _DownloadRegionPageState(); +} + +class _DownloadRegionPageState extends State<_DownloadRegionPage> { + final _nameController = TextEditingController(); + String _selectedStyle = 'Liberty'; + double _minZoom = 6; + double _maxZoom = 14; + bool _submitting = false; + String? _error; + + // Bounds selection via interactive map + MapLibreMapController? _mapController; + LatLng? _boundsNE; + LatLng? _boundsSW; + int _tapCount = 0; + Line? _boundsLine; + Fill? _boundsFill; + + // Existing region overlays + final List _existingFills = []; + final List _existingLines = []; + bool _showExisting = true; + + @override + void initState() { + super.initState(); + // grab user's current map style to start + final pref = context.read().preferences.mapStyle; + final mapped = pref.substring(0, 1).toUpperCase() + pref.substring(1); + if (_downloadStyles.containsKey(mapped)) { + _selectedStyle = mapped; + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + LatLngBounds? get _selectedBounds { + if (_boundsNE == null || _boundsSW == null) return null; + return LatLngBounds(southwest: _boundsSW!, northeast: _boundsNE!); + } + + bool get _canSubmit => + _nameController.text.trim().isNotEmpty && + _selectedBounds != null && + !_submitting; + + int get _estimatedTiles { + final bounds = _selectedBounds; + if (bounds == null) return 0; + return OfflineMapService.estimateTileCount(bounds, _minZoom, _maxZoom); + } + + String get _estimatedSize { + final bytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Download Region', style: TextStyle(fontSize: 18)), + ), + body: Column( + children: [ + // Map for bounds selection + Expanded( + flex: 3, + child: Stack( + children: [ + MapLibreMap( + styleString: _downloadStyles[_selectedStyle]!, + initialCameraPosition: const CameraPosition( + target: LatLng(49.28, -123.12), // Vancouver default + zoom: 10, + ), + onMapCreated: (controller) { + _mapController = controller; + }, + onStyleLoadedCallback: () { + if (_showExisting) _drawExistingRegions(); + }, + onMapClick: _onMapTap, + rotateGesturesEnabled: false, + tiltGesturesEnabled: false, + ), + // Bounds instruction overlay + Positioned( + top: 8, + left: 8, + right: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _selectedBounds != null + ? 'Region selected · ~$_estimatedTiles tiles · $_estimatedSize' + : _tapCount == 1 + ? 'Tap the opposite corner to complete the region' + : 'Tap two corners on the map to select a region', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + // Reset bounds button + if (_selectedBounds != null) + Positioned( + top: 8, + right: 8, + child: Material( + type: MaterialType.circle, + color: theme.colorScheme.primaryContainer, + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _resetBounds, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.restart_alt, + size: 20, + color: theme.colorScheme.onPrimaryContainer), + ), + ), + ), + ), + // Existing regions toggle + Positioned( + bottom: 8, + left: 8, + child: Material( + borderRadius: BorderRadius.circular(16), + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + elevation: 2, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _toggleExistingRegions, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showExisting + ? Icons.visibility + : Icons.visibility_off, + size: 14, + color: const Color(0xFFF59E0B), + ), + const SizedBox(width: 4), + Text( + '${context.read().regions.length} existing', + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: _showExisting + ? const Color(0xFFF59E0B) + : Colors.grey, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + + // Configuration panel + Expanded( + flex: 2, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Region name + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Region Name', + hintText: 'e.g. Downtown Vancouver', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + isDense: true, + prefixIcon: const Icon(Icons.label_outline, size: 20), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + + // Style selector + Row( + children: [ + const Text('Style: ', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Expanded( + child: SegmentedButton( + segments: _downloadStyles.keys + .map((s) => ButtonSegment( + value: s, + label: Text(s, + style: const TextStyle(fontSize: 12)), + )) + .toList(), + selected: {_selectedStyle}, + onSelectionChanged: (selected) { + setState(() => _selectedStyle = selected.first); + }, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Zoom range + Row( + children: [ + Text( + 'Zoom: ${_minZoom.round()} – ${_maxZoom.round()}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const Spacer(), + Text( + '~$_estimatedTiles tiles', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + RangeSlider( + values: RangeValues(_minZoom, _maxZoom), + min: 0, + max: 18, + divisions: 18, + labels: RangeLabels( + _minZoom.round().toString(), + _maxZoom.round().toString(), + ), + onChanged: (values) { + setState(() { + _minZoom = values.start; + _maxZoom = values.end; + }); + }, + ), + + if (_error != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3)), + ), + child: Text( + _error!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ], + + const SizedBox(height: 12), + + // Download button + FilledButton.icon( + onPressed: _canSubmit ? _startDownload : null, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download), + label: + Text(_submitting ? 'Starting...' : 'Download Region'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _onMapTap(Point point, LatLng coordinates) { + setState(() { + if (_tapCount == 0) { + _boundsSW = coordinates; + _boundsNE = null; + _tapCount = 1; + _clearBoundsOverlay(); + } else if (_tapCount == 1) { + // Ensure SW is actually southwest and NE is northeast + final lat1 = _boundsSW!.latitude; + final lng1 = _boundsSW!.longitude; + final lat2 = coordinates.latitude; + final lng2 = coordinates.longitude; + + _boundsSW = LatLng( + lat1 < lat2 ? lat1 : lat2, + lng1 < lng2 ? lng1 : lng2, + ); + _boundsNE = LatLng( + lat1 > lat2 ? lat1 : lat2, + lng1 > lng2 ? lng1 : lng2, + ); + _tapCount = 2; + _drawBoundsOverlay(); + } + }); + } + + void _resetBounds() { + _clearBoundsOverlay(); + setState(() { + _boundsSW = null; + _boundsNE = null; + _tapCount = 0; + }); + } + + /// Draw outlines for all previously downloaded regions so the user + /// can see existing coverage while selecting a new area. + Future _drawExistingRegions() async { + if (_mapController == null) return; + final service = context.read(); + for (final region in service.regions) { + try { + final sw = region.bounds.southwest; + final ne = region.bounds.northeast; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + final fill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#F59E0B', // amber-500 + fillOpacity: 0.10, + )); + final line = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#F59E0B', + lineWidth: 1.5, + lineOpacity: 0.6, + )); + _existingFills.add(fill); + _existingLines.add(line); + } catch (e) { + debugPrint( + '[OFFLINE_MAP] Failed to draw existing region ${region.name}: $e'); + } + } + } + + Future _clearExistingRegions() async { + if (_mapController == null) return; + for (final f in _existingFills) { + try { + await _mapController!.removeFill(f); + } catch (_) {} + } + for (final l in _existingLines) { + try { + await _mapController!.removeLine(l); + } catch (_) {} + } + _existingFills.clear(); + _existingLines.clear(); + } + + void _toggleExistingRegions() { + setState(() => _showExisting = !_showExisting); + if (_showExisting) { + _drawExistingRegions(); + } else { + _clearExistingRegions(); + } + } + + Future _drawBoundsOverlay() async { + if (_mapController == null || _boundsSW == null || _boundsNE == null) + return; + + final sw = _boundsSW!; + final ne = _boundsNE!; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + try { + _boundsFill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#4A90D9', + fillOpacity: 0.15, + )); + _boundsLine = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#4A90D9', + lineWidth: 2.0, + lineOpacity: 0.8, + )); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); + } + } + + Future _clearBoundsOverlay() async { + if (_mapController == null) return; + try { + if (_boundsFill != null) { + await _mapController!.removeFill(_boundsFill!); + _boundsFill = null; + } + if (_boundsLine != null) { + await _mapController!.removeLine(_boundsLine!); + _boundsLine = null; + } + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); + } + } + + Future _startDownload() async { + final bounds = _selectedBounds; + if (bounds == null) return; + + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + final service = context.read(); + final styleUrl = _downloadStyles[_selectedStyle]!; + + // Check storage limit + final estBytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (service.wouldExceedLimit(estBytes)) { + setState(() { + _error = 'This download would exceed your storage limit. ' + 'Free up space or increase the limit in storage settings.'; + }); + return; + } + + setState(() { + _submitting = true; + _error = null; + }); + + // Fire-and-forget: the service runs the download in the background + // and shows a system notification for progress. We just kick it off + // and return to the management screen. + service.downloadRegion( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: _selectedStyle, + minZoom: _minZoom, + maxZoom: _maxZoom, + ); + + // Give the service a tick to validate and start + await Future.delayed(const Duration(milliseconds: 100)); + + if (!mounted) return; + + if (service.lastError != null && !service.isDownloading) { + setState(() { + _submitting = false; + _error = service.lastError; + }); + } else { + // Download is queued — return to the management screen + Navigator.pop(context, true); + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4cfe78ed..bfd2832f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -29,6 +29,7 @@ import '../widgets/bug_report_dialog.dart'; import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; +import 'offline_maps_screen.dart'; /// Settings screen for user preferences and API configuration class SettingsScreen extends StatefulWidget { @@ -154,12 +155,27 @@ class _SettingsScreenState extends State { title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' - : 'Disabled to save mobile data'), + : 'Network tiles disabled · downloaded regions still visible'), value: !prefs.mapTilesEnabled, onChanged: (value) { appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.download_for_offline), + title: const Text('Offline Maps'), + subtitle: const Text('Download map tiles for offline use'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const OfflineMapsScreen(), + ), + ); + }, + ), if (prefs.mapTilesEnabled) ListTile( leading: const Icon(Icons.opacity), diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart new file mode 100644 index 00000000..56df1f9f --- /dev/null +++ b/lib/services/offline_map_service.dart @@ -0,0 +1,534 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Metadata keys stored with each offline region. +class _MetaKeys { + _MetaKeys._(); + static const name = 'name'; + static const styleName = 'styleName'; + static const createdAt = 'createdAt'; + + /// Estimated size in bytes (rough heuristic based on tile count). + static const estimatedBytes = 'estimatedBytes'; +} + +/// A user-friendly wrapper around a raw [OfflineRegion]. +class OfflineMapRegion { + final int id; + final String name; + final String styleName; + final LatLngBounds bounds; + final double minZoom; + final double maxZoom; + final DateTime createdAt; + final int estimatedBytes; + + const OfflineMapRegion({ + required this.id, + required this.name, + required this.styleName, + required this.bounds, + required this.minZoom, + required this.maxZoom, + required this.createdAt, + required this.estimatedBytes, + }); + + factory OfflineMapRegion.fromOfflineRegion(OfflineRegion region) { + final meta = region.metadata; + return OfflineMapRegion( + id: region.id, + name: (meta[_MetaKeys.name] as String?) ?? 'Region ${region.id}', + styleName: (meta[_MetaKeys.styleName] as String?) ?? 'Unknown', + bounds: region.definition.bounds, + minZoom: region.definition.minZoom, + maxZoom: region.definition.maxZoom, + createdAt: DateTime.tryParse( + (meta[_MetaKeys.createdAt] as String?) ?? '') ?? + DateTime.now(), + // Platform channel JSON round-trip can return int as num/double. + estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, + ); + } + + /// Human-readable size string. + String get sizeDisplay { + if (estimatedBytes < 1024) return '$estimatedBytes B'; + if (estimatedBytes < 1024 * 1024) { + return '${(estimatedBytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(estimatedBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// Short bounds description (e.g. "49.2°N, 123.1°W"). + String get boundsDisplay { + final sw = bounds.southwest; + final ne = bounds.northeast; + final latCenter = (sw.latitude + ne.latitude) / 2; + final lngCenter = (sw.longitude + ne.longitude) / 2; + final latDir = latCenter >= 0 ? 'N' : 'S'; + final lngDir = lngCenter >= 0 ? 'E' : 'W'; + return '${latCenter.abs().toStringAsFixed(2)}°$latDir, ' + '${lngCenter.abs().toStringAsFixed(2)}°$lngDir'; + } +} + +/// Manages offline map tile downloads, listing, deletion, and storage limits. +/// +/// Lives at the app level (provided via [ChangeNotifierProvider] in main.dart) +/// so downloads continue when the user navigates away from the Offline Maps +/// screen. A system notification shows real-time progress. +/// +/// Not available on web (maplibre_gl offline APIs are mobile-only). +class OfflineMapService extends ChangeNotifier { + static const _storageLimitKey = 'offline_map_storage_limit_mb'; + static const int defaultStorageLimitMb = 500; + static const int minStorageLimitMb = 50; + static const int maxStorageLimitMb = 5000; + + // ── Notification constants ── + static const String _notifChannelId = 'meshmapper_offline_maps'; + static const String _notifChannelName = 'Offline Map Downloads'; + static const int _progressNotifId = 889; + static const int _completeNotifId = 890; + + final FlutterLocalNotificationsPlugin _notifPlugin = + FlutterLocalNotificationsPlugin(); + bool _notifInitialized = false; + + // ── Region state ── + List _regions = []; + List get regions => List.unmodifiable(_regions); + + int _storageLimitMb = defaultStorageLimitMb; + int get storageLimitMb => _storageLimitMb; + int get storageLimitBytes => _storageLimitMb * 1024 * 1024; + + /// Total estimated bytes across all downloaded regions. + int get totalUsedBytes => + _regions.fold(0, (sum, r) => sum + r.estimatedBytes); + + double get usageRatio { + if (storageLimitBytes == 0) return 0; + return (totalUsedBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + String get totalUsedDisplay => _formatBytes(totalUsedBytes); + String get storageLimitDisplay => '$_storageLimitMb MB'; + + // ── Download state ── + + /// Currently active download progress (null if idle). + double? _downloadProgress; + double? get downloadProgress => _downloadProgress; + + String? _downloadingRegionName; + String? get downloadingRegionName => _downloadingRegionName; + + bool get isDownloading => _downloadProgress != null; + + String? _lastError; + String? get lastError => _lastError; + + /// Name of the most recently completed download (for one-shot UI toast). + /// Call [consumeLastCompletedName] to read and clear. + String? _lastCompletedName; + String? consumeLastCompletedName() { + final name = _lastCompletedName; + _lastCompletedName = null; + return name; + } + + bool _initialized = false; + bool get initialized => _initialized; + + // ── Initialization ── + + /// Initialize: create notification channel, load storage limit, refresh list. + /// Safe to call multiple times — subsequent calls are no-ops. + Future initialize() async { + if (kIsWeb) return; + if (_initialized) return; + try { + await _initNotifications(); + final prefs = await SharedPreferences.getInstance(); + _storageLimitMb = + prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; + await refreshRegions(); + _initialized = true; + notifyListeners(); + } catch (e) { + debugPrint('[OFFLINE_MAP] Init error: $e'); + _initialized = true; + notifyListeners(); + } + } + + /// Set up the Android notification channel for download progress. + Future _initNotifications() async { + if (_notifInitialized) return; + try { + const AndroidNotificationChannel channel = AndroidNotificationChannel( + _notifChannelId, + _notifChannelName, + description: 'Shows progress when downloading offline map tiles', + importance: Importance.low, // No sound/vibration + showBadge: false, + ); + + await _notifPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + + // Initialize the plugin (required before showing notifications). + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await _notifPlugin.initialize( + const InitializationSettings(android: androidInit, iOS: iosInit), + ); + _notifInitialized = true; + } catch (e) { + debugPrint('[OFFLINE_MAP] Notification init error: $e'); + } + } + + // ── Notifications ── + + Future _showProgressNotification(String regionName, int percent) async { + if (!_notifInitialized) return; + try { + final androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: percent, + ongoing: true, // Non-dismissable while downloading + autoCancel: false, + onlyAlertOnce: true, // Don't buzz on every update + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _progressNotifId, + 'Downloading "$regionName"', + '$percent% complete', + NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show progress notification: $e'); + } + } + + Future _showCompleteNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Complete', + '"$regionName" is ready for offline use', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show complete notification: $e'); + } + } + + Future _showErrorNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Failed', + 'Failed to download "$regionName"', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show error notification: $e'); + } + } + + Future _dismissProgressNotification() async { + try { + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to dismiss notification: $e'); + } + } + + // ── Region queries ── + + /// Refresh the list of downloaded regions from MapLibre native storage. + Future refreshRegions() async { + if (kIsWeb) return; + try { + final rawRegions = await getListOfRegions(); + final parsed = []; + for (final r in rawRegions) { + try { + parsed.add(OfflineMapRegion.fromOfflineRegion(r)); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); + } + } + _regions = parsed; + _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + notifyListeners(); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to list regions: $e'); + } + } + + // ── Storage limit ── + + /// Update the storage limit (in MB) and persist it. + Future setStorageLimit(int limitMb) async { + _storageLimitMb = limitMb.clamp(minStorageLimitMb, maxStorageLimitMb); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_storageLimitKey, _storageLimitMb); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to save storage limit: $e'); + } + notifyListeners(); + } + + // ── Tile estimation ── + + /// Estimate tile count for a region (rough heuristic). + /// Uses the standard 2^z tile count formula for each zoom level. + static int estimateTileCount( + LatLngBounds bounds, double minZoom, double maxZoom) { + int total = 0; + for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { + final tilesPerSide = 1 << z; // 2^z + final lonFraction = (bounds.northeast.longitude - + bounds.southwest.longitude) + .abs() / + 360.0; + final latFraction = + (bounds.northeast.latitude - bounds.southwest.latitude).abs() / + 180.0; + final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + total += xTiles * yTiles; + } + return total; + } + + /// Rough estimate of download size in bytes from tile count. + /// Vector tiles average ~15-25 KB each; raster tiles ~20-40 KB. + /// We use 20 KB as a middle estimate. + static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; + + /// Check if downloading a region of [estimatedBytes] would exceed the limit. + bool wouldExceedLimit(int estimatedBytes) => + (totalUsedBytes + estimatedBytes) > storageLimitBytes; + + // ── Download ── + + /// Download an offline region. + /// + /// The download runs in MapLibre's native layer, so it survives Flutter + /// screen navigation. This service (kept alive by the app-level Provider) + /// receives progress callbacks and forwards them to both [notifyListeners] + /// and a system notification. + /// + /// Returns the new [OfflineMapRegion] on success, null on failure. + Future downloadRegion({ + required String name, + required LatLngBounds bounds, + required String styleUrl, + required String styleName, + double minZoom = 0, + double maxZoom = 14, + }) async { + if (kIsWeb) return null; + if (isDownloading) { + _lastError = 'A download is already in progress'; + notifyListeners(); + return null; + } + + final tileCount = estimateTileCount(bounds, minZoom, maxZoom); + final estBytes = estimateSizeBytes(tileCount); + + if (wouldExceedLimit(estBytes)) { + _lastError = + 'Download would exceed storage limit (${_formatBytes(estBytes)} needed, ' + '${_formatBytes(storageLimitBytes - totalUsedBytes)} remaining)'; + notifyListeners(); + return null; + } + + _downloadProgress = 0; + _downloadingRegionName = name; + _lastError = null; + _lastCompletedName = null; + notifyListeners(); + _showProgressNotification(name, 0); + + try { + final definition = OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: styleUrl, + minZoom: minZoom, + maxZoom: maxZoom, + ); + + final metadata = { + _MetaKeys.name: name, + _MetaKeys.styleName: styleName, + _MetaKeys.createdAt: DateTime.now().toIso8601String(), + _MetaKeys.estimatedBytes: estBytes, + }; + + final region = await downloadOfflineRegion( + definition, + metadata: metadata, + onEvent: _onDownloadEvent, + ); + + // downloadOfflineRegion resolves once the native download is queued, + // not necessarily when it finishes. The _onDownloadEvent callback + // handles completion. But if progress is already null (Success fired + // synchronously), the download completed inline. + if (_downloadProgress != null) { + // Still in progress — the event callback will finalize. + return null; + } + + // Completed synchronously (small region / cached tiles) + _downloadProgress = null; + _downloadingRegionName = null; + _lastCompletedName = name; + await refreshRegions(); + _showCompleteNotification(name); + return _regions.firstWhere((r) => r.id == region.id, + orElse: () => OfflineMapRegion.fromOfflineRegion(region)); + } catch (e) { + _downloadProgress = null; + _downloadingRegionName = null; + _lastError = 'Download failed: $e'; + notifyListeners(); + _showErrorNotification(name); + return null; + } + } + + void _onDownloadEvent(DownloadRegionStatus status) { + if (status is Success) { + final name = _downloadingRegionName ?? 'Region'; + _downloadProgress = null; + _downloadingRegionName = null; + _lastCompletedName = name; + notifyListeners(); // Immediately clear progress state + _showCompleteNotification(name); + // Small delay lets the native DB commit before we query it. + Future.delayed(const Duration(milliseconds: 500), () { + refreshRegions(); + }); + } else if (status is InProgress) { + _downloadProgress = status.progress / 100.0; + notifyListeners(); + // Throttle notification updates to every 2% to avoid flooding + final percent = status.progress.round(); + if (percent % 2 == 0) { + _showProgressNotification( + _downloadingRegionName ?? 'Region', percent); + } + } else { + // Error status + final name = _downloadingRegionName ?? 'Region'; + _downloadProgress = null; + _downloadingRegionName = null; + _lastError = 'Download error occurred'; + _showErrorNotification(name); + notifyListeners(); + } + } + + // ── Deletion ── + + /// Delete a downloaded region by ID. + Future deleteRegion(int regionId) async { + if (kIsWeb) return false; + try { + await deleteOfflineRegion(regionId); + await refreshRegions(); + return true; + } catch (e) { + debugPrint('[OFFLINE_MAP] Delete failed: $e'); + _lastError = 'Failed to delete region: $e'; + notifyListeners(); + return false; + } + } + + /// Delete all downloaded regions. + Future deleteAllRegions() async { + if (kIsWeb) return; + final ids = _regions.map((r) => r.id).toList(); + for (final id in ids) { + try { + await deleteOfflineRegion(id); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to delete region $id: $e'); + } + } + await refreshRegions(); + } + + // ── Cleanup ── + + /// Cancel any stale progress notification from a previous session. + /// Called at app startup (mirrors BackgroundServiceManager.cleanupOrphanedService). + Future cleanupOrphanedNotification() async { + if (kIsWeb) return; + try { + await _initNotifications(); + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); + } + } + + static String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 848f432c..8496e198 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -383,6 +383,10 @@ class _MapWidgetState extends State { // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. bool _tileLoadFailed = false; + + /// Tracks the last-applied mapTilesEnabled value so we can detect changes + /// in _buildMap and call setOffline() without a full style reload. + bool? _lastMapTilesEnabled; Timer? _tileLoadTimeoutTimer; static const _tileLoadTimeoutSeconds = 8; @@ -1038,10 +1042,23 @@ class _MapWidgetState extends State { Widget _buildMap(AppStateProvider appState, LatLng center) { final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - // When mapTilesEnabled is false, use a blank style (just background) to save mobile data - final newStyleUrl = appState.preferences.mapTilesEnabled - ? mapStyle.styleUrl - : _blankStyleJson; + // Always use the real style so downloaded offline tiles can render from + // cache. Network access is controlled via setOffline() instead. + final newStyleUrl = mapStyle.styleUrl; + + // Detect mapTilesEnabled toggle changes and switch MapLibre between + // online (network tiles) and offline (cache-only) mode. This avoids + // a full style reload — the same style stays loaded but MapLibre stops + // or starts making network requests for tiles. + final tilesEnabled = appState.preferences.mapTilesEnabled; + if (_lastMapTilesEnabled != tilesEnabled && _isMapReady) { + _lastMapTilesEnabled = tilesEnabled; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setOffline(!tilesEnabled); + debugPrint('[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + }); + } // Style changes flow through MapLibreMap.styleString — the plugin's // didUpdateWidget detects the new value and fires a native setStyle. @@ -1412,8 +1429,14 @@ class _MapWidgetState extends State { // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, // we assume tiles are failing to load (network down, server error, etc.) // and surface a banner. Cleared as soon as onMapIdle fires. + // When tiles are disabled (cache-only mode), suppress the warning — cached + // tiles load instantly or not at all; a timeout would be misleading. _tileLoadTimeoutTimer?.cancel(); - if (appState.preferences.mapTilesEnabled) { + final tilesEnabled = appState.preferences.mapTilesEnabled; + _lastMapTilesEnabled = tilesEnabled; + // Ensure MapLibre offline mode matches the user's preference. + setOffline(!tilesEnabled); + if (tilesEnabled) { _tileLoadFailed = false; _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { if (mounted && !_tileLoadFailed) { @@ -1422,7 +1445,7 @@ class _MapWidgetState extends State { } }); } else { - // Blank style — never show the warning + // Cache-only mode — never show the tile-load warning _tileLoadFailed = false; } From 82ee9d75f8c6e48e25a7b2b54d6c391c7516bfb8 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 12:10:11 -0700 Subject: [PATCH 069/360] format to match dev --- bin/test_message.dart | 42 +- lib/main.dart | 49 +- lib/models/api_queue_item.dart | 21 +- lib/models/connection_state.dart | 32 +- lib/models/device_model.dart | 15 +- lib/models/log_entry.dart | 74 +- lib/models/noise_floor_session.dart | 10 +- lib/models/ping_data.dart | 15 +- lib/models/user_preferences.dart | 42 +- lib/providers/app_state_provider.dart | 1029 ++++++++----- lib/screens/connection_screen.dart | 278 ++-- lib/screens/graph_screen.dart | 18 +- lib/screens/home_screen.dart | 139 +- lib/screens/log_screen.dart | 370 +++-- lib/screens/main_scaffold.dart | 16 +- lib/screens/settings_screen.dart | 517 ++++--- lib/services/api_queue_service.dart | 99 +- lib/services/api_service.dart | 252 +-- lib/services/audio_service.dart | 18 +- lib/services/background_service.dart | 6 +- lib/services/bluetooth/mobile_bluetooth.dart | 89 +- lib/services/bluetooth/web_bluetooth.dart | 35 +- lib/services/countdown_timer_service.dart | 12 +- lib/services/custom_api_service.dart | 21 +- lib/services/debug_file_logger.dart | 4 +- lib/services/debug_submit_service.dart | 159 +- lib/services/device_model_service.dart | 13 +- lib/services/gps_service.dart | 70 +- lib/services/gps_simulator_service.dart | 85 +- lib/services/meshcore/buffer_utils.dart | 7 +- lib/services/meshcore/channel_service.dart | 45 +- lib/services/meshcore/connection.dart | 204 ++- lib/services/meshcore/crypto_service.dart | 95 +- lib/services/meshcore/disc_tracker.dart | 58 +- lib/services/meshcore/packet_metadata.dart | 35 +- lib/services/meshcore/packet_parser.dart | 4 +- lib/services/meshcore/packet_validator.dart | 55 +- lib/services/meshcore/protocol_constants.dart | 22 +- lib/services/meshcore/rx_logger.dart | 113 +- lib/services/meshcore/trace_tracker.dart | 32 +- lib/services/meshcore/tx_tracker.dart | 144 +- lib/services/meshcore/unified_rx_handler.dart | 19 +- lib/services/offline_map_service.dart | 22 +- lib/services/offline_session_service.dart | 49 +- .../permission_disclosure_service.dart | 14 +- lib/services/ping_service.dart | 178 ++- lib/utils/debug_logger.dart | 44 +- lib/utils/debug_logger_io.dart | 4 +- lib/utils/debug_logger_stub.dart | 28 +- lib/utils/ping_colors.dart | 88 +- lib/widgets/bug_report_dialog.dart | 533 ++++--- lib/widgets/connection_panel.dart | 30 +- lib/widgets/map_widget.dart | 1351 +++++++++++------ lib/widgets/noise_floor_chart.dart | 173 ++- lib/widgets/offline_mode_toggle.dart | 15 +- lib/widgets/ping_controls.dart | 885 +++++++---- lib/widgets/regional_config_card.dart | 52 +- lib/widgets/repeater_id_chip.dart | 54 +- lib/widgets/repeater_picker_sheet.dart | 24 +- lib/widgets/status_bar.dart | 104 +- lib/widgets/upload_logs_dialog.dart | 109 +- 61 files changed, 5311 insertions(+), 2809 deletions(-) diff --git a/bin/test_message.dart b/bin/test_message.dart index f111341e..48ab857b 100644 --- a/bin/test_message.dart +++ b/bin/test_message.dart @@ -107,8 +107,22 @@ class PayloadType { class CryptoService { /// Fixed key for "Public" channel static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a hashtag channel name using SHA-256 @@ -228,8 +242,10 @@ class PacketMetadata { final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - final int payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; - final int protocolVersion = (header >> PacketHeader.verShift) & PacketHeader.verMask; + final int payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final int protocolVersion = + (header >> PacketHeader.verShift) & PacketHeader.verMask; // Calculate offset for Path Length based on route type int pathLengthOffset = 1; @@ -427,9 +443,12 @@ void main(List arguments) { // Print packet metadata print('PACKET METADATA'); - print(' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); - print(' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); - print(' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); print(' Protocol Version: ${metadata.protocolVersion}'); print(' Path Length: ${metadata.pathLength} bytes'); @@ -444,10 +463,12 @@ void main(List arguments) { print(' Path: (empty)'); } - print(' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); + print( + ' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); if (metadata.channelHash != null) { - print(' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); } print(''); @@ -514,7 +535,8 @@ void main(List arguments) { print(''); print(' Known channel hashes:'); for (final entry in channels.entries) { - print(' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); + print( + ' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); } printValidationResults(steps, false, 'Unknown channel hash'); return; diff --git a/lib/main.dart b/lib/main.dart index 2745c9c8..22d67ee0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -120,7 +120,8 @@ Future _requestPermissions() async { Future _requestiOSPermissions() async { // Note: Location permission is now requested AFTER showing the prominent disclosure // dialog in MainScaffold (required for Google Play compliance) - debugLog('[APP] iOS: Skipping location permission (handled after disclosure)'); + debugLog( + '[APP] iOS: Skipping location permission (handled after disclosure)'); // Trigger Core Bluetooth authorization by checking adapter state // This will cause iOS to show the Bluetooth permission prompt if not already granted @@ -138,7 +139,8 @@ Future _requestiOSPermissions() async { .where((state) => state == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 3), onTimeout: () { - debugLog('[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); + debugLog( + '[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); return fbp.BluetoothAdapterState.off; }); } @@ -171,36 +173,39 @@ Future _requestAndroidPermissions() async { // Dark theme - Tailwind Slate palette const darkColorScheme = ColorScheme.dark( - primary: Color(0xFF059669), // emerald-600 (main actions) + primary: Color(0xFF059669), // emerald-600 (main actions) onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 (TX ping) + secondary: Color(0xFF0284C7), // sky-600 (TX ping) onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) + tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) onTertiary: Colors.white, - surface: Color(0xFF1E293B), // slate-800 (cards/panels) - onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) - onSurfaceVariant: Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) + surface: Color(0xFF1E293B), // slate-800 (cards/panels) + onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) + onSurfaceVariant: + Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) surfaceContainerHighest: Color(0xFF0F172A), // slate-900 (main bg) - outline: Color(0xFF334155), // slate-700 (borders) - error: Color(0xFFF87171), // red-400 + outline: Color(0xFF334155), // slate-700 (borders) + error: Color(0xFFF87171), // red-400 onError: Colors.white, ); // Light theme - Tailwind Slate palette (inverted) // Note: Using darker grays for better text contrast const lightColorScheme = ColorScheme.light( - primary: Color(0xFF059669), // emerald-600 + primary: Color(0xFF059669), // emerald-600 onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 + secondary: Color(0xFF0284C7), // sky-600 onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 + tertiary: Color(0xFF4F46E5), // indigo-600 onTertiary: Colors.white, - surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) - onSurface: Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) - onSurfaceVariant: Color(0xFF475569), // slate-600 (muted text - darker for readability) + surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) + onSurface: + Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) + onSurfaceVariant: + Color(0xFF475569), // slate-600 (muted text - darker for readability) surfaceContainerHighest: Color(0xFFFFFFFF), // white (main bg) - outline: Color(0xFFCBD5E1), // slate-300 (borders) - error: Color(0xFFDC2626), // red-600 + outline: Color(0xFFCBD5E1), // slate-300 (borders) + error: Color(0xFFDC2626), // red-600 onError: Colors.white, ); @@ -212,9 +217,8 @@ class MeshMapperApp extends StatelessWidget { @override Widget build(BuildContext context) { // Create platform-appropriate Bluetooth service - final BluetoothService bluetoothService = kIsWeb - ? WebBluetoothService() - : MobileBluetoothService(); + final BluetoothService bluetoothService = + kIsWeb ? WebBluetoothService() : MobileBluetoothService(); return MultiProvider( providers: [ @@ -269,7 +273,8 @@ class _ThemedAppState extends State<_ThemedApp> { scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 appBarTheme: const AppBarTheme( backgroundColor: Color(0xFFF8FAFC), // slate-50 - foregroundColor: Color(0xFF0F172A), // slate-900 (darker for contrast) + foregroundColor: + Color(0xFF0F172A), // slate-900 (darker for contrast) ), cardTheme: CardThemeData( color: const Color(0xFFF8FAFC), // slate-50 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 0f58d317..3a197358 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -87,7 +87,8 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing + canUploadAfter: DateTime.now() + .millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, @@ -135,7 +136,8 @@ class ApiQueueItem extends HiveObject { double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" - final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; + final heardRepeats = + '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; return ApiQueueItem( type: 'DISC', latitude: latitude, @@ -163,7 +165,8 @@ class ApiQueueItem extends HiveObject { int? noiseFloor, double? power, }) { - final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + final heardRepeats = + '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( type: 'TRACE', latitude: latitude, @@ -249,7 +252,8 @@ class ApiQueueItem extends HiveObject { 'local_rssi': parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0, 'remote_snr': parts.length > 4 ? double.tryParse(parts[4]) ?? 0.0 : 0.0, 'public_key': parts.length > 5 ? parts[5] : '', - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': timestamp.millisecondsSinceEpoch ~/ + 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; @@ -261,7 +265,8 @@ class ApiQueueItem extends HiveObject { 'lon': longitude, 'noisefloor': noiseFloor, 'heard_repeats': heardRepeats, - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': + timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; @@ -281,7 +286,8 @@ class ApiQueueItem extends HiveObject { } /// Check if item is eligible for upload based on canUploadAfter - bool get isUploadEligible => DateTime.now().millisecondsSinceEpoch >= canUploadAfter; + bool get isUploadEligible => + DateTime.now().millisecondsSinceEpoch >= canUploadAfter; /// Mark as retried void markRetried() { @@ -291,5 +297,6 @@ class ApiQueueItem extends HiveObject { } @override - String toString() => 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; + String toString() => + 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; } diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index d8045983..e8072954 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -2,16 +2,16 @@ enum ConnectionStatus { /// Not connected to any device disconnected, - + /// Currently scanning for devices scanning, - + /// Connecting to device connecting, - + /// Connected and ready connected, - + /// Connection error occurred error, } @@ -27,31 +27,31 @@ enum ConnectionStep { /// Step 1: BLE GATT connect bleConnecting, - + /// Step 2: Protocol handshake protocolHandshake, - + /// Step 3: Device info query deviceQuery, - + /// Step 4: Device identification (match device model for display/reporting) powerConfiguration, - + /// Step 5: Time synchronization timeSync, - + /// Step 6: API slot acquisition slotAcquisition, - + /// Step 7: Channel setup (#wardriving) channelSetup, - + /// Step 8: GPS initialization gpsInit, - + /// Step 9: Fully connected and ready connected, - + /// Error state error, } @@ -60,13 +60,13 @@ enum ConnectionStep { enum GpsStatus { /// GPS permissions not granted permissionDenied, - + /// GPS is disabled on device disabled, - + /// Searching for GPS signal searching, - + /// GPS lock acquired locked, diff --git a/lib/models/device_model.dart b/lib/models/device_model.dart index 41ff907e..61505f98 100644 --- a/lib/models/device_model.dart +++ b/lib/models/device_model.dart @@ -1,24 +1,24 @@ /// Represents a MeshCore device model with its power configuration. -/// +/// /// This maps to the device-models.json database from the WebClient repo. /// Power configuration is critical for PA amplifier models to prevent hardware damage. class DeviceModel { /// Full manufacturer string reported by device (e.g., "Ikoka Stick-E22-30dBm (Xiao_nrf52)") final String manufacturer; - + /// Short display name (e.g., "Ikoka Stick") final String shortName; - + /// Power setting for wardrive.js (0.3, 0.6, 1.0, 2.0) /// CRITICAL: PA amplifier models require exact values final double power; - + /// Hardware platform (nrf52, esp32, esp32-s3, etc.) final String platform; - + /// Firmware TX power setting in dBm final int txPower; - + /// Additional notes about the device final String notes; @@ -55,7 +55,8 @@ class DeviceModel { } @override - String toString() => 'DeviceModel($shortName, power=$power, txPower=$txPower)'; + String toString() => + 'DeviceModel($shortName, power=$power, txPower=$txPower)'; } /// Container for the full device models database diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 26429beb..2fe84af8 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,11 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); + : events + .map((e) => e.snr != null + ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' + : '${e.repeaterId}(null)') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,7 +43,8 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ @@ -68,8 +73,10 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) - final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? + rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -190,7 +197,8 @@ class UnifiedPingLogEntry implements Comparable { final DateTime timestamp; final dynamic entry; - UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + UnifiedPingLogEntry( + {required this.type, required this.timestamp, required this.entry}); TxLogEntry get asTx => entry as TxLogEntry; RxLogEntry get asRx => entry as RxLogEntry; @@ -198,28 +206,29 @@ class UnifiedPingLogEntry implements Comparable { TraceLogEntry get asTrace => entry as TraceLogEntry; @override - int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + int compareTo(UnifiedPingLogEntry other) => + other.timestamp.compareTo(timestamp); String get timeString => switch (type) { - PingLogType.tx => asTx.timeString, - PingLogType.rx => asRx.timeString, - PingLogType.disc => asDisc.timeString, - PingLogType.trace => asTrace.timeString, - }; + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; String get locationString => switch (type) { - PingLogType.tx => asTx.locationString, - PingLogType.rx => asRx.locationString, - PingLogType.disc => asDisc.locationString, - PingLogType.trace => asTrace.locationString, - }; + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; String toCsv() => switch (type) { - PingLogType.tx => 'TX,${asTx.toCsv()}', - PingLogType.rx => 'RX,${asRx.toCsv()}', - PingLogType.disc => 'DISC,${asDisc.toCsv()}', - PingLogType.trace => 'TRC,${asTrace.toCsv()}', - }; + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; } /// User Error Entry for error log @@ -249,9 +258,9 @@ class UserErrorEntry { /// Error severity levels enum ErrorSeverity { - info, // Blue: informational messages + info, // Blue: informational messages warning, // Orange: warnings - error, // Red: errors + error, // Red: errors } /// Discovery Log Entry (discovery protocol observation) @@ -290,19 +299,24 @@ class DiscLogEntry { String toCsv() { final nodesStr = discoveredNodes.isEmpty ? 'None' - : discoveredNodes.map((n) => '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})').join(','); + : discoveredNodes + .map((n) => + '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,${noiseFloor ?? ''},${discoveredNodes.length},$nodesStr'; } } /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") - final String nodeType; // "REPEATER" or "ROOM" - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String? pubkeyHex; // Full public key hex (64 chars) for exact repeater matching + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final String nodeType; // "REPEATER" or "ROOM" + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String? + pubkeyHex; // Full public key hex (64 chars) for exact repeater matching DiscoveredNodeEntry({ required this.repeaterId, diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 6bd9fbf3..c2af3531 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -163,11 +163,11 @@ class NoiseFloorSession extends HiveObject { /// Display name for the mode String get modeDisplay => switch (mode) { - 'active' => 'Active Mode', - 'hybrid' => 'Hybrid Mode', - 'targeted' => 'Trace Mode', - _ => 'Passive Mode', - }; + 'active' => 'Active Mode', + 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', + _ => 'Passive Mode', + }; /// Formatted duration string (M:SS or H:MM:SS for long sessions) String get durationDisplay { diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 0e42d087..9d7e1051 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -7,7 +7,7 @@ part 'ping_data.g.dart'; enum PingType { @HiveField(0) tx, - + @HiveField(1) rx, } @@ -48,7 +48,8 @@ class TxPing { /// Note: power is stored in dBm but the message format uses watts /// The actual message is built in PingService with the correct watts value String toMessageFormat({double? powerWatts}) { - final coordsStr = '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + final coordsStr = + '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; final pw = powerWatts ?? 0.3; // Default to 0.3w if not provided return '@[MapperBot] $coordsStr [${pw.toStringAsFixed(1)}w]'; } @@ -70,19 +71,19 @@ class TxPing { class RxPing { @HiveField(0) final double latitude; - + @HiveField(1) final double longitude; - + @HiveField(2) final String repeaterId; - + @HiveField(3) final DateTime timestamp; - + @HiveField(4) final double snr; - + @HiveField(5) final int rssi; diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index dc16045c..84e4acc9 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -179,7 +179,8 @@ class UserPreferences { backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, mapStyle: (json['mapStyle'] as String?) ?? 'liberty', - closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, + closeAppAfterDisconnect: + (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, @@ -189,7 +190,8 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, - deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + deleteChannelOnDisconnect: + (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, @@ -197,13 +199,17 @@ class UserPreferences { gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, - coverageOverlayOpacity: (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, - disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, + coverageOverlayOpacity: + (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, + disconnectAlertEnabled: + (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, customApiUrl: json['customApiUrl'] as String?, customApiKey: json['customApiKey'] as String?, - customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, - customApiIncludeContact: (json['customApiIncludeContact'] as bool?) ?? true, + customApiDisclaimerAccepted: + (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: + (json['customApiIncludeContact'] as bool?) ?? true, ); } @@ -313,10 +319,12 @@ class UserPreferences { powerLevelSet: powerLevelSet ?? this.powerLevelSet, offlineMode: offlineMode ?? this.offlineMode, iataCode: iataCode ?? this.iataCode, - backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + backgroundModeEnabled: + backgroundModeEnabled ?? this.backgroundModeEnabled, developerModeEnabled: developerModeEnabled ?? this.developerModeEnabled, mapStyle: mapStyle ?? this.mapStyle, - closeAppAfterDisconnect: closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, + closeAppAfterDisconnect: + closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, themeMode: themeMode ?? this.themeMode, unitSystem: unitSystem ?? this.unitSystem, hybridModeEnabled: hybridModeEnabled ?? this.hybridModeEnabled, @@ -326,21 +334,27 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, - deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, - minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + deleteChannelOnDisconnect: + deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: + minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, - coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, - disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, + coverageOverlayOpacity: + coverageOverlayOpacity ?? this.coverageOverlayOpacity, + disconnectAlertEnabled: + disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, customApiUrl: customApiUrl ?? this.customApiUrl, customApiKey: customApiKey ?? this.customApiKey, - customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, - customApiIncludeContact: customApiIncludeContact ?? this.customApiIncludeContact, + customApiDisclaimerAccepted: + customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: + customApiIncludeContact ?? this.customApiIncludeContact, ); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7cb837c2..9c14b969 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -30,7 +31,8 @@ import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; -import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; +import '../services/meshcore/packet_validator.dart' + show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; @@ -46,10 +48,13 @@ import '../utils/debug_logger_io.dart'; enum AutoMode { /// Active Mode: Sends pings on movement, listens for RX responses active, + /// Passive Mode: Listening only (no transmit) passive, + /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater targeted, } @@ -61,16 +66,22 @@ enum OverlayPingType { tx, disc, trace, rx } enum OfflineUploadResult { /// Upload completed successfully success, + /// Session file not found notFound, + /// Session data is invalid or empty invalidSession, + /// API authentication failed authFailed, + /// Some pings failed to upload partialFailure, + /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available gpsRequired, } @@ -90,11 +101,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DeviceModelService _deviceModelService; late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); - late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + late final CooldownTimer + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + late final ManualPingCooldownTimer + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) late final AutoPingTimer _autoPingTimer; late final RxWindowTimer _rxWindowTimer; - late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final DiscoveryWindowTimer + _discoveryWindowTimer; // Discovery listening window (Passive Mode) MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -111,8 +125,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; - bool _isAuthError = false; // Track if connection failed due to auth - bool _isNetworkError = false; // Track if connection failed due to network + bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; @@ -125,8 +139,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { GpsStatus _gpsStatus = GpsStatus.permissionDenied; Position? _currentPosition; ({double lat, double lon})? _lastKnownPosition; - DateTime? _lastPositionSaveTime; // Throttle position saves to every 30 seconds - bool _firstGpsLockLogged = false; // Track if we've logged first GPS lock message + DateTime? + _lastPositionSaveTime; // Throttle position saves to every 30 seconds + bool _firstGpsLockLogged = + false; // Track if we've logged first GPS lock message // Device info DeviceModel? _deviceModel; @@ -144,7 +160,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The device name to display (prefers SelfInfo name over BLE advertisement name) /// SelfInfo name reflects user's chosen name in MeshCore; BLE name may be cached/stale - String? get displayDeviceName => _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + String? get displayDeviceName => + _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); // Ping state PingStats _pingStats = const PingStats(); @@ -177,7 +194,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _traceLogEntries = []; // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _topRepeatersOverlay = []; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -191,8 +209,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { UserPreferences _preferences = const UserPreferences(); // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -228,11 +246,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isCheckingZone = false; // Zone check retry state - String? _zoneCheckError; // Error message from last failed check (null = no error) - String? _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' - int _zoneCheckRetryCountdown = 0; // Seconds until next retry (0 = not counting) - Timer? _zoneCheckRetryTimer; // Fires to trigger the retry - Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown + String? + _zoneCheckError; // Error message from last failed check (null = no error) + String? + _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' + int _zoneCheckRetryCountdown = + 0; // Seconds until next retry (0 = not counting) + Timer? _zoneCheckRetryTimer; // Fires to trigger the retry + Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown // Maintenance mode state bool _maintenanceMode = false; @@ -274,9 +295,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry bool _isInZoneGracePeriod = false; - Timer? _zoneGraceTimer; // 5-minute overall timeout - Timer? _zoneGracePollingTimer; // 5-second zone polling - Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick int _zoneGraceSecondsRemaining = 0; bool _autoPingWasEnabledBeforeGrace = false; AutoMode _autoModeBeforeGrace = AutoMode.active; @@ -311,10 +332,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _scope; // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) - int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + int? + _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = + false; // True if user manually changed hopBytes while connected + int _hopBytes = + 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = + 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; @@ -359,7 +384,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; - bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; + bool get isBluetoothOff => + _bluetoothAdapterState == BluetoothAdapterState.off; GpsStatus get gpsStatus => _gpsStatus; Position? get currentPosition => _currentPosition; ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; @@ -371,14 +397,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; - bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) - bool get isDiscoveryListening => _pingService?.isDiscoveryListening ?? false; // True during discovery listening window (for Passive Mode) + bool get isPingInProgress => + _pingService?.pingInProgress ?? + false; // True during entire ping + RX window (for auto pings) + bool get isDiscoveryListening => + _pingService?.isDiscoveryListening ?? + false; // True during discovery listening window (for Passive Mode) /// Check if auto-ping disable is pending (waiting for RX window) bool get isPendingDisable => _pingService?.pendingDisable ?? false; + /// True when running any mode that does TX (Active or Hybrid) - bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + bool get isTxModeRunning => + _autoPingEnabled && + (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) - bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + bool get isTargetedModeRunning => + _autoPingEnabled && _autoMode == AutoMode.targeted; String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; @@ -389,13 +424,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get rxPings => List.unmodifiable(_rxPings); /// Top 3 repeaters by best SNR from TX/DISC/Trace pings - List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + List<({String repeaterId, double snr, OverlayPingType type})> + get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. /// Replaces all 3 slots entirely (no carryover from previous pings). - void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + void _updateTopRepeaters( + List<({String repeaterId, double snr})> current, OverlayPingType type) { final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -419,7 +457,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { + _rxOverlayWindowTimer = + Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared }); } @@ -432,21 +471,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; } + List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get traceLogEntries => List.unmodifiable(_traceLogEntries); - List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get traceLogEntries => + List.unmodifiable(_traceLogEntries); + List get errorLogEntries => + List.unmodifiable(_errorLogEntries); List get unifiedPingLogEntries { final merged = [ - ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), - ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), - ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), - ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ..._txLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.trace, timestamp: e.timestamp, entry: e)), ]; merged.sort(); return merged; } + ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; @@ -476,7 +523,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; String? get nearestZoneCode => _nearestZone?['code'] as String?; - double? get nearestZoneDistanceKm => (_nearestZone?['distance_km'] as num?)?.toDouble(); + double? get nearestZoneDistanceKm => + (_nearestZone?['distance_km'] as num?)?.toDouble(); // Zone check retry getters String? get zoneCheckError => _zoneCheckError; @@ -546,11 +594,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; bool get enforceHybrid => _apiService.enforceHybrid; bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + bool get discDropEnabled => + _preferences.discDropEnabled || _apiService.enforceDiscDrop; int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get effectiveHopBytes => + enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; @@ -573,11 +623,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Countdown timers - CooldownTimer get cooldownTimer => _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + CooldownTimer get cooldownTimer => + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) AutoPingTimer get autoPingTimer => _autoPingTimer; RxWindowTimer get rxWindowTimer => _rxWindowTimer; - DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + DiscoveryWindowTimer get discoveryWindowTimer => + _discoveryWindowTimer; // Discovery listening window (Passive Mode) // ============================================ // Initialization @@ -596,11 +649,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize custom API forwarding service _customApiService = CustomApiService(prefsGetter: () => _preferences); _customApiService.onError = (message) { - logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Custom API: $message', + severity: ErrorSeverity.warning, autoSwitch: false); }; _customApiService.contactGetter = () { final pk = _devicePublicKey; - return (pk != null && pk.length >= 8) ? pk.substring(0, 8).toUpperCase() : null; + return (pk != null && pk.length >= 8) + ? pk.substring(0, 8).toUpperCase() + : null; }; _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; @@ -622,7 +678,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize countdown timers with notifyListeners callback for smooth UI updates _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = ManualPingCooldownTimer(onUpdate: notifyListeners); + _manualPingCooldownTimer = + ManualPingCooldownTimer(onUpdate: notifyListeners); _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); @@ -650,9 +707,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -666,7 +727,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); - debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); + debugLog( + '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); // Schedule overlay tile refresh after server has time to regenerate tiles. @@ -709,7 +771,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth adapter state changes (on/off) debugLog('[INIT] Setting up Bluetooth adapter state listener...'); - _adapterStateSubscription = _bluetoothService.adapterStateStream.listen((state) { + _adapterStateSubscription = + _bluetoothService.adapterStateStream.listen((state) { final previousState = _bluetoothAdapterState; _bluetoothAdapterState = state; @@ -725,7 +788,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); await _connectionSubscription?.cancel(); - _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { + _connectionSubscription = + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -735,7 +799,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isInZoneGracePeriod) { // BLE disconnected during zone grace period — abandon grace, full cleanup - debugLog('[CONN] BLE disconnect during zone grace period — full cleanup'); + debugLog( + '[CONN] BLE disconnect during zone grace period — full cleanup'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; @@ -743,14 +808,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingWasEnabledBeforeGrace = false; await _fullDisconnectCleanup(); } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { - debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); + debugLog( + '[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { // Normal disconnect (user-requested or no remembered device) await _fullDisconnectCleanup(); } else { // Disconnected during a reconnect attempt - _attemptReconnect handles retry - debugLog('[CONN] BLE disconnect during reconnect attempt - will retry'); + debugLog( + '[CONN] BLE disconnect during reconnect attempt - will retry'); } } notifyListeners(); @@ -769,23 +836,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Log when we transition to locked state (permission granted + GPS available) if (status == GpsStatus.locked) { - debugLog('[GPS] GPS lock acquired - zone check should trigger on first position'); + debugLog( + '[GPS] GPS lock acquired - zone check should trigger on first position'); } // Log when permission is denied or GPS disabled if (status == GpsStatus.permissionDenied) { - debugLog('[GPS] Location permission denied - zone checks will be blocked'); + debugLog( + '[GPS] Location permission denied - zone checks will be blocked'); } else if (status == GpsStatus.disabled) { - debugLog('[GPS] Location services disabled - zone checks will be blocked'); + debugLog( + '[GPS] Location services disabled - zone checks will be blocked'); } } notifyListeners(); }); - _gpsStatus = _gpsService.status; // Sync initial status + _gpsStatus = _gpsService.status; // Sync initial status debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); await _gpsPositionSubscription?.cancel(); - _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { + _gpsPositionSubscription = + _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -798,7 +869,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] First GPS lock, triggering zone check'); await checkZoneStatus(); _firstGpsLockLogged = true; - } else if (_inZone == null && _preferences.offlineMode && !_firstGpsLockLogged) { + } else if (_inZone == null && + _preferences.offlineMode && + !_firstGpsLockLogged) { debugLog('[GEOFENCE] First GPS lock skipped: offline mode enabled'); _firstGpsLockLogged = true; } @@ -806,14 +879,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check zone every 100m movement (while disconnected) // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled - if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { + if (!isConnected && + !_preferences.offlineMode && + _shouldRecheckZone(position)) { // Throttle log to once per 30s to avoid spam while driving final now = DateTime.now(); - if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_lastZoneCheckLogTime == null || + now.difference(_lastZoneCheckLogTime!) >= + const Duration(seconds: 30)) { if (_zoneCheckSuppressedCount > 0) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); } else { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); } _lastZoneCheckLogTime = now; _zoneCheckSuppressedCount = 0; @@ -857,15 +936,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'isCheckingZone=$_isCheckingZone, hasPosition=${_currentPosition != null}'); await _gpsService.startWatching(); - _gpsStatus = _gpsService.status; // Sync after restart + _gpsStatus = _gpsService.status; // Sync after restart debugLog('[GPS] GPS restarted, new status: $_gpsStatus'); - debugLog('[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}'); // If we now have a position and zone hasn't been checked, trigger check - if (_currentPosition != null && _inZone == null && !_preferences.offlineMode) { - debugLog('[GPS] Permission granted with existing position - triggering zone check'); + if (_currentPosition != null && + _inZone == null && + !_preferences.offlineMode) { + debugLog( + '[GPS] Permission granted with existing position - triggering zone check'); await checkZoneStatus(); } notifyListeners(); @@ -923,7 +1006,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!isEnabled) { debugLog('[SCAN] Bluetooth still disabled after retries'); - _connectionError = 'Bluetooth is disabled. Please enable Bluetooth and try again.'; + _connectionError = + 'Bluetooth is disabled. Please enable Bluetooth and try again.'; notifyListeners(); return; } @@ -938,21 +1022,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen for discovered devices using subscription so stopScan() can cancel DiscoveredDevice? selectedDevice; final completer = Completer(); - _activeScanSubscription = _bluetoothService.scanForDevices( + _activeScanSubscription = _bluetoothService + .scanForDevices( timeout: const Duration(seconds: 15), - ).listen( + ) + .listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { // Prefer remembered device name (from SelfInfo) over BLE cache var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + if (_rememberedDevice != null && + device.id == _rememberedDevice!.id && device.name != _rememberedDevice!.name) { enrichedDevice = DiscoveredDevice( id: device.id, name: _rememberedDevice!.name, rssi: device.rssi, ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + debugLog( + '[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); } _discoveredDevices.add(enrichedDevice); selectedDevice = enrichedDevice; @@ -1024,7 +1112,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final publicKey = _meshCoreConnection!.devicePublicKey; if (publicKey == null) { debugError('[APP] Cannot request auth: no public key'); - return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; } // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" @@ -1036,7 +1128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + debugLog( + '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1049,16 +1142,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name final deviceName = _isAnonymousRenamed ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection!.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot request auth: could not retrieve device name'); - return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); final result = await _apiService.requestAuth( reason: 'connect', @@ -1067,7 +1167,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1078,7 +1180,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); return { @@ -1115,12 +1218,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); return { 'success': false, 'reason': stage1Reason, @@ -1137,13 +1242,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); return { 'success': false, 'reason': 'registration_failed', - 'message': 'Companion not found in backend and failed to register via API' + 'message': + 'Companion not found in backend and failed to register via API' }; } @@ -1155,7 +1262,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1171,9 +1280,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); return { 'success': false, 'reason': serverReason, @@ -1208,10 +1319,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification @@ -1222,7 +1335,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { + if (deviceName != null && + deviceName.isNotEmpty && + _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } @@ -1240,7 +1355,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for noise floor updates - _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _currentNoiseFloor = noiseFloor; // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); @@ -1248,7 +1364,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for battery updates - _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { _currentBatteryPercent = batteryPercent; notifyListeners(); }); @@ -1261,16 +1378,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update preferences if device model was recognized (for display/API reporting) // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { final device = connectionResult.deviceModel!; _preferences = _preferences.copyWith( powerLevel: device.power, txPower: device.txPower, - autoPowerSet: true, // Indicates power was auto-detected from device model + autoPowerSet: + true, // Indicates power was auto-detected from device model powerLevelSet: false, // Clear stale manual flag from previous session ); notifyListeners(); - debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + debugLog( + '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } // Note: API session acquisition is now handled by the auth callback @@ -1287,7 +1407,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update unified RX handler's validator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -1301,7 +1422,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } @@ -1310,7 +1432,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Any other value (e.g., "ottawa") → derive TransportKey and set scope final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -1337,8 +1460,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); } // Configure multi-byte path hash mode on radio @@ -1363,7 +1488,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { shouldIgnoreRepeater: (String repeaterId) { final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); } return false; }, @@ -1377,13 +1503,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // External antenna must be explicitly set (yes or no) before pinging return _preferences.externalAntennaSet; }; - + _pingService!.checkPowerLevelConfigured = () { // Power is configured if: // - Auto-detected from device model, OR // - Manually selected by user, OR // - Device model is known (has default power) - return _preferences.autoPowerSet || _preferences.powerLevelSet || _deviceModel != null; + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; }; // Get external antenna value for API payloads @@ -1450,9 +1578,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1465,14 +1597,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events if (_txLogEntries.isNotEmpty) { final lastEntry = _txLogEntries.last; // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); if (timeDiff <= 10) { // Build updated events list final existingEvents = List.from(lastEntry.events); @@ -1489,7 +1623,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _audioService.playReceiveSound(); } else { // Update existing event's SNR - final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); if (idx >= 0) { existingEvents[idx] = newEvent; } @@ -1504,19 +1639,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: existingEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); // Update top repeaters overlay with current TX echoes - _updateTopRepeaters(existingEvents - .where((e) => e.snr != null) - .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), OverlayPingType.tx); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); } else { - debugLog('[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } } else { debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); @@ -1533,7 +1673,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Track idle time for auto-stop if (skipReason != null) { // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { final elapsed = DateTime.now().difference(_idleAutoStopReference!); if (elapsed >= _autoStopIdleTimeout) { _triggerIdleAutoStop(); @@ -1552,15 +1693,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up real-time disc node discovery callback (like onEchoReceived) _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog('[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); if (isNew) { _audioService.playReceiveSound(); } // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters(discPing.discoveredNodes - .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), OverlayPingType.disc); + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); notifyListeners(); }; @@ -1577,11 +1722,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTx.latitude; lon = lastTx.longitude; if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )).toList(); + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } } @@ -1606,12 +1753,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastDisc.latitude; lon = lastDisc.longitude; if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes.map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )).toList(); + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); } } @@ -1648,11 +1797,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTrace.latitude; lon = lastTrace.longitude; if (result != null && result.success) { - repeaters = [MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - )]; + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; // Update the log entry with success data _traceLogEntries[0] = TraceLogEntry( timestamp: lastTrace.timestamp, @@ -1681,7 +1832,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }; @@ -1735,20 +1887,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + final realName = _isAnonymousRenamed + ? (_originalDeviceName ?? selfInfoName) + : selfInfoName; if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName)); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } } } // Restore per-device antenna preference if previously saved // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( externalAntenna: savedAntenna, @@ -1756,12 +1914,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _antennaRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); notifyListeners(); } // Restore per-device power override if previously saved - if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { final saved = _devicePowerOverrides[resolvedName]!; _preferences = _preferences.copyWith( powerLevel: (saved['powerLevel'] as num).toDouble(), @@ -1771,7 +1931,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _powerRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); notifyListeners(); } @@ -1780,7 +1941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (txAllowed && rxAllowed) { debugLog('[CONN] Connected with full access (TX + RX allowed)'); } else if (rxAllowed) { - debugLog('[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { debugLog('[CONN] Connected with limited access'); } @@ -1818,7 +1980,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (validation != PingValidation.valid) { debugLog('[CONN] Ping validation after connect: $validation'); } - } catch (e) { debugError('[APP] Connection failed: $e'); @@ -1849,7 +2010,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (parts.length > 1) { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { @@ -1859,7 +2021,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAuthError = false; _isNetworkError = false; // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; } else { _connectionError = errorStr.replaceFirst('Exception: ', ''); @@ -1879,8 +2043,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); + _txTracker!.carpeaterPrefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog( + '[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1893,16 +2059,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + carpeaterPrefix: + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { - debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' + debugLog( + '[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging - debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); + debugLog( + '[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); // Check if repeater already has a pin in CURRENT BATCH (not all-time) // This allows new pins after batch flushes (25m movement) @@ -1924,7 +2093,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Increment RX count immediately when pin is created (not on batch flush) _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); - debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' + debugLog( + '[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); // Update RX overlay slot immediately @@ -1948,7 +2118,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); notifyListeners(); } else { - debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); + debugLog( + '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); } } catch (e, stackTrace) { debugError('[APP] Error in immediate observation callback: $e'); @@ -1961,7 +2132,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onRxEntry: (entry) async { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); - debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' + debugLog( + '[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1980,20 +2152,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + final shouldUpdateSnr = + entry.snr != null && entry.snr! > existingPin.snr; if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( - latitude: existingPin.latitude, // KEEP batch start location + latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + snr: entry.snr ?? + existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, ); - debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { - debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { @@ -2008,7 +2184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' + debugLog( + '[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -2080,7 +2257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { severity: ErrorSeverity.warning, autoSwitch: false); }, ); - + // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; @@ -2091,7 +2268,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); final validator = PacketValidator( allowedChannels: allowedChannels, @@ -2104,15 +2282,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxLogger: _rxLogger!, validator: validator, ); - + // Subscribe to LogRxData stream - _logRxDataSubscription = _meshCoreConnection!.logRxDataStream.listen((data) { + _logRxDataSubscription = + _meshCoreConnection!.logRxDataStream.listen((data) { _unifiedRxHandler!.handlePacket(data.raw, data.snr, data.rssi); }); - + // Start listening _unifiedRxHandler!.startListening(); - + debugLog('[APP] Unified RX handler created and listening'); } @@ -2134,14 +2313,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; _traceHopBytes = 1; } final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) final deviceHopBytes = deviceMode + 1; if (effective != deviceHopBytes && _originalPathHashMode != null) { @@ -2151,7 +2332,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = effective; // Update runtime state to reflect new mode _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2166,13 +2348,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else if (_originalPathHashMode == null && effective > 1) { // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + debugWarn( + '[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: effective, reason: 'firmware_unsupported'); notifyListeners(); } } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + debugLog( + '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); } } @@ -2182,7 +2367,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) return; if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + debugLog( + '[PATH] User manually changed path mode, not restoring on disconnect'); _originalPathHashMode = null; _userChangedPathMode = false; return; @@ -2195,12 +2381,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_hopBytes != originalHopBytes) { try { await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + debugLog( + '[PATH] Restored path hash mode to original: $originalHopBytes-byte'); } catch (e) { debugError('[PATH] Failed to restore path hash mode: $e'); } } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + debugLog( + '[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); } _originalPathHashMode = null; _userChangedPathMode = false; @@ -2211,7 +2399,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) { // Old firmware — can't send command, show warning debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: newHopBytes, reason: 'firmware_unsupported'); _hopBytes = 1; // Force back to 1 notifyListeners(); return; @@ -2230,7 +2419,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + debugLog( + '[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -2261,7 +2451,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => + _pendingPathHashWarning; /// Clear the pending warning after UI has shown it void clearPathHashWarning() { @@ -2406,7 +2597,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService = null; // Do NOT release API session or clear API queue - debugLog('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); + debugLog( + '[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); notifyListeners(); @@ -2423,40 +2615,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Attempt a single reconnection void _attemptReconnect() { if (_reconnectAttempt >= _maxReconnectAttempts) { - debugLog('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); + debugLog( + '[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); _abandonAutoReconnect(); return; } _reconnectAttempt++; - debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); + debugLog( + '[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); // Use longer delay after bond errors to give iOS time to clear stale keys - final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + final delay = _lastReconnectWasBondError + ? _reconnectDelayAfterBondError + : _reconnectDelay; // Delay before attempting reconnection _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { - debugLog('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); + debugLog( + '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { - debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + debugLog( + '[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again - debugLog('[CONN] Auto-reconnect: connection did not complete, retrying...'); + debugLog( + '[CONN] Auto-reconnect: connection did not complete, retrying...'); _connectionStep = ConnectionStep.reconnecting; notifyListeners(); _attemptReconnect(); } } catch (e) { - debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); + debugError( + '[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { // Check for iOS apple-code 14 (Peer removed pairing information) // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying @@ -2479,10 +2679,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { if (!isConnected || _autoPingEnabled) return; debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); - logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + logError('Disconnected: 15 minutes of inactivity', + severity: ErrorSeverity.warning); disconnect(); }); - debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + debugLog( + '[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); } /// Cancel the idle disconnect timer @@ -2497,11 +2699,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry Future _handleBondErrorIfNeeded(Object error) async { final errorStr = error.toString(); - if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + if (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')) { _lastReconnectWasBondError = true; final deviceId = _rememberedDevice?.id; if (deviceId != null) { - debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + debugLog( + '[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); await _bluetoothService.removeBond(deviceId); } } @@ -2523,7 +2728,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - debugLog('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); + debugLog( + '[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); // Restore auto-ping if it was active if (wasAutoPing) { @@ -2537,13 +2743,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + debugLog( + '[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { toggleAutoPing(previousMode); - debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); + debugLog( + '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); } else { @@ -2582,7 +2790,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); // Reset anonymous mode state (BLE already gone, can't restore name) @@ -2671,11 +2880,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed && _originalDeviceName != null) { try { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + debugLog( + '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); + logError( + 'Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, + autoSwitch: false); } _isAnonymousRenamed = false; _originalDeviceName = null; @@ -2709,7 +2921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); - + // Cancel stream subscriptions await _noiseFloorSubscription?.cancel(); _noiseFloorSubscription = null; @@ -2730,7 +2942,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _displayDeviceName = null; _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; @@ -2830,7 +3043,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { - debugWarn('[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); + debugWarn( + '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors return false; } @@ -2851,7 +3065,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); - logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Auto-ping stopped: no movement for 30 minutes', + severity: ErrorSeverity.warning, autoSwitch: false); _idleAutoStopReference = null; toggleAutoPing(_autoMode); } @@ -2919,7 +3134,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Passive Mode is listening only, no cooldown needed if (isTxMode) { _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2936,7 +3152,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Block starting if shared cooldown is active (TX modes only) // Passive Mode is listening only and can start during cooldown if (isTxMode && _cooldownTimer.isRunning) { - debugLog('[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); return false; } @@ -2967,7 +3184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Set interval from user preferences before starting final intervalMs = _preferences.autoPingInterval * 1000; _pingService!.setAutoPingInterval(intervalMs); - debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); final started = await _pingService!.enableAutoPing( passiveMode: isPassive, @@ -2978,7 +3196,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { - debugLog('[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); } else { debugLog('[PING] Auto mode start blocked'); } @@ -2991,7 +3210,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -3011,7 +3236,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -3050,7 +3281,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } - debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); + debugLog( + '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); notifyListeners(); } @@ -3060,14 +3292,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_traceLogEntries.length > _maxLogEntries) { _traceLogEntries.removeLast(); } - debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + debugLog( + '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); // Update top repeaters overlay with successful trace result if (entry.success && entry.localSnr != null) { // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace); } notifyListeners(); @@ -3075,13 +3309,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab - void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { + void logError(String message, + {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { _errorLogEntries.add(UserErrorEntry( timestamp: DateTime.now(), message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) _errorLogEntries.removeAt(0); + if (_errorLogEntries.length > _maxErrorEntries) + _errorLogEntries.removeAt(0); if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3129,9 +3365,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Hot-switch while connected - return enabled - ? await _switchToOfflineMode() - : await _switchToOnlineMode(); + return enabled ? await _switchToOfflineMode() : await _switchToOnlineMode(); } /// Simple offline mode change (when not connected) @@ -3158,7 +3392,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); + debugLog( + '[GEOFENCE] Re-checking zone status after offline mode disabled'); checkZoneStatus(); } } @@ -3257,13 +3492,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot switch to online mode: no device name available'); + debugError( + '[APP] Cannot switch to online mode: no device name available'); _modeSwitchError = 'Device name not available'; return (success: false, error: _modeSwitchError); } if (_devicePublicKey == null) { - debugError('[APP] Cannot switch to online mode: no public key available'); + debugError( + '[APP] Cannot switch to online mode: no public key available'); _modeSwitchError = 'Device public key not available'; return (success: false, error: _modeSwitchError); } @@ -3280,17 +3517,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (zoneCode == null) { debugError('[APP] Cannot switch to online mode: not in a zone'); - _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + _modeSwitchError = + 'Could not determine your zone. Check GPS and internet connection.'; return (success: false, error: _modeSwitchError); } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; var result = await _apiService.requestAuth( reason: 'connect', @@ -3310,10 +3550,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); - _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; + _modeSwitchError = + _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } @@ -3333,11 +3575,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } else { // Stage 1 failed — check if Stage 2 is worth attempting - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); _modeSwitchError = result['message'] as String? ?? 'GPS error'; return (success: false, error: _modeSwitchError); } @@ -3351,10 +3595,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); - _modeSwitchError = 'Companion not found in backend and failed to register via API'; + _modeSwitchError = + 'Companion not found in backend and failed to register via API'; return (success: false, error: _modeSwitchError); } @@ -3378,9 +3624,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); _modeSwitchError = serverMessage ?? 'Registration rejected by server'; return (success: false, error: _modeSwitchError); } @@ -3509,7 +3757,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Note: Connection already validates device name exists, so this should never be null final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, @@ -3525,14 +3774,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) + return; final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); _offlineSessionService.updateCurrentSession( pings, @@ -3582,7 +3833,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (success) { // Delete the session file on successful upload await _offlineSessionService.deleteSession(filename); - debugLog('[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); + debugLog( + '[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); } else { debugError('[API] Failed to upload offline session: $filename'); } @@ -3607,7 +3859,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }) async { // Concurrency guard — only one offline upload at a time if (_isUploadingOfflineSession) { - debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + debugWarn( + '[OFFLINE] Upload already in progress, rejecting concurrent request'); return OfflineUploadResult.uploadInProgress; } @@ -3615,7 +3868,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); try { - return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + return await _uploadOfflineSessionIsolated(filename, + onProgress: onProgress); } finally { _isUploadingOfflineSession = false; notifyListeners(); @@ -3662,13 +3916,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 3. Check GPS before auth — the server requires current coordinates for geo-auth if (_currentPosition == null) { - debugError('[OFFLINE] Upload requires GPS - location services not available'); + debugError( + '[OFFLINE] Upload requires GPS - location services not available'); return OfflineUploadResult.gpsRequired; } // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. - debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); + debugLog( + '[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3697,7 +3953,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stage 2: If unknown_device and we have a stored contactUri, attempt registration if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + debugLog( + '[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); final registerResult = await _apiService.requestAuth( reason: 'register', contactUri: session.contactUri, @@ -3719,7 +3976,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + debugLog( + '[OFFLINE] Stage 2 succeeded: device registered for offline upload'); effectiveAuth = registerResult; } else { debugError('[OFFLINE] Auth failed: $reason'); @@ -3734,7 +3992,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); + debugLog( + '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); @@ -3750,7 +4009,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + final result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); @@ -3779,7 +4039,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn( + '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -3803,7 +4064,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { - debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' + debugLog( + '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); _preferences = preferences; @@ -3813,26 +4075,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final deviceName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); - debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + debugLog( + '[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } // Persist power override per device name - if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + if (deviceName != null && + preferences.powerLevelSet && + !preferences.autoPowerSet) { _devicePowerOverrides[deviceName] = { 'powerLevel': preferences.powerLevel, 'txPower': preferences.txPower, }; _saveDevicePowerOverrides(); - debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + debugLog( + '[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); } else if (deviceName != null && preferences.autoPowerSet) { // User re-selected the auto-detected value — clear any saved override if (_devicePowerOverrides.remove(deviceName) != null) { _saveDevicePowerOverrides(); - debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + debugLog( + '[APP] Cleared power override for "$deviceName" (auto-detected selected)'); } } @@ -3843,7 +4111,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _syncCarpeaterPrefix(); // Propagate min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; notifyListeners(); @@ -3859,7 +4128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + if (_connectionStatus == ConnectionStatus.connected && + _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { _requestConnectionTabSwitch = true; @@ -3874,7 +4144,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Propagate carpeaterPrefix to live TxTracker and RxLogger void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + final prefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; if (_txTracker != null) { _txTracker!.carpeaterPrefix = prefix; debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); @@ -3927,7 +4198,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setCoverageOverlayOpacity(double opacity) { final clamped = opacity.clamp(0.3, 1.0); _preferences = _preferences.copyWith(coverageOverlayOpacity: clamped); - debugLog('[MAP] Coverage overlay opacity set to ${clamped.toStringAsFixed(2)}'); + debugLog( + '[MAP] Coverage overlay opacity set to ${clamped.toStringAsFixed(2)}'); notifyListeners(); _savePreferences(); } @@ -3944,7 +4216,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setColorVisionType(String type) { _preferences = _preferences.copyWith(colorVisionType: type); PingColors.setColorVisionType( - ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ColorVisionType.values.firstWhere((e) => e.name == type, + orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); notifyListeners(); @@ -4025,7 +4298,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) + return; debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } @@ -4109,13 +4383,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { - debugWarn('[API] Rate limited - continuing without disconnect: $userMessage'); + debugWarn( + '[API] Rate limited - continuing without disconnect: $userMessage'); return; } // Zone grace period: intercept outside_zone during active session if (reason == 'outside_zone' && _isInZoneGracePeriod) { - debugLog('[ZONE GRACE] outside_zone during grace period — already handling'); + debugLog( + '[ZONE GRACE] outside_zone during grace period — already handling'); return; } if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { @@ -4171,7 +4447,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, ); - debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + debugLog( + '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); } } catch (e) { debugError('[APP] Failed to preserve queue to offline storage: $e'); @@ -4185,7 +4462,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Handle maintenance mode while connected - end session and log error - Future _handleMaintenanceModeConnected(String message, String? url) async { + Future _handleMaintenanceModeConnected( + String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); // Alert if auto-ping was running (maintenance is not user-initiated) @@ -4194,7 +4472,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Log to error log (this sets _requestErrorLogSwitch = true) - logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); + logError('Maintenance Mode Enabled: $message', + severity: ErrorSeverity.warning); // Disconnect (ends session, cleans up) await disconnect(); @@ -4225,7 +4504,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start periodic polling to check if maintenance mode has ended void _startMaintenancePolling() { _maintenanceCheckTimer?.cancel(); - _maintenanceCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + _maintenanceCheckTimer = + Timer.periodic(const Duration(seconds: 30), (_) async { if (!_maintenanceMode) { _maintenanceCheckTimer?.cancel(); _maintenanceCheckTimer = null; @@ -4255,7 +4535,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Validate GPS position for API calls /// Returns (isValid, errorMessage, errorCode) tuple - ({bool isValid, String? errorMessage, String? errorCode}) _validateGps(Position? position) { + ({bool isValid, String? errorMessage, String? errorCode}) _validateGps( + Position? position) { if (position == null) { return ( isValid: false, @@ -4269,7 +4550,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ageSeconds > _maxGpsAgeSeconds) { return ( isValid: false, - errorMessage: 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', + errorMessage: + 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', errorCode: 'gps_stale', ); } @@ -4278,7 +4560,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (position.accuracy > _maxGpsAccuracyMeters) { return ( isValid: false, - errorMessage: 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', + errorMessage: + 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', errorCode: 'gps_inaccurate', ); } @@ -4317,7 +4600,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Schedule a zone check retry with countdown timer for UI feedback - void _scheduleZoneCheckRetry({required int seconds, required String error, required String reason}) { + void _scheduleZoneCheckRetry( + {required int seconds, required String error, required String reason}) { // Cancel any existing timers _zoneCheckRetryTimer?.cancel(); _zoneCheckCountdownTimer?.cancel(); @@ -4358,11 +4642,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Should be called on app launch and every 100m of GPS movement while disconnected Future checkZoneStatus() async { debugLog('[GEOFENCE] checkZoneStatus() called'); - debugLog('[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); if (_currentPosition == null) { - debugLog('[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); + debugLog( + '[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); return; } @@ -4372,18 +4658,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (_isCheckingZone) { - debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); + debugLog( + '[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; } - debugLog('[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); + debugLog( + '[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); _isCheckingZone = true; // Don't clear error or notify here — keep current error view visible during retry // to avoid a full-screen flash. Error is cleared in finally block on success, // or overwritten by _scheduleZoneCheckRetry on failure. try { - debugLog('[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); final result = await _apiService.checkZoneStatus( @@ -4393,7 +4682,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, ); - debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); + debugLog( + '[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { // Update position even on failure to prevent zone check flooding @@ -4416,7 +4706,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); // Start polling to detect when maintenance ends _startMaintenancePolling(); @@ -4433,29 +4724,36 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final success = result['success'] == true; if (!success) { final reason = result['reason'] as String?; - final message = result['message'] as String? ?? 'Zone status check failed'; - debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + final message = + result['message'] as String? ?? 'Zone status check failed'; + debugError( + '[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); // Schedule a retry so we don't depend solely on the GPS stream firing // again — on first launch the stream may stall on a low-accuracy fix // and the coverage tile overlay would never load. - _scheduleZoneCheckRetry(seconds: 10, error: message, reason: 'gps_inaccurate'); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_inaccurate'); } else if (reason == 'gps_stale') { logError('GPS Stale Error\n$message', autoSwitch: false); - _scheduleZoneCheckRetry(seconds: 10, error: message, reason: 'gps_stale'); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_stale'); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 30, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 30, error: errorMsg, reason: reason!); } else if (reason == 'bad_key' || reason == 'invalid_request') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 60, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 60, error: errorMsg, reason: reason!); } else { // Unknown server errors — use server message - _scheduleZoneCheckRetry(seconds: 15, error: message, reason: 'server_error'); + _scheduleZoneCheckRetry( + seconds: 15, error: message, reason: 'server_error'); } return; @@ -4487,14 +4785,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check + _fetchRepeatersForZone( + newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; - final distanceKm = (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; - debugWarn('[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + final distanceKm = + (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; + debugWarn( + '[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); // Clear repeaters when exiting zone _repeaters = []; @@ -4505,7 +4806,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check error: $e'); } finally { _isCheckingZone = false; - debugLog('[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); notifyListeners(); } @@ -4521,11 +4823,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth response includes slot data, use it directly (forward-compatible) if (authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = authResult['slots_available']; - debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + debugLog( + '[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); } if (authResult.containsKey('slots_max')) { _currentZone!['slots_max'] = authResult['slots_max']; - debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + debugLog( + '[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); } // Sync at_capacity with tx_allowed @@ -4535,7 +4839,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth says TX not allowed and server didn't provide slot data, set slots to 0 if (!authTxAllowed && !authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = 0; - debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + debugLog( + '[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); } // If auth says TX allowed and we have slot data but server didn't provide updated count, @@ -4593,8 +4898,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _startZoneGracePeriod() async { if (_isInZoneGracePeriod) return; _isInZoneGracePeriod = true; - debugLog('[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); - logError('Left wardriving zone. Searching for nearby zone...', severity: ErrorSeverity.warning, autoSwitch: false); + debugLog( + '[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', + severity: ErrorSeverity.warning, autoSwitch: false); // Save auto-ping state for restoration on zone re-entry _autoPingWasEnabledBeforeGrace = _autoPingEnabled; @@ -4676,18 +4983,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + debugLog( + '[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume if (_sessionZoneCode != null && reEnteredZoneCode.isNotEmpty && reEnteredZoneCode != _sessionZoneCode) { - debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + debugLog( + '[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + await _handleZoneTransfer( + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); return; } @@ -4708,8 +5018,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - debugLog('[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); - logError('Re-entered wardriving zone. Resuming...', severity: ErrorSeverity.info, autoSwitch: false); + debugLog( + '[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', + severity: ErrorSeverity.info, autoSwitch: false); // Re-enable heartbeat _apiService.enableHeartbeat( @@ -4735,7 +5047,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -4782,7 +5095,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle zone-to-zone transfer during active wardriving session. /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. - Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + Future _handleZoneTransfer( + String newZoneCode, String newZoneName) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -4845,7 +5159,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + if (_devicePublicKey == null || + deviceName == null || + _currentPosition == null) { debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); await disconnect(); return; @@ -4860,7 +5176,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4869,7 +5186,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 10. Check auth result if (result == null) { debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); - logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + logError('Zone transfer failed: unable to reach server', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4887,8 +5205,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (result['success'] != true) { final reason = result['reason'] as String? ?? 'unknown'; final message = result['message'] as String? ?? 'Auth failed'; - debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + debugError( + '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4911,7 +5231,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 13. Update PacketValidator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -4925,13 +5246,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + debugLog( + '[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); } // 14. Update flood scope from new auth response final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -4960,8 +5283,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); } // 16. Reconfigure path hash mode if new zone requires different hop bytes @@ -5000,7 +5325,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -5053,7 +5379,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); notifyListeners(); } else { - debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + debugWarn( + '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); @@ -5304,7 +5631,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load a route file (KML or GPX) bool loadSimulatorRoute(String content, {String? filename}) { - final success = _gpsService.simulator.loadRoute(content, filename: filename); + final success = + _gpsService.simulator.loadRoute(content, filename: filename); if (success) { _gpsSimulatorPattern = SimulatorPattern.route; // If simulator is running, it will automatically use the new route @@ -5363,7 +5691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Attempt to recover from Hive corruption - Future?> _attemptHiveRecovery(String boxName, Duration timeout) async { + Future?> _attemptHiveRecovery( + String boxName, Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -5377,7 +5706,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return box; } catch (e) { debugError('[HIVE] Recovery failed for "$boxName": $e'); - logError('Storage for "$boxName" unavailable - some settings may not persist'); + logError( + 'Storage for "$boxName" unavailable - some settings may not persist'); return null; } } @@ -5393,7 +5723,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('device'); if (json != null) { - _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); + _rememberedDevice = + RememberedDevice.fromJson(Map.from(json)); debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); notifyListeners(); } @@ -5478,13 +5809,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('preferences'); if (json != null) { - _preferences = UserPreferences.fromJson(Map.from(json)); - debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' + _preferences = + UserPreferences.fromJson(Map.from(json)); + debugLog( + '[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); // Apply saved min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; // Apply saved color vision type @@ -5528,7 +5862,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_antenna_preferences'); if (raw != null) { _deviceAntennaPreferences = Map.from(raw as Map); - debugLog('[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); + debugLog( + '[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device antenna preferences: $e'); @@ -5560,9 +5895,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_power_overrides'); if (raw != null) { _devicePowerOverrides = (raw as Map).map( - (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + (key, value) => + MapEntry(key.toString(), Map.from(value as Map)), ); - debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + debugLog( + '[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device power overrides: $e'); @@ -5591,10 +5928,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (box == null) return; try { - _lastConnectedDeviceName = box.get('last_connected_device_name') as String?; + _lastConnectedDeviceName = + box.get('last_connected_device_name') as String?; _lastConnectedPublicKey = box.get('last_connected_public_key') as String?; if (_lastConnectedDeviceName != null) { - debugLog('[APP] Loaded last connected device: $_lastConnectedDeviceName'); + debugLog( + '[APP] Loaded last connected device: $_lastConnectedDeviceName'); } } catch (e) { debugLog('[APP] Failed to load last connected device: $e'); @@ -5602,7 +5941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save last connected device info to Hive storage - Future _saveLastConnectedDevice(String deviceName, String publicKey) async { + Future _saveLastConnectedDevice( + String deviceName, String publicKey) async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; @@ -5693,34 +6033,45 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[HIVE] Opening typed box "$_noiseFloorSessionBoxName"...'); try { - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); return box; } on TimeoutException { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } catch (e) { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } } /// Attempt to recover from Hive corruption for noise floor box - Future?> _attemptNoiseFloorBoxRecovery(Duration timeout) async { + Future?> _attemptNoiseFloorBoxRecovery( + Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$_noiseFloorSessionBoxName"...'); await Hive.deleteBoxFromDisk(_noiseFloorSessionBoxName); debugLog('[HIVE] Retrying open...'); // Notify user that cleanup happened - logError('Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); - - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); + + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); return box; } catch (e) { debugError('[HIVE] Recovery failed for "$_noiseFloorSessionBoxName": $e'); - logError('Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); return null; } } @@ -5736,7 +6087,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _storedNoiseFloorSessions = _noiseFloorSessionBox!.values.toList() ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Newest first - debugLog('[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); + debugLog( + '[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); } catch (e) { debugError('[GRAPH] Failed to load noise floor sessions: $e'); _storedNoiseFloorSessions = []; @@ -5799,7 +6151,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_currentNoiseFloorSession == null) return; _currentNoiseFloorSession!.endTime = DateTime.now(); - debugLog('[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' + debugLog( + '[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' '${_currentNoiseFloorSession!.samples.length} samples, ' '${_currentNoiseFloorSession!.markers.length} markers'); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 2ebb0da3..5e6b9a79 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -24,7 +24,8 @@ class ConnectionScreen extends StatefulWidget { State createState() => _ConnectionScreenState(); } -class _ConnectionScreenState extends State with WidgetsBindingObserver { +class _ConnectionScreenState extends State + with WidgetsBindingObserver { @override void initState() { super.initState(); @@ -125,7 +126,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (pathWarning != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + _showPathHashWarning( + context, pathWarning.hopBytes, pathWarning.reason); appState.clearPathHashWarning(); }); } @@ -234,10 +236,12 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { + Widget _buildConnectionProgress( + BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -272,7 +276,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildZoneGraceView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final nearestName = appState.nearestZoneName; final nearestDistance = appState.nearestZoneDistanceKm; final hasNearestInfo = nearestName != null && nearestDistance != null; @@ -299,7 +304,10 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -322,14 +330,20 @@ class _ConnectionScreenState extends State with WidgetsBinding height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), const SizedBox(width: 8), Text( 'Searching for zone...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), ], @@ -346,8 +360,10 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildZoneTransferView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildZoneTransferView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final from = appState.zoneTransferFrom ?? '?'; final to = appState.zoneTransferTo ?? '?'; @@ -368,7 +384,10 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( '$from → $to', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), SizedBox(height: isLandscape ? 8 : 12), @@ -380,14 +399,20 @@ class _ConnectionScreenState extends State with WidgetsBinding height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), const SizedBox(width: 8), Text( 'Re-authenticating...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), ], @@ -404,8 +429,10 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildReconnectingView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; return SafeArea( @@ -425,14 +452,20 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Attempt ${appState.reconnectAttempt} of 3', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), const SizedBox(height: 4), Text( deviceName, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), SizedBox(height: isLandscape ? 16 : 24), @@ -459,7 +492,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (semverMatch != null) { version = semverMatch.group(1); } else { - final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + final nightlyMatch = + RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); if (nightlyMatch != null) { version = nightlyMatch.group(1); } @@ -468,7 +502,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (version == null) { final manufacturerString = appState.manufacturerString; if (manufacturerString != null) { - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final versionRegex = + RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); final match = versionRegex.firstMatch(manufacturerString); if (match != null) { version = match.group(1); @@ -476,12 +511,17 @@ class _ConnectionScreenState extends State with WidgetsBinding } } - final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; + final hardware = appState.deviceModel?.shortName ?? + appState.manufacturerString ?? + 'Unknown'; final platform = appState.deviceModel?.platform; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final prefs = appState.preferences; final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Compact device summary card final deviceSummaryCard = Card( @@ -494,7 +534,8 @@ class _ConnectionScreenState extends State with WidgetsBinding // Header: BT icon + name/status Row( children: [ - const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const Icon(Icons.bluetooth_connected, + color: Colors.green, size: 20), const SizedBox(width: 8), Expanded( child: Column( @@ -503,15 +544,15 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( deviceName, style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), overflow: TextOverflow.ellipsis, ), Text( 'Connected', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.green, - ), + color: Colors.green, + ), ), ], ), @@ -526,8 +567,10 @@ class _ConnectionScreenState extends State with WidgetsBinding runSpacing: 4, children: [ _buildDetailChip(context, Icons.memory, hardware), - if (version != null) _buildDetailChip(context, Icons.code, version), - if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + if (version != null) + _buildDetailChip(context, Icons.code, version), + if (platform != null) + _buildDetailChip(context, Icons.developer_board, platform), ], ), @@ -606,7 +649,9 @@ class _ConnectionScreenState extends State with WidgetsBinding return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: InkWell( - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showPowerLevelSelector(context, appState), borderRadius: BorderRadius.circular(4), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -622,10 +667,15 @@ class _ConnectionScreenState extends State with WidgetsBinding Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + Icon(Icons.bolt, + size: 16, + color: + isPowerSet ? Colors.amber.shade700 : Colors.orange), const SizedBox(width: 4), Text( - isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + isPowerSet + ? prefs.powerLevelDisplay + : 'Unknown - tap to set', style: TextStyle( fontWeight: FontWeight.w500, color: isPowerSet ? null : Colors.orange, @@ -633,7 +683,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ), if (prefs.autoPowerSet) ...[ const SizedBox(width: 4), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const Icon(Icons.auto_awesome, + size: 14, color: Colors.green), const SizedBox(width: 2), const Text( 'Auto', @@ -643,7 +694,9 @@ class _ConnectionScreenState extends State with WidgetsBinding fontWeight: FontWeight.bold, ), ), - ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + ] else if (prefs.powerLevelSet && + !prefs.autoPowerSet && + appState.deviceModel != null) ...[ const SizedBox(width: 4), const Icon(Icons.edit, size: 14, color: Colors.orange), const SizedBox(width: 2), @@ -658,7 +711,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ], if (!isAutoMode) ...[ const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + const Icon(Icons.chevron_right, + size: 16, color: Colors.grey), ], ], ), @@ -695,8 +749,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - - Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -841,17 +893,19 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.verified_user, color: Colors.blue, size: 20), + child: const Icon(Icons.verified_user, + color: Colors.blue, size: 20), ), const SizedBox(width: 12), Expanded( child: Text( 'Registration Methods', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -875,7 +929,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.green, title: 'Mesh', trustLevel: 'Most trusted', - description: 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', + description: + 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', isCurrentType: currentType == 'Mesh', ), const SizedBox(height: 12), @@ -885,7 +940,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.blue, title: 'API', trustLevel: 'Trusted', - description: 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', + description: + 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', isCurrentType: currentType == 'API', ), const SizedBox(height: 12), @@ -895,7 +951,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.orange, title: 'Manual', trustLevel: 'Basic', - description: 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', + description: + 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', isCurrentType: currentType == 'Manual', ), ], @@ -924,7 +981,9 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: isCurrentType ? color.withValues(alpha: 0.1) : null, borderRadius: BorderRadius.circular(8), - border: isCurrentType ? Border.all(color: color.withValues(alpha: 0.4)) : null, + border: isCurrentType + ? Border.all(color: color.withValues(alpha: 0.4)) + : null, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -949,13 +1008,15 @@ class _ConnectionScreenState extends State with WidgetsBinding trustLevel, style: TextStyle( fontSize: 11, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: + theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), if (isCurrentType) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(4), @@ -988,11 +1049,13 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { + void _showPowerLevelSelector( + BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; // Only show selection if power has been set (auto or manual) - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; + final isPowerSet = + prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; final currentPower = isPowerSet ? prefs.powerLevel : null; // Helper to handle power selection with confirmation for overrides @@ -1040,7 +1103,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, - powerLevelSet: true, // Mark as manually set + powerLevelSet: true, // Mark as manually set ), ); Navigator.pop(context); @@ -1061,8 +1124,10 @@ class _ConnectionScreenState extends State with WidgetsBinding padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), - border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange) + .withValues(alpha: 0.1), + border: Border.all( + color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -1097,7 +1162,8 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = deviceModel != null && power == deviceModel.power; + final isRecommended = + deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -1105,10 +1171,12 @@ class _ConnectionScreenState extends State with WidgetsBinding return RadioListTile( title: Row( children: [ - Flexible(child: Text(tempPrefs.powerLevelDisplayWithDbm)), + Flexible( + child: Text(tempPrefs.powerLevelDisplayWithDbm)), if (isRecommended) ...[ const SizedBox(width: 8), - const Icon(Icons.check_circle, size: 16, color: Colors.green), + const Icon(Icons.check_circle, + size: 16, color: Colors.green), ], ], ), @@ -1157,7 +1225,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); Navigator.pop(context); }, - child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + child: const Text('Reset to Auto', + style: TextStyle(color: Colors.green)), ), TextButton( onPressed: () => Navigator.pop(context), @@ -1169,7 +1238,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildError(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -1187,7 +1257,9 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( appState.isNetworkError ? 'Server Unreachable' - : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + : appState.isAuthError + ? 'Authentication Failed' + : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -1226,24 +1298,24 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.gps_off; locationText = '-'; locationColor = Colors.grey; - // Check maintenance mode + // Check maintenance mode } else if (appState.maintenanceMode) { locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; - // Network error: show wifi off indicator + // Network error: show wifi off indicator } else if (appState.zoneCheckErrorReason == 'network') { locationIcon = Icons.wifi_off; locationText = 'No Internet'; locationColor = Colors.red; - // GPS error: show GPS issue indicator + // GPS error: show GPS issue indicator } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || - appState.zoneCheckErrorReason == 'gps_stale') { + appState.zoneCheckErrorReason == 'gps_stale') { locationIcon = Icons.gps_off; locationText = 'GPS Unavailable'; locationColor = Colors.orange; - // Show "Checking Zone..." whenever a zone check is in progress - // This provides consistent UI feedback during both initial and re-checks + // Show "Checking Zone..." whenever a zone check is in progress + // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { locationIcon = Icons.location_searching; locationText = 'Checking Zone...'; @@ -1367,7 +1439,8 @@ class _ConnectionScreenState extends State with WidgetsBinding required String message, Widget? action, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Use Center with CustomScrollView for both vertical centering and scroll capability return Center( @@ -1392,7 +1465,10 @@ class _ConnectionScreenState extends State with WidgetsBinding message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), if (action != null) ...[ @@ -1408,7 +1484,8 @@ class _ConnectionScreenState extends State with WidgetsBinding Widget _buildDeviceList(BuildContext context, AppStateProvider appState) { // Offline mode bypasses both zone and maintenance checks - final canConnect = appState.offlineMode || (appState.inZone == true && !appState.maintenanceMode); + final canConnect = appState.offlineMode || + (appState.inZone == true && !appState.maintenanceMode); // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { @@ -1433,7 +1510,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ), const SizedBox(height: 8), Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + appState.maintenanceMessage ?? + 'Service is temporarily unavailable.', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, @@ -1447,13 +1525,15 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: const Icon(Icons.cloud_off), label: const Text('Enable Offline Mode'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), if (appState.maintenanceUrl != null) ...[ const SizedBox(height: 12), OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + onPressed: () => + _launchMaintenanceUrl(appState.maintenanceUrl!), icon: const Icon(Icons.open_in_new, size: 18), label: const Text('More Info'), ), @@ -1470,12 +1550,14 @@ class _ConnectionScreenState extends State with WidgetsBinding child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), const SizedBox(width: 8), Flexible( child: Text( 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], @@ -1507,8 +1589,10 @@ class _ConnectionScreenState extends State with WidgetsBinding String message = 'Your geo zone is not on-boarded into MeshMapper.'; if (nearestName != null && distKmValue != null) { - final zoneDisplay = nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; - final dist = formatKilometers(distKmValue, isImperial: appState.preferences.isImperial); + final zoneDisplay = + nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; + final dist = formatKilometers(distKmValue, + isImperial: appState.preferences.isImperial); message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } @@ -1578,7 +1662,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: const Icon(Icons.cloud_off), label: const Text('Enable Offline Mode'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), ), ), const SizedBox(height: 32), @@ -1587,17 +1672,20 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), const SizedBox(width: 8), Flexible( child: Text( 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], @@ -1619,13 +1707,15 @@ class _ConnectionScreenState extends State with WidgetsBinding title: appState.zoneCheckErrorReason == 'gps_inaccurate' ? 'GPS Accuracy Error' : 'GPS Stale Error', - message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + message: + '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', action: FilledButton.icon( onPressed: () => appState.checkZoneStatus(), icon: const Icon(Icons.refresh), label: const Text('Retry Zone Check'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ); @@ -1657,7 +1747,9 @@ class _ConnectionScreenState extends State with WidgetsBinding return Column( children: [ const LinearProgressIndicator(), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), + Expanded( + child: _buildDeviceListView(context, appState, + canConnect: canConnect)), ], ); } @@ -1666,7 +1758,8 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); + return _buildRememberedDeviceView(context, appState, remembered, + canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1679,7 +1772,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: Icons.gps_off, iconColor: Colors.red.withValues(alpha: 0.7), title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', + message: + 'Please enable Location Services to verify you\'re in an allowed zone.', action: isIOS ? null : ElevatedButton.icon( @@ -1697,7 +1791,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: Icons.location_off, iconColor: Colors.orange.withValues(alpha: 0.7), title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', + message: + 'Location access is needed to verify you\'re in an allowed zone.', action: ElevatedButton.icon( onPressed: () => _requestLocationPermission(appState), icon: const Icon(Icons.location_on), @@ -1742,7 +1837,8 @@ class _ConnectionScreenState extends State with WidgetsBinding RememberedDevice remembered, { bool canConnect = true, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SingleChildScrollView( child: Center( @@ -1772,7 +1868,9 @@ class _ConnectionScreenState extends State with WidgetsBinding ), SizedBox(height: isLandscape ? 12 : 24), ElevatedButton.icon( - onPressed: canConnect ? () => appState.reconnectToRememberedDevice() : null, + onPressed: canConnect + ? () => appState.reconnectToRememberedDevice() + : null, icon: const Icon(Icons.bluetooth_connected), label: Text(canConnect ? 'Reconnect' @@ -1819,7 +1917,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, {bool canConnect = true}) { + Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, + {bool canConnect = true}) { return ListView.builder( itemCount: appState.discoveredDevices.length, itemBuilder: (context, index) { @@ -1947,9 +2046,8 @@ class _DeviceListTile extends StatelessWidget { device.id, style: TextStyle(color: enabled ? null : Colors.grey), ), - trailing: device.rssi != null - ? _buildRssiChip(device.rssi!, enabled) - : null, + trailing: + device.rssi != null ? _buildRssiChip(device.rssi!, enabled) : null, enabled: enabled, onTap: onTap, ); diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 977ce8cf..623520de 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -20,7 +20,8 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: + const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) @@ -79,7 +80,8 @@ class GraphScreen extends StatelessWidget { _SessionListTile( session: currentSession, isActive: true, - onTap: () => _openFullScreenGraph(context, currentSession, isLive: true), + onTap: () => + _openFullScreenGraph(context, currentSession, isLive: true), ), if (sessions.isNotEmpty) const Divider(), ], @@ -94,10 +96,12 @@ class GraphScreen extends StatelessWidget { ); } - void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, {bool isLive = false}) { + void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, + {bool isLive = false}) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => _FullScreenGraphPage(session: session, isLive: isLive), + builder: (context) => + _FullScreenGraphPage(session: session, isLive: isLive), ), ); } @@ -107,7 +111,8 @@ class GraphScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Clear All Sessions?'), - content: const Text('This will delete all saved noise floor session graphs. The current active session will not be affected.'), + content: const Text( + 'This will delete all saved noise floor session graphs. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -149,7 +154,8 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = widget.session; if (widget.isLive) { _liveTimer = Timer.periodic(const Duration(seconds: 2), (_) { - final current = context.read().currentNoiseFloorSession; + final current = + context.read().currentNoiseFloorSession; if (current != null) { setState(() { _session = current; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e835e9d9..90c94030 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -68,11 +68,11 @@ class _HomeScreenState extends State { return _isControlsMinimized ? 60 : 320; } - @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // In landscape: no AppBar, everything on map overlays if (isLandscape) { @@ -148,7 +148,8 @@ class _HomeScreenState extends State { } /// Stats row for AppBar/floating status bar (matches StatusBar exactly) - Widget _buildAppBarStats(AppStateProvider appState, {bool withTapHandlers = false}) { + Widget _buildAppBarStats(AppStateProvider appState, + {bool withTapHandlers = false}) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -173,7 +174,8 @@ class _HomeScreenState extends State { Icons.radar, appState.pingStats.discCount, PingColors.discSuccess, - onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), // Trace count @@ -181,7 +183,8 @@ class _HomeScreenState extends State { Icons.route, appState.pingStats.traceCount, PingColors.traceSuccess, - onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), // Upload count @@ -189,14 +192,16 @@ class _HomeScreenState extends State { Icons.cloud_done, appState.pingStats.successfulUploads, Colors.teal.shade400, - onTap: withTapHandlers ? () => _showInfoPopup('upload', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('upload', appState) : null, ), ], ); } /// Stat chip for AppBar (same style as StatusBar) - Widget _buildAppBarStatChip(IconData icon, int value, Color color, {VoidCallback? onTap}) { + Widget _buildAppBarStatChip(IconData icon, int value, Color color, + {VoidCallback? onTap}) { final chip = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -239,7 +244,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -300,51 +306,102 @@ class _HomeScreenState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); + return ( + 'Discovery Requests', + 'Discovery request packets we have sent out.', + Icons.radar, + PingColors.discSuccess + ); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -576,7 +633,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.help_outline, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.help_outline, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -588,7 +646,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.close, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.close, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -676,13 +735,14 @@ class _HomeScreenState extends State { children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), - Text(text, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)), + Text(text, + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: color)), ], ), ); } - /// Reconnecting overlay shown centered over the map during auto-reconnect Widget _buildReconnectingOverlay(AppStateProvider appState) { final deviceName = appState.rememberedDevice?.displayName ?? 'device'; @@ -932,7 +992,8 @@ class _HomeScreenState extends State { children: [ // Header with help and minimize buttons ListTile( - title: const Text('Controls', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text('Controls', + style: TextStyle(fontWeight: FontWeight.bold)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1007,7 +1068,8 @@ class _HomeScreenState extends State { /// Show help bottom sheet explaining each control void _showControlsHelp(BuildContext context) { - final prefs = Provider.of(context, listen: false).preferences; + final prefs = + Provider.of(context, listen: false).preferences; showModalBottomSheet( context: context, useSafeArea: true, @@ -1061,7 +1123,8 @@ class _HomeScreenState extends State { icon: Icons.settings_input_antenna, color: Colors.orange, title: 'External Antenna', - description: 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', + description: + 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', ), // Send Ping button @@ -1069,12 +1132,15 @@ class _HomeScreenState extends State { icon: Icons.cell_tower, color: const Color(0xFF0EA5E9), title: 'Send Ping', - description: 'Send a single ping to #wardriving and track which repeaters heard it.', + description: + 'Send a single ping to #wardriving and track which repeaters heard it.', ), // Active Mode / Hybrid Mode button _buildHelpItem( - icon: prefs.hybridModeEnabled ? Icons.compare_arrows : Icons.sensors, + icon: prefs.hybridModeEnabled + ? Icons.compare_arrows + : Icons.sensors, color: const Color(0xFF6366F1), title: prefs.hybridModeEnabled ? 'Hybrid Mode' : 'Active Mode', description: prefs.hybridModeEnabled @@ -1087,7 +1153,8 @@ class _HomeScreenState extends State { icon: Icons.hearing, color: const Color(0xFF6366F1), title: 'Passive Mode', - description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', + description: + 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), // Trace Mode @@ -1095,7 +1162,8 @@ class _HomeScreenState extends State { icon: Icons.gps_fixed, color: Colors.cyan, title: 'Trace Mode', - description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', + description: + 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), @@ -1217,4 +1285,3 @@ class _HomeScreenState extends State { return Colors.red; } } - diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 565f3920..d95172c3 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -16,7 +16,8 @@ class LogScreen extends StatefulWidget { State createState() => _LogScreenState(); } -class _LogScreenState extends State with SingleTickerProviderStateMixin { +class _LogScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; final _allPingsKey = GlobalKey<_AllPingsTabState>(); @@ -68,7 +69,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix }, itemBuilder: (context) => [ const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), - const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + const PopupMenuItem( + value: 'clear', child: Text('Clear all logs')), ], ), ], @@ -80,8 +82,12 @@ class _LogScreenState extends State with SingleTickerProviderStateMix dividerHeight: 1, labelPadding: EdgeInsets.zero, tabs: [ - Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), - Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + Tab( + height: 32, + text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab( + height: 32, + text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), ], ), ), @@ -120,7 +126,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix final filtered = tabState._filteredEntries; if (filtered.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No matching entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -131,7 +139,10 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), + SnackBar( + content: + Text('${filtered.length} filtered entries copied to clipboard'), + duration: const Duration(seconds: 2)), ); return; } @@ -143,7 +154,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No ping log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -161,7 +174,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + buffer.writeln( + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -170,7 +184,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (disc.isNotEmpty) { buffer.writeln('--- DISC Log ---'); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + buffer + .writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); for (final entry in disc) { buffer.writeln(entry.toCsv()); } @@ -179,7 +194,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (trace.isNotEmpty) { buffer.writeln('--- TRC Log ---'); - buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + buffer.writeln( + 'timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); for (final entry in trace) { buffer.writeln(entry.toCsv()); } @@ -187,14 +203,18 @@ class _LogScreenState extends State with SingleTickerProviderStateMix Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('All ping logs copied to clipboard'), + duration: Duration(seconds: 2)), ); } void _copyErrorLogToCsv(BuildContext context, List entries) { if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No error log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No error log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -206,7 +226,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Error log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Error log copied to clipboard'), + duration: Duration(seconds: 2)), ); } @@ -215,7 +237,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), + content: + const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -299,7 +322,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Resolve a short repeater ID to known repeater names via prefix matching. static ({List names, bool ambiguous}) _resolveRepeaterNames( - String repeaterId, List repeaters, + String repeaterId, + List repeaters, ) { final idLower = repeaterId.toLowerCase(); final matches = repeaters @@ -330,7 +354,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final event in tx.events) { if (event.repeaterId.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) + return true; } return false; case PingLogType.rx: @@ -342,31 +367,37 @@ class _AllPingsTabState extends State<_AllPingsTab> { final disc = entry.asDisc; for (final node in disc.discoveredNodes) { if (node.repeaterId.toLowerCase().startsWith(query)) return true; - if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && + node.pubkeyHex!.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) + return true; } return false; case PingLogType.trace: final trace = entry.asTrace; if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + final resolved = + _resolveRepeaterNames(trace.targetRepeaterId, repeaters); return resolved.names.any((n) => n.toLowerCase().contains(query)); } } /// Whether an entry should show the ambiguity indicator. /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. - bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + bool _shouldShowAmbiguity( + UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; switch (entry.type) { case PingLogType.tx: - return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + return entry.asTx.events + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); case PingLogType.rx: return _isAmbiguousId(entry.asRx.repeaterId, repeaters); case PingLogType.disc: - return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + return entry.asDisc.discoveredNodes + .any((n) => _isAmbiguousId(n.repeaterId, repeaters)); case PingLogType.trace: return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); } @@ -412,11 +443,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { contentPadding: const EdgeInsets.symmetric(vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), ), onChanged: (value) => setState(() => _searchQuery = value.trim()), @@ -429,19 +468,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), clipBehavior: Clip.antiAlias, child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, + PingColors.txSuccess, + isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), + _buildFilterSegment( + PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), + _buildFilterSegment(PingLogType.disc, 'DISC', + widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', + widget.traceCount, PingColors.traceSuccess, + isLast: true), ], ), ), @@ -464,7 +513,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { hasEntries && _searchQuery.isNotEmpty ? 'No results for \'$_searchQuery\'' : 'No pings logged yet', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -474,12 +525,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { itemCount: filtered.length, itemBuilder: (context, index) { final unified = filtered[index]; - final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + final showAmbiguity = + _shouldShowAmbiguity(unified, widget.repeaters); return switch (unified.type) { - PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), - PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), - PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), - PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + PingLogType.tx => _buildTxCard(context, unified.asTx, + showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, + showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard( + context, unified.asDisc, + showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard( + context, unified.asTrace, + showAmbiguity: showAmbiguity), }; }, ), @@ -488,7 +546,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + Widget _buildFilterSegment( + PingLogType type, String label, int count, Color color, + {bool isFirst = false, bool isLast = false}) { final active = _activeFilters.contains(type); return Expanded( child: GestureDetector( @@ -504,16 +564,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { style: TextStyle( fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), ), ), if (count > 0) ...[ const SizedBox(width: 4), Container( - constraints: const BoxConstraints(minWidth: 18, minHeight: 16), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + constraints: + const BoxConstraints(minWidth: 18, minHeight: 16), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, @@ -600,19 +672,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- - Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.tx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), @@ -640,7 +716,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ @@ -670,9 +748,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null ? '${event.rssi}' : '-', + rssiColor))), ], ), ), @@ -683,7 +769,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // RX Card // --------------------------------------------------------------------------- - Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildRxCard(BuildContext context, RxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); @@ -692,43 +779,71 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.rx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), const SizedBox(height: 10), // Repeater table (single row) Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'SNR', center: true)), - Expanded(child: _tableHeader(context, 'RSSI', center: true)), + SizedBox( + width: 60, child: _tableHeader(context, 'Node')), + Expanded( + child: + _tableHeader(context, 'SNR', center: true)), + Expanded( + child: + _tableHeader(context, 'RSSI', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.repeaterId), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: entry.repeaterId, + fontSize: 14, + width: 60), + Expanded( + child: Center( + child: _buildChip( + entry.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.rssi != null + ? '${entry.rssi}' + : '-', + rssiColor))), ], ), ), @@ -747,44 +862,63 @@ class _AllPingsTabState extends State<_AllPingsTab> { // DISC Card // --------------------------------------------------------------------------- - Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.disc, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes + .map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -812,7 +946,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, + fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( @@ -821,7 +956,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { width: 70, child: Row( children: [ - Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), + Flexible( + child: RepeaterIdChip( + repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: TextStyle( @@ -833,9 +970,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), - Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), - Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), - Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), + Expanded( + child: Center( + child: _buildChip( + node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded( + child: + Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded( + child: Center( + child: _buildChip( + node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), @@ -846,7 +991,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Trace Card // --------------------------------------------------------------------------- - Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, + {bool showAmbiguity = false}) { final colorScheme = Theme.of(context).colorScheme; final appState = context.read(); @@ -854,13 +1000,16 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.trace, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Results table if (entry.success) ...[ const SizedBox(height: 10), @@ -868,18 +1017,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), @@ -915,10 +1074,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), - Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), - Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), - Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + SizedBox( + width: 70, + child: RepeaterIdChip( + repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded( + child: Center( + child: _buildChip( + entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.localRssi != null ? '${entry.localRssi}' : '-', + rssiColor))), + Expanded( + child: Center( + child: _buildChip( + entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), ], ), ); @@ -928,7 +1100,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Shared helpers // --------------------------------------------------------------------------- - static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + static Widget _buildCardHeader(BuildContext context, PingLogType type, + String timeString, String locationString, + {bool showAmbiguity = false}) { return Row( children: [ _buildTypeBadge(type), @@ -936,7 +1110,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { const SizedBox(width: 2), Tooltip( message: 'Repeater ID matches multiple nodes', - child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + child: Icon(Icons.help_outline, + size: 14, color: Colors.amber.shade700), ), ], const SizedBox(width: 6), @@ -950,7 +1125,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), const Spacer(), - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 2), Text( locationString, @@ -964,7 +1140,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + static Widget _tableHeader(BuildContext context, String text, + {bool center = false}) { return Text( text, textAlign: center ? TextAlign.center : TextAlign.left, @@ -1014,9 +1191,12 @@ class _ErrorLogTab extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle_outline, size: 48, color: Colors.green), + const Icon(Icons.check_circle_outline, + size: 48, color: Colors.green), const SizedBox(height: 16), - Text('No errors logged', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text('No errors logged', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant)), ], ), ); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 87c2c435..a3cdfef1 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -53,7 +53,8 @@ class _MainScaffoldState extends State { if (kIsWeb) { // Web: No disclosure dialog needed, just request permission // This triggers the browser's native location permission prompt - debugLog('[DISCLOSURE] Web platform - requesting GPS permission directly'); + debugLog( + '[DISCLOSURE] Web platform - requesting GPS permission directly'); await _requestWebGpsPermission(); return; } @@ -109,7 +110,7 @@ class _MainScaffoldState extends State { return; } granted = permission == LocationPermission.always || - permission == LocationPermission.whileInUse; + permission == LocationPermission.whileInUse; } else { // Android: only request if needed so previously granted permission just restarts GPS. var status = await Permission.locationWhenInUse.status; @@ -187,7 +188,8 @@ class _MainScaffoldState extends State { }); } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( body: IndexedStack( @@ -233,8 +235,12 @@ class _MainScaffoldState extends State { index: 2, ), _buildCompactNavItem( - icon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, - activeIcon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, + icon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, + activeIcon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, index: 3, color: appState.isConnected ? Colors.green : null, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bfd2832f..f0981368 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'dart:io' show File; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -44,13 +45,15 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + Future _showUploadLogsDialog( + BuildContext context, AppStateProvider appState) async { final result = await showUploadLogsDialog(context, appState); if (!context.mounted || result == null) return; if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + String message = + 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; if (result.failedCount > 0) { message += ' (${result.failedCount} failed)'; } @@ -116,11 +119,13 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), child: const Row( children: [ @@ -142,23 +147,25 @@ class _SettingsScreenState extends State { prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, ), title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + subtitle: + Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + if (!kIsWeb) _BackgroundModeToggle(appState: appState), SwitchListTile( - secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + secondary: + Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' : 'Network tiles disabled · downloaded regions still visible'), value: !prefs.mapTilesEnabled, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), if (!kIsWeb) @@ -186,9 +193,11 @@ class _SettingsScreenState extends State { max: 1.0, divisions: 7, label: '${(prefs.coverageOverlayOpacity * 100).round()}%', - onChanged: (value) => appState.setCoverageOverlayOpacity(value), + onChanged: (value) => + appState.setCoverageOverlayOpacity(value), ), - trailing: Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), + trailing: + Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), ), ListTile( leading: const Icon(Icons.visibility), @@ -202,7 +211,8 @@ class _SettingsScreenState extends State { prefs.isImperial ? Icons.square_foot : Icons.straighten, ), title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); @@ -211,10 +221,12 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.cell_tower), title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), value: prefs.showTopRepeaters, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), ListTile( @@ -232,9 +244,11 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), value: appState.isSoundEnabled, onChanged: (_) => appState.toggleSoundEnabled(), ), @@ -249,14 +263,16 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Response Received'), - subtitle: const Text('Sound when repeater echo or RX is received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), value: appState.isRxSoundEnabled, onChanged: (value) => appState.setRxSoundEnabled(value), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Disconnect Alert'), - subtitle: const Text('Triple beep when pinging stops unexpectedly'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), value: appState.isDisconnectAlertEnabled, onChanged: (value) => appState.setDisconnectAlertEnabled(value), ), @@ -272,17 +288,20 @@ class _SettingsScreenState extends State { ? 'Device broadcasts as "Anonymous"' : 'Device uses its real name'), value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == + ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, ), ListTile( leading: const Icon(Icons.timer), @@ -290,7 +309,9 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showIntervalSelector(context, appState), ), ListTile( leading: const Icon(Icons.straighten), @@ -298,16 +319,22 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.minPingDistanceDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showDistanceSelector(context, appState), ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), + subtitle: + const Text('Stops auto-ping after 30 min without movement'), value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(autoStopAfterIdle: value)); + }, ), ]), @@ -317,7 +344,9 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.compare_arrows), title: Row( children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHybridModeInfo(context), @@ -339,15 +368,20 @@ class _SettingsScreenState extends State { ) : const Text('Combines Active and Passive modes'), value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, + onChanged: (isAutoMode || appState.enforceHybrid) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(hybridModeEnabled: value)); + }, ), SwitchListTile( secondary: const Icon(Icons.signal_wifi_off), title: Row( children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('Discovery Drop', + overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showDiscDropInfo(context), @@ -369,13 +403,16 @@ class _SettingsScreenState extends State { ) : const Text('Count failed discoveries as failed pings'), value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value == true) { - _showDiscDropEnableConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); - } - }, + onChanged: (isAutoMode || appState.enforceDiscDrop) + ? null + : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(discDropEnabled: false)); + } + }, ), ]), @@ -384,17 +421,21 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.filter_alt), title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), + subtitle: Text( + prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(ignoreCarpeater: value)); + } + }, ), if (prefs.ignoreCarpeater) ListTile( @@ -405,7 +446,9 @@ class _SettingsScreenState extends State { : 'Not set'), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showRepeaterIdDialog(context, appState), ), SwitchListTile( secondary: const Icon(Icons.shield_outlined), @@ -414,13 +457,16 @@ class _SettingsScreenState extends State { ? 'Allows all signal strengths' : 'Drops signals stronger than -30 dBm'), value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(disableRssiFilter: false)); + } + }, ), ]), @@ -430,7 +476,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.linear_scale), title: Row( children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHopBytesInfo(context), @@ -462,14 +509,19 @@ class _SettingsScreenState extends State { ) : const Text('Repeater ID size in TX/RX path hops'), trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + value: appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes, underline: const SizedBox(), items: const [ DropdownMenuItem(value: 1, child: Text('1')), DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 3, child: Text('3')), ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + appState.enforceHopBytes || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setHopBytes(value); @@ -480,7 +532,9 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.gps_fixed), title: Row( children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Trace Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showTraceBytesInfo(context), @@ -514,7 +568,9 @@ class _SettingsScreenState extends State { DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 4, child: Text('4')), ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setTraceHopBytes(value); @@ -545,7 +601,8 @@ class _SettingsScreenState extends State { : 'Keeps #wardriving channel on device'), value: prefs.deleteChannelOnDisconnect, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + appState.updatePreferences( + prefs.copyWith(deleteChannelOnDisconnect: value)); }, ), ]), @@ -600,12 +657,15 @@ class _SettingsScreenState extends State { ) else ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession( + context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession( + context, appState, session.filename), + onDownload: () => _downloadOfflineSession( + context, appState, session.filename), + )), ]), // API Endpoints @@ -622,13 +682,16 @@ class _SettingsScreenState extends State { ? (prefs.customApiUrl ?? 'Not configured') : 'Forward pings to a third-party server'), value: prefs.customApiEnabled, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showCustomApiDisclaimer(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(customApiEnabled: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(customApiEnabled: false)); + } + }, ), if (prefs.customApiEnabled) ...[ ListTile( @@ -636,29 +699,41 @@ class _SettingsScreenState extends State { title: const Text('Endpoint URL'), subtitle: Text(prefs.customApiUrl ?? 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiUrlDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiUrlDialog(context, appState), ), ListTile( leading: const SizedBox(width: 24), title: const Text('API Key'), - subtitle: Text(prefs.customApiKey != null ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Not set'), + subtitle: Text(prefs.customApiKey != null + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' + : 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiKeyDialog(context, appState), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Include Contact Key'), - subtitle: const Text('Share device public key prefix with endpoint'), + subtitle: + const Text('Share device public key prefix with endpoint'), value: prefs.customApiIncludeContact, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(customApiIncludeContact: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(customApiIncludeContact: value)); + }, ), ListTile( leading: const Icon(Icons.content_paste), title: const Text('Import from Clipboard'), subtitle: const Text('Paste a meshmapper:// config link'), - onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState), + onTap: isAutoMode + ? null + : () => _importCustomApiFromClipboard(context, appState), ), ], ]), @@ -686,7 +761,8 @@ class _SettingsScreenState extends State { leading: const FaIcon(FontAwesomeIcons.github), title: const Text('GitHub'), subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + onTap: () => _launchUrl( + 'https://github.com/MeshMapper/MeshMapper_Project'), ), ListTile( leading: const FaIcon(FontAwesomeIcons.discord), @@ -697,7 +773,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.groups), title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + subtitle: const Text( + 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), ListTile( @@ -714,12 +791,15 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.exit_to_app), title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), + subtitle: + const Text('Automatically exit the app when disconnecting'), value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + onChanged: (value) => + appState.setCloseAppAfterDisconnect(value), ), ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), + leading: + const Icon(Icons.power_settings_new, color: Colors.red), title: const Text('Close App'), subtitle: const Text('Exit the app completely'), onTap: () => _showCloseAppConfirmation(context, appState), @@ -749,7 +829,8 @@ class _SettingsScreenState extends State { if (appState.isGpsSimulatorEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -787,13 +868,15 @@ class _SettingsScreenState extends State { min: 10, max: 120, divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + label: formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), onChanged: (value) { appState.setGpsSimulatorSpeed(value); }, ), trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -809,15 +892,18 @@ class _SettingsScreenState extends State { items: [ const DropdownMenuItem( value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), + child: Text('Straight Line', + overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + child: + Text('Circle', overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + child: Text('Random Walk', + overflow: TextOverflow.ellipsis), ), if (appState.hasSimulatorRoute) DropdownMenuItem( @@ -898,7 +984,8 @@ class _SettingsScreenState extends State { if (appState.debugLogsEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -929,7 +1016,8 @@ class _SettingsScreenState extends State { } }, ), - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + if (appState.debugLogsEnabled || + appState.debugLogFiles.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( @@ -945,12 +1033,14 @@ class _SettingsScreenState extends State { TextButton.icon( icon: const Icon(Icons.cloud_upload, size: 18), label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + onPressed: () => + _showUploadLogsDialog(context, appState), ), TextButton.icon( icon: const Icon(Icons.delete_sweep, size: 18), label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + onPressed: () => + _confirmDeleteAllLogs(context, appState), ), ], ], @@ -961,7 +1051,8 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + style: + TextStyle(color: Colors.grey.shade500, fontSize: 13), ), ) else @@ -971,19 +1062,27 @@ class _SettingsScreenState extends State { final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final timestampMatch = + RegExp(r'meshmapper-debug-(\d+)\.txt') + .firstMatch(filename); final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + int.parse(timestampMatch.group(1)!) * 1000) : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + final dateStr = fileDate != null + ? DateFormat('MMM d, h:mm a').format(fileDate) + : filename; String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } if (isCurrentLog) { sizeDisplay = '$sizeDisplay (current)'; @@ -991,7 +1090,8 @@ class _SettingsScreenState extends State { return ListTile( leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), + title: + Text(dateStr, style: const TextStyle(fontSize: 13)), subtitle: Text( sizeDisplay, style: const TextStyle(fontSize: 11), @@ -1001,7 +1101,8 @@ class _SettingsScreenState extends State { children: [ IconButton( icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), + onPressed: () => + _showLogViewer(context, appState, file), tooltip: 'View', ), IconButton( @@ -1022,27 +1123,38 @@ class _SettingsScreenState extends State { String _markerStyleLabel(String style) { switch (style) { - case 'circle': return 'Outlined Dot'; - case 'pin': return 'Pin'; - case 'diamond': return 'Diamond'; + case 'circle': + return 'Outlined Dot'; + case 'pin': + return 'Pin'; + case 'diamond': + return 'Diamond'; case 'dot': - default: return 'Dot'; + default: + return 'Dot'; } } String _gpsMarkerLabel(String style) { switch (style) { - case 'car': return 'Car'; - case 'bike': return 'Bike'; - case 'boat': return 'Boat'; - case 'walk': return 'Walk'; - case 'chomper': return 'Chomper'; + case 'car': + return 'Car'; + case 'bike': + return 'Bike'; + case 'boat': + return 'Boat'; + case 'walk': + return 'Walk'; + case 'chomper': + return 'Chomper'; case 'arrow': - default: return 'Arrow'; + default: + return 'Arrow'; } } - void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + void _showMarkerStyleSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('dot', 'Dot', Icons.circle), ('circle', 'Outlined Dot', Icons.circle_outlined), @@ -1060,13 +1172,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Map Marker Style', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.markerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(markerStyle: v)); } Navigator.pop(context); }, @@ -1109,13 +1223,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('GPS Marker', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.gpsMarkerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(gpsMarkerStyle: v)); } Navigator.pop(context); }, @@ -1148,13 +1264,30 @@ class _SettingsScreenState extends State { }; } - void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + void _showColorVisionSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('none', 'Default', 'Standard color palette'), - ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), - ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), - ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), - ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ( + 'protanopia', + 'Protanopia', + 'Red-blind — difficulty distinguishing red and green' + ), + ( + 'deuteranopia', + 'Deuteranopia', + 'Green-blind — difficulty distinguishing red and green' + ), + ( + 'tritanopia', + 'Tritanopia', + 'Blue-blind — difficulty distinguishing blue and yellow' + ), + ( + 'achromatopsia', + 'Achromatopsia', + 'Total color blindness — sees in greyscale' + ), ]; showModalBottomSheet( context: context, @@ -1167,7 +1300,8 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Color Vision', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.colorVisionType, @@ -1182,7 +1316,8 @@ class _SettingsScreenState extends State { RadioListTile( secondary: const Icon(Icons.visibility), title: Text(label), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), value: value, ), ], @@ -1195,7 +1330,8 @@ class _SettingsScreenState extends State { ); } - Widget _buildSection(BuildContext context, String title, List children) { + Widget _buildSection( + BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Card( @@ -1232,7 +1368,8 @@ class _SettingsScreenState extends State { } } - Future _showBugReportDialog(BuildContext context, AppStateProvider appState) async { + Future _showBugReportDialog( + BuildContext context, AppStateProvider appState) async { final result = await showBugReportDialog(context, appState); if (!context.mounted || result == null) return; @@ -1252,7 +1389,8 @@ class _SettingsScreenState extends State { message, duration: const Duration(seconds: 5), actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + onAction: + result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, ); } else if (result.errorMessage != null) { AppToast.error( @@ -1313,7 +1451,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableRssiFilterConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1346,7 +1485,8 @@ class _SettingsScreenState extends State { ); } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showEnableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, @@ -1380,7 +1520,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1425,21 +1566,24 @@ class _SettingsScreenState extends State { style: TextStyle(fontSize: 14), ), SizedBox(height: 12), - Text('How it works:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('How it works:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'Discovery \u2192 wait \u2192 TX Ping \u2192 wait \u2192 Discovery \u2192 ...', style: TextStyle(fontSize: 13, fontFamily: 'monospace'), ), SizedBox(height: 12), - Text('Interval timing:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('Interval timing:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'At 15s interval, each ping type fires every 30s. Discovery\'s 30s firmware rate limit is naturally respected.', style: TextStyle(fontSize: 13), ), SizedBox(height: 12), - Text('When enabled:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('When enabled:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( '\u2022 Replaces the Active button with Hybrid\n' @@ -1496,7 +1640,8 @@ class _SettingsScreenState extends State { ); } - void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + void _showDiscDropEnableConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1718,7 +1863,8 @@ class _SettingsScreenState extends State { final tile = RadioListTile( title: Text( '$interval seconds', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), ), subtitle: isDisabled ? const Text( @@ -1826,7 +1972,8 @@ class _SettingsScreenState extends State { textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters - final filtered = value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + final filtered = + value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (filtered != value) { controller.value = controller.value.copyWith( text: filtered, @@ -1870,7 +2017,8 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); + AppToast.warning( + context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1880,7 +2028,8 @@ class _SettingsScreenState extends State { ); } - Future _pickRouteFile(BuildContext context, AppStateProvider appState) async { + Future _pickRouteFile( + BuildContext context, AppStateProvider appState) async { try { debugLog('[SETTINGS] Opening file picker...'); @@ -1898,9 +2047,8 @@ class _SettingsScreenState extends State { if (result != null && result.files.isNotEmpty) { debugLog('[SETTINGS] File picked: ${result.files.first.name}'); final file = result.files.first; - final content = file.bytes != null - ? String.fromCharCodes(file.bytes!) - : null; + final content = + file.bytes != null ? String.fromCharCodes(file.bytes!) : null; if (content != null && context.mounted) { debugLog('[SETTINGS] File content loaded, ${content.length} chars'); @@ -1934,7 +2082,8 @@ class _SettingsScreenState extends State { ); } - void _processRouteFile(BuildContext context, AppStateProvider appState, String content, String filename) { + void _processRouteFile(BuildContext context, AppStateProvider appState, + String content, String filename) { debugLog('[SETTINGS] Calling loadSimulatorRoute...'); final success = appState.loadSimulatorRoute( content, @@ -1955,7 +2104,8 @@ class _SettingsScreenState extends State { } } - Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { + Future _uploadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { // Progress text notifier for updating dialog without rebuilding screen final progressNotifier = ValueNotifier('Authenticating...'); @@ -2055,7 +2205,8 @@ class _SettingsScreenState extends State { } } - void _confirmDeleteOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _confirmDeleteOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2081,9 +2232,11 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { try { - final sessionData = appState.offlineSessionService.getSessionData(filename); + final sessionData = + appState.offlineSessionService.getSessionData(filename); if (sessionData == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2095,7 +2248,8 @@ class _SettingsScreenState extends State { } // Convert to pretty JSON - final jsonString = const JsonEncoder.withIndent(' ').convert(sessionData); + final jsonString = + const JsonEncoder.withIndent(' ').convert(sessionData); if (kIsWeb && isWebFileHelpersAvailable) { // Web: Create a blob and trigger download @@ -2162,7 +2316,8 @@ class _SettingsScreenState extends State { } /// Show debug log viewer dialog - void _showLogViewer(BuildContext context, AppStateProvider appState, File file) async { + void _showLogViewer( + BuildContext context, AppStateProvider appState, File file) async { await appState.viewDebugLog(file); if (!context.mounted) return; @@ -2194,7 +2349,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiDisclaimer(BuildContext context, AppStateProvider appState) { + void _showCustomApiDisclaimer( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2252,7 +2408,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiUrlDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiUrlDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiUrl ?? '', ); @@ -2312,7 +2469,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiKeyDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiKeyDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiKey ?? '', ); @@ -2351,7 +2509,8 @@ class _SettingsScreenState extends State { ); } - Future _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async { + Future _importCustomApiFromClipboard( + BuildContext context, AppStateProvider appState) async { final clipData = await Clipboard.getData('text/plain'); final text = clipData?.text?.trim(); @@ -2374,11 +2533,13 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the url parameter'); + if (context.mounted) + AppToast.error(context, 'Link is missing the url parameter'); return; } if (key == null || key.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the key parameter'); + if (context.mounted) + AppToast.error(context, 'Link is missing the key parameter'); return; } @@ -2387,7 +2548,8 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl'); + if (context.mounted) + AppToast.error(context, 'Invalid URL in link: $rawUrl'); return; } @@ -2404,11 +2566,13 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link'); + if (context.mounted) + AppToast.error(context, 'Invalid meshmapper:// link'); } } - void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { + void _showCloseAppConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; showDialog( @@ -2488,7 +2652,9 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Future _requestPermission() async { // Show prominent disclosure before requesting background location - final accepted = await PermissionDisclosureService.showBackgroundLocationDisclosure(context); + final accepted = + await PermissionDisclosureService.showBackgroundLocationDisclosure( + context); if (!accepted) { return; // User declined } @@ -2623,7 +2789,10 @@ class _OfflineSessionTile extends StatelessWidget { if (isUploaded) const Text( 'Uploaded', - style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500), ), if (session.deviceName != null) Text( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 932f06ed..4ed600a4 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -79,7 +79,8 @@ class ApiQueueService { // Pings without a valid session cannot be uploaded, so delete them try { if (_box != null && _box!.isNotEmpty) { - debugLog('[API QUEUE] Clearing ${_box!.length} stale items from previous session'); + debugLog( + '[API QUEUE] Clearing ${_box!.length} stale items from previous session'); await _box!.clear(); } } catch (e) { @@ -108,10 +109,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened successfully'); return box; } on TimeoutException { - debugError('[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); return _attemptRecovery(timeout); } catch (e) { - debugError('[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); return _attemptRecovery(timeout); } } @@ -132,10 +135,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened after recovery'); return box; } catch (e) { - debugError('[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); + debugError( + '[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); // Notify user of persistence failure - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); return null; } @@ -150,7 +155,8 @@ class ApiQueueService { _isRecovering = true; try { - debugLog('[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); + debugLog( + '[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); // Close the corrupt box try { @@ -168,16 +174,19 @@ class ApiQueueService { _box = box; debugLog('[API QUEUE] Box recovered successfully'); } catch (e) { - debugError('[API QUEUE] Runtime recovery failed: $e - operating without persistence'); + debugError( + '[API QUEUE] Runtime recovery failed: $e - operating without persistence'); _box = null; - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); } finally { _isRecovering = false; } } /// Wrap a write operation with corruption recovery and single retry - Future _safeWrite(Future Function(Box box) operation) async { + Future _safeWrite( + Future Function(Box box) operation) async { final box = _box; if (box == null) return false; @@ -249,9 +258,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -344,9 +355,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -393,9 +406,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -434,9 +449,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -474,7 +491,8 @@ class ApiQueueService { } } - debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); + debugLog( + '[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); onQueueUpdated?.call(queueSize); } finally { _isFlushing = false; @@ -525,13 +543,15 @@ class ApiQueueService { try { // Collect items from both Hive and memory queue - final hiveItems = _safeRead((box) => box.values - .where((item) => - item.retryCount < _maxRetries && - item.isReadyForRetry && - item.isUploadEligible) - .take(_batchSize) - .toList(), []); + final hiveItems = _safeRead( + (box) => box.values + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize) + .toList(), + []); final memoryItems = _memoryQueue .where((item) => @@ -555,12 +575,14 @@ class ApiQueueService { // Log each item with external_antenna value for (int i = 0; i < items.length; i++) { final item = items[i]; - debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); + debugLog( + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } final memoryCount = memoryItems.length; if (memoryCount > 0) { - debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + debugLog( + '[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); } else { debugLog('[API QUEUE] Uploading ${items.length} items...'); } @@ -572,7 +594,9 @@ class ApiQueueService { final uploadedCount = items.length; // Remove successful Hive items for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} + try { + await item.delete(); + } catch (_) {} } // Remove successful memory items for (final item in memoryItems) { @@ -585,12 +609,15 @@ class ApiQueueService { } else if (result == UploadResult.nonRetryable) { // Data is permanently invalid — discard for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} + try { + await item.delete(); + } catch (_) {} } for (final item in memoryItems) { _memoryQueue.remove(item); } - debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); + debugWarn( + '[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried for (final item in hiveItems) { @@ -601,7 +628,8 @@ class ApiQueueService { item.retryCount++; item.lastRetryAt = DateTime.now(); } - debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); + debugLog( + '[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } onQueueUpdated?.call(queueSize); @@ -648,7 +676,8 @@ class ApiQueueService { final count = queueSize + _rxBuffer.length; if (count > 0) { - debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); + debugLog( + '[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); _memoryQueue.clear(); @@ -679,10 +708,12 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { final hiveItems = _safeRead( - (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), + (box) => + box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); - final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + final memoryItems = + _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); return [...hiveItems, ...memoryItems]; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index eb8a47df..e147e94b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -33,7 +33,7 @@ class ApiService { static const Duration heartbeatBuffer = Duration(minutes: 1); final http.Client _client; - bool _heartbeatEnabled = false; // Track if heartbeat mode is active + bool _heartbeatEnabled = false; // Track if heartbeat mode is active String? _sessionId; bool _txAllowed = false; bool _rxAllowed = false; @@ -91,7 +91,8 @@ class ApiService { /// Check if response indicates maintenance mode, trigger callback if so bool _checkMaintenanceMode(Map response) { if (response['maintenance'] == true) { - final message = response['maintenance_message'] as String? ?? 'Service is under maintenance'; + final message = response['maintenance_message'] as String? ?? + 'Service is under maintenance'; final url = response['maintenance_url'] as String?; debugLog('[MAINTENANCE] Maintenance mode detected: $message'); onMaintenanceMode?.call(message, url); @@ -109,7 +110,8 @@ class ApiService { Map? request, dynamic response, }) { - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); String reqSummary; if (request != null) { @@ -136,13 +138,13 @@ class ApiService { /// Check if we have a valid session bool get hasSession => _sessionId != null; - + /// Check if TX is allowed bool get txAllowed => _txAllowed; - + /// Check if RX is allowed bool get rxAllowed => _rxAllowed; - + /// Get session ID String? get sessionId => _sessionId; @@ -174,17 +176,21 @@ class ApiService { 'key': apiKey, }; - final response = await _client.post( - Uri.parse(geoAuthStatusUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthStatusUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); debugError('[API] Response headers: ${response.headers}'); } @@ -193,7 +199,8 @@ class ApiService { data = json.decode(response.body) as Map; } on FormatException { // CDN/proxy can return HTML error pages with HTTP 200 - debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } @@ -226,8 +233,8 @@ class ApiService { /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, - String? publicKey, // Now optional - either publicKey or contactUri required - String? contactUri, // NEW: for registration flow + String? publicKey, // Now optional - either publicKey or contactUri required + String? contactUri, // NEW: for registration flow String? who, String? appVersion, double? power, @@ -269,7 +276,8 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + if (power != null) + payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; payload['coords'] = { @@ -283,24 +291,29 @@ class ApiService { payload['session_id'] = sessionId ?? _sessionId; } - final response = await _client.post( - Uri.parse(geoAuthUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -316,7 +329,8 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) - if ((reason == 'connect' || reason == 'register') && data['success'] == true) { + if ((reason == 'connect' || reason == 'register') && + data['success'] == true) { if (!skipSessionStore) { _sessionId = data['session_id'] as String?; _txAllowed = data['tx_allowed'] == true; @@ -367,7 +381,8 @@ class ApiService { if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { _apiHopBytes = hopBytes; if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + debugLog( + '[API] Regional admin enforces $_apiHopBytes-byte paths'); } } else { _apiHopBytes = 1; @@ -397,7 +412,8 @@ class ApiService { /// /// @param entries List of wardrive entries (TX/RX) /// @returns Map with success, expires_at, reason, message - Future?> submitWardriveData(List> entries) async { + Future?> submitWardriveData( + List> entries) async { if (_sessionId == null) { throw Exception('Cannot submit: no session_id'); } @@ -410,32 +426,37 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } // Log with data summary including external_antenna values - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive', method: 'POST', @@ -486,24 +507,29 @@ class ApiService { }; } - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -533,7 +559,8 @@ class ApiService { return data; } catch (e) { stopwatch.stop(); - debugError('[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); + debugError( + '[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); return null; } } @@ -547,7 +574,11 @@ class ApiService { }) async { if (_sessionId == null) { debugWarn('[SESSION] No session to validate'); - return (isValid: false, reason: 'no_session', message: 'No active session'); + return ( + isValid: false, + reason: 'no_session', + message: 'No active session' + ); } debugLog('[SESSION] Checking session validity via heartbeat...'); @@ -555,11 +586,16 @@ class ApiService { if (result == null) { debugWarn('[SESSION] Session check failed: no response'); - return (isValid: false, reason: 'no_response', message: 'Server did not respond'); + return ( + isValid: false, + reason: 'no_response', + message: 'Server did not respond' + ); } if (result['success'] == true) { - debugLog('[SESSION] Session is valid (expires_at: ${result['expires_at']})'); + debugLog( + '[SESSION] Session is valid (expires_at: ${result['expires_at']})'); return (isValid: true, reason: null, message: null); } @@ -570,9 +606,15 @@ class ApiService { // Trigger session error callback for critical errors const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); @@ -628,14 +670,17 @@ class ApiService { // Calculate when to send heartbeat (1 minute before expiry) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final secondsUntilExpiry = expiresAt - now; - final secondsUntilHeartbeat = secondsUntilExpiry - heartbeatBuffer.inSeconds; + final secondsUntilHeartbeat = + secondsUntilExpiry - heartbeatBuffer.inSeconds; if (secondsUntilHeartbeat <= 0) { // Session is about to expire or already expired - send heartbeat immediately - debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); } else { - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + debugLog( + '[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { debugLog('[HEARTBEAT] Timer fired, sending keepalive'); @@ -662,11 +707,14 @@ class ApiService { if (_heartbeatRetryCount < _maxHeartbeatRetries) { final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); _heartbeatRetryCount++; - debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + debugWarn( + '[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + _heartbeatRetryTimer = + Timer(Duration(seconds: delay), _sendScheduledHeartbeat); } else { - debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + debugError( + '[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); } _onSessionExpiring?.call(); } else { @@ -676,9 +724,15 @@ class ApiService { debugWarn('[HEARTBEAT] Heartbeat failed: $reason - $message'); const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { @@ -773,7 +827,8 @@ class ApiService { // outside_zone: preserve session (backend auto-transfers on zone re-entry), // but discard this batch (gap-GPS coords would be rejected again) if (reason == 'outside_zone') { - debugWarn('[API] Upload batch outside_zone — discarding batch, preserving session'); + debugWarn( + '[API] Upload batch outside_zone — discarding batch, preserving session'); final message = result['message'] as String?; onSessionError?.call(reason, message); return UploadResult.nonRetryable; @@ -781,10 +836,15 @@ class ApiService { // Errors where the batch data itself is invalid — retrying won't help const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } @@ -803,9 +863,11 @@ class ApiService { try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; - final response = await _client.get( - Uri.parse(url), - ).timeout(const Duration(seconds: 15)); + final response = await _client + .get( + Uri.parse(url), + ) + .timeout(const Duration(seconds: 15)); stopwatch.stop(); @@ -820,7 +882,8 @@ class ApiService { return []; } - final List jsonList = json.decode(response.body) as List; + final List jsonList = + json.decode(response.body) as List; final repeaters = []; for (final item in jsonList) { @@ -865,31 +928,36 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive (offline)', method: 'POST', @@ -933,9 +1001,16 @@ class ApiService { // For offline uploads, session/auth errors are non-retryable but do NOT cascade const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'outside_zone', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { debugError('[API] Offline upload batch session error: $reason'); @@ -943,10 +1018,15 @@ class ApiService { } const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Offline upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 6b74c07c..27cc57b9 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -25,8 +25,10 @@ class AudioService { AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it - bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) - bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) + bool _txEnabled = + true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = + true; // RX sound sub-toggle (only matters when master is on) Timer? _focusReleaseTimer; /// Whether the audio service is initialized @@ -148,13 +150,15 @@ class AudioService { debugError('[AUDIO] Hive box "$boxName" timed out - attempting recovery'); return _attemptRecovery(boxName, timeout); } catch (e) { - debugError('[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); + debugError( + '[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); return _attemptRecovery(boxName, timeout); } } /// Attempt to recover from Hive corruption - Future?> _attemptRecovery(String boxName, Duration timeout) async { + Future?> _attemptRecovery( + String boxName, Duration timeout) async { try { debugLog('[AUDIO] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -163,7 +167,8 @@ class AudioService { debugLog('[AUDIO] Box "$boxName" opened after recovery'); return box; } catch (e) { - debugError('[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); + debugError( + '[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); return null; } } @@ -182,7 +187,8 @@ class AudioService { /// Shared playback logic for both TX and RX sounds. /// Ensures audio session is active before playing and debounces focus release. - Future _playSound(AudioPlayer? player, String assetPath, String label) async { + Future _playSound( + AudioPlayer? player, String assetPath, String label) async { if (!_initialized || !_enabled || player == null) return; try { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 1e76464f..2d7bed52 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -95,7 +95,8 @@ class BackgroundServiceManager { // (e.g., Android resurrecting a previously-killed foreground service). final isRunning = await _service!.isRunning(); if (isRunning) { - debugLog('[BACKGROUND] Service unexpectedly running after configure(), stopping it'); + debugLog( + '[BACKGROUND] Service unexpectedly running after configure(), stopping it'); _service!.invoke('stop'); } @@ -221,7 +222,8 @@ class BackgroundServiceManager { static Future cleanupOrphanedService() async { if (kIsWeb) return; try { - debugLog('[BACKGROUND] Dismissing any orphaned notification from previous session'); + debugLog( + '[BACKGROUND] Dismissing any orphaned notification from previous session'); final plugin = FlutterLocalNotificationsPlugin(); await plugin.cancel(_notificationId); debugLog('[BACKGROUND] Orphaned notification cleanup complete'); diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 8fb3d629..cc3a441a 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -35,10 +35,13 @@ class MobileBluetoothService implements BluetoothService { } void _ensureControllers() { - if (_isDisposed || _connectionController == null || _connectionController!.isClosed) { + if (_isDisposed || + _connectionController == null || + _connectionController!.isClosed) { _initControllers(); } } + DiscoveredDevice? _connectedDevice; fbp.BluetoothDevice? _bleDevice; fbp.BluetoothCharacteristic? _rxCharacteristic; @@ -135,19 +138,21 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] iOS location permission check: $locationPermission'); if (locationPermission == LocationPermission.deniedForever) { - debugLog('[BLE] iOS location permission permanently denied - user must enable in Settings'); + debugLog( + '[BLE] iOS location permission permanently denied - user must enable in Settings'); throw BlePermissionDeniedException( - 'Location permission required for Bluetooth scanning. ' - 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper' - ); + 'Location permission required for Bluetooth scanning. ' + 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper'); } if (locationPermission == LocationPermission.denied) { - debugLog('[BLE] iOS location permission not yet granted (disclosure flow will handle)'); + debugLog( + '[BLE] iOS location permission not yet granted (disclosure flow will handle)'); return false; } - debugLog('[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); + debugLog( + '[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); return true; } else { // Android: Use bluetoothScan and bluetoothConnect (Android 12+) @@ -155,18 +160,25 @@ class MobileBluetoothService implements BluetoothService { // Location requests are handled by the disclosure flow in MainScaffold. final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); - final location = await Permission.locationWhenInUse.status; // CHECK only, don't request + final location = await Permission + .locationWhenInUse.status; // CHECK only, don't request - debugLog('[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); + debugLog( + '[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); // Check for permanently denied permissions - if (bluetoothScan.isPermanentlyDenied || bluetoothConnect.isPermanentlyDenied || location.isPermanentlyDenied) { + if (bluetoothScan.isPermanentlyDenied || + bluetoothConnect.isPermanentlyDenied || + location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) denied.add('Bluetooth Connect'); + if (bluetoothConnect.isPermanentlyDenied) + denied.add('Bluetooth Connect'); if (location.isPermanentlyDenied) denied.add('Location'); - debugLog('[BLE] Android permissions permanently denied: ${denied.join(", ")}'); - throw BlePermissionDeniedException('${denied.join(", ")} permission(s) denied. Please enable in Settings'); + debugLog( + '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); + throw BlePermissionDeniedException( + '${denied.join(", ")} permission(s) denied. Please enable in Settings'); } final granted = bluetoothScan.isGranted && @@ -185,7 +197,7 @@ class MobileBluetoothService implements BluetoothService { Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); _scanController = controller; - + _updateStatus(ConnectionStatus.scanning); try { @@ -203,9 +215,11 @@ class MobileBluetoothService implements BluetoothService { _scanSubscription = fbp.FlutterBluePlus.scanResults.listen((results) { for (final result in results) { final hasName = result.device.platformName.isNotEmpty; - final deviceName = hasName ? result.device.platformName : 'MeshCore Device'; + final deviceName = + hasName ? result.device.platformName : 'MeshCore Device'; if (!hasName) { - debugLog('[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); } final device = DiscoveredDevice( id: result.device.remoteId.str, @@ -222,7 +236,9 @@ class MobileBluetoothService implements BluetoothService { // Complete stream when scan naturally stops (timeout or platform stop) unawaited(() async { - await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + await fbp.FlutterBluePlus.isScanning + .where((isScanning) => !isScanning) + .first; if (!controller.isClosed) { await controller.close(); } @@ -296,7 +312,8 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Connecting to GATT...'); await _bleDevice!.connect( timeout: const Duration(seconds: 15), - mtu: null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android + mtu: + null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android ); debugLog('[BLE] GATT connected'); @@ -313,7 +330,8 @@ class MobileBluetoothService implements BluetoothService { } catch (e) { // MTU negotiation failure is not fatal - continue with default MTU // Some older devices may not support MTU negotiation - debugLog('[BLE] MTU negotiation failed (continuing with default): $e'); + debugLog( + '[BLE] MTU negotiation failed (continuing with default): $e'); } } else { // iOS auto-negotiates MTU, just log the current value @@ -326,7 +344,8 @@ class MobileBluetoothService implements BluetoothService { // Flutter Blue Plus emits the current state immediately when you subscribe, // but we only want to react to CHANGES, not the initial state. // This prevents false disconnection triggers during connection setup. - _connectionStateSubscription = _bleDevice!.connectionState.skip(1).listen((state) { + _connectionStateSubscription = + _bleDevice!.connectionState.skip(1).listen((state) { debugLog('[BLE] Connection state changed: $state'); if (state == fbp.BluetoothConnectionState.disconnected) { _handleDisconnection(); @@ -364,8 +383,11 @@ class MobileBluetoothService implements BluetoothService { // Enable notifications on TX characteristic debugLog('[BLE] Enabling notifications...'); await _txCharacteristic!.setNotifyValue(true); - _notificationSubscription = _txCharacteristic!.lastValueStream.listen((value) { - if (value.isNotEmpty && _dataController != null && !_dataController!.isClosed) { + _notificationSubscription = + _txCharacteristic!.lastValueStream.listen((value) { + if (value.isNotEmpty && + _dataController != null && + !_dataController!.isClosed) { _dataController!.add(Uint8List.fromList(value)); } }); @@ -380,41 +402,48 @@ class MobileBluetoothService implements BluetoothService { deviceName = _bleDevice!.platformName; } else { deviceName = 'MeshCore Device'; - debugLog('[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: deviceId, name: deviceName, ); if (deviceName == 'MeshCore Device') { - debugLog('[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); } else { - debugLog('[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); } debugLog('[BLE] Connection complete'); _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop - } catch (e, stackTrace) { final errorStr = e.toString(); // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + final isError133 = + Platform.isAndroid && errorStr.contains('android-code: 133'); // Check for iOS apple-code 14 (Peer removed pairing information) or // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys final isBondError = Platform.isIOS && - (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')); if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { - debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + debugLog( + '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); await removeBond(deviceId); await Future.delayed(const Duration(seconds: 2)); } else { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + debugLog( + '[BLE] Error 133 on attempt $attempt, retrying after delay...'); await Future.delayed(_retryDelay); } // Force cleanup before retry diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index ef5ed4c2..e93d16f9 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -13,14 +13,16 @@ import 'bluetooth_service.dart'; class WebBluetoothService implements BluetoothService { final _connectionController = StreamController.broadcast(); final _dataController = StreamController.broadcast(); - final fwb.FlutterWebBluetoothInterface _webBluetooth = fwb.FlutterWebBluetooth.instance; + final fwb.FlutterWebBluetoothInterface _webBluetooth = + fwb.FlutterWebBluetooth.instance; ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; DiscoveredDevice? _connectedDevice; fwb.BluetoothDevice? _device; fwb.BluetoothDevice? _pendingDevice; // Store device from scan for connect() - fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) - fwb.BluetoothCharacteristic? _txCharacteristic; // For notifications (device TX) + fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) + fwb.BluetoothCharacteristic? + _txCharacteristic; // For notifications (device TX) StreamSubscription? _notificationSubscription; @override @@ -73,13 +75,15 @@ class WebBluetoothService implements BluetoothService { // Web Bluetooth doesn't support scanning - uses requestDevice dialog // This is a stub that will yield devices from the request dialog _updateStatus(ConnectionStatus.scanning); - debugLog('[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); - + debugLog( + '[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); + try { // Request device filtered by MeshCore service UUID (matches JS implementation) final device = await _webBluetooth.requestDevice( fwb.RequestOptionsBuilder([ - fwb.RequestFilterBuilder(services: [BleUuids.serviceUuid.toLowerCase()]), + fwb.RequestFilterBuilder( + services: [BleUuids.serviceUuid.toLowerCase()]), ]), ); @@ -89,7 +93,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = device.name ?? 'MeshCore Device'; if (device.name == null) { - debugWarn('[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); } yield DiscoveredDevice( id: device.id, @@ -123,7 +128,7 @@ class WebBluetoothService implements BluetoothService { debugError('[BLE] No pending device - must call scanForDevices first'); throw Exception('No device selected. Please scan for devices first.'); } - + _device = _pendingDevice; _pendingDevice = null; // Clear pending debugLog('[BLE] Using stored device: ${_device!.name ?? _device!.id}'); @@ -137,7 +142,7 @@ class WebBluetoothService implements BluetoothService { debugLog('[BLE] Discovering services...'); final services = await _device!.discoverServices(); debugLog('[BLE] Found ${services.length} services'); - + // Find our MeshCore service fwb.BluetoothService? meshCoreService; for (final service in services) { @@ -148,7 +153,7 @@ class WebBluetoothService implements BluetoothService { break; } } - + if (meshCoreService == null) { throw Exception('MeshCore service not found'); } @@ -179,14 +184,15 @@ class WebBluetoothService implements BluetoothService { try { await _txCharacteristic!.startNotifications(); debugLog('[BLE] Notifications started, setting up listener...'); - + // HIGH-LEVEL API: BluetoothCharacteristic.value is a Stream _notificationSubscription = _txCharacteristic!.value.listen( (ByteData data) { try { // Convert ByteData to Uint8List - final buffer = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - + final buffer = data.buffer + .asUint8List(data.offsetInBytes, data.lengthInBytes); + if (buffer.isNotEmpty) { debugLog('[BLE] Received ${buffer.length} bytes'); _dataController.add(buffer); @@ -209,7 +215,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = _device!.name ?? 'MeshCore Device'; if (_device!.name == null) { - debugWarn('[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: _device!.id, diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index bf5c94a2..412bd3fb 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -13,8 +13,8 @@ import '../utils/debug_logger_io.dart'; class CountdownTimerService { Timer? _timer; DateTime? _endTime; - int? _durationMs; // Original duration for progress calculation - final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick + int? _durationMs; // Original duration for progress calculation + final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick CountdownTimerService({this.onUpdate}); @@ -42,11 +42,12 @@ class CountdownTimerService { /// @param durationMs - Duration in milliseconds void start(int durationMs) { stop(); - _durationMs = durationMs; // Track original duration for progress + _durationMs = durationMs; // Track original duration for progress _endTime = DateTime.now().add(Duration(milliseconds: durationMs)); // Start 500ms update timer for responsive countdown - _timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); + _timer = + Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update _update(); @@ -136,7 +137,8 @@ class ManualPingCooldownTimer extends CountdownTimerService { final remaining = remainingMs; super.stop(); if (wasRunning) { - debugLog('[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); + debugLog( + '[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); } } } diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index b839622b..f5ba0c71 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -49,7 +49,8 @@ class CustomApiService { if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) return; // Enrich with contact and iata (custom API only — never sent to MeshMapper) - final contact = prefs.customApiIncludeContact ? contactGetter?.call() : null; + final contact = + prefs.customApiIncludeContact ? contactGetter?.call() : null; final iata = iataGetter?.call(); final enriched = pings.map((ping) { @@ -86,16 +87,21 @@ class CustomApiService { stopwatch.stop(); if (response.statusCode >= 200 && response.statusCode < 300) { - debugLog('[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); } else { final errorType = 'http_${response.statusCode}'; - debugError('[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); - debugError('[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); - _throttledError(errorType, 'Custom API returned HTTP ${response.statusCode}'); + debugError( + '[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); + debugError( + '[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + _throttledError( + errorType, 'Custom API returned HTTP ${response.statusCode}'); } } on TimeoutException { stopwatch.stop(); - debugError('[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); + debugError( + '[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); _throttledError('timeout', 'Custom API request timed out'); } catch (e) { stopwatch.stop(); @@ -124,7 +130,8 @@ class CustomApiService { String _describeError(Object e) { final full = e.toString(); // Look for SocketException detail (e.g. "Failed host lookup: 'blah.blah'") - final socketMatch = RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); + final socketMatch = + RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); if (socketMatch != null) return socketMatch.group(1)!.trim(); // Look for OS-level message final osMatch = RegExp(r'OS Error: (.+?)(?:,|\))').firstMatch(full); diff --git a/lib/services/debug_file_logger.dart b/lib/services/debug_file_logger.dart index 1406023c..5b43551e 100644 --- a/lib/services/debug_file_logger.dart +++ b/lib/services/debug_file_logger.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; /// - Non-persistent (always starts disabled on app launch) class DebugFileLogger { static const int maxLogFiles = 10; + /// Maximum file size for upload (4.5MB, 0.5MB safety margin under 5MB server limit) static const int maxUploadSizeBytes = 4718592; static File? _currentLogFile; @@ -293,7 +294,8 @@ class DebugFileLogger { for (final line in lines) { final lineBytes = line.length + 1; // +1 for newline - if (currentSize + lineBytes > maxUploadSizeBytes && currentChunk.isNotEmpty) { + if (currentSize + lineBytes > maxUploadSizeBytes && + currentChunk.isNotEmpty) { chunkLines.add(currentChunk); currentChunk = []; currentSize = 0; diff --git a/lib/services/debug_submit_service.dart b/lib/services/debug_submit_service.dart index fe902ba3..df147e74 100644 --- a/lib/services/debug_submit_service.dart +++ b/lib/services/debug_submit_service.dart @@ -135,7 +135,8 @@ class DebugSubmitService { ); if (ticketResult == null || ticketResult['success'] != true) { - final error = ticketResult?['message'] as String? ?? 'Failed to create ticket'; + final error = + ticketResult?['message'] as String? ?? 'Failed to create ticket'; debugError('[BUG REPORT] FAILED: Ticket creation failed: $error'); debugLog('[BUG REPORT] ========================================'); return BugReportResult.error(error); @@ -167,11 +168,13 @@ class DebugSubmitService { debugLog('[BUG REPORT] ----------------------------------------'); debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: $filename'); - reportProgress('Uploading $filename...', fileProgress, currentFile: i + 1); + reportProgress('Uploading $filename...', fileProgress, + currentFile: i + 1); // Add delay before file uploads to prevent server overload if (totalFiles > 1) { - final delayMs = i == 0 ? 500 : 1000; // 500ms before first, 1s between others + final delayMs = + i == 0 ? 500 : 1000; // 500ms before first, 1s between others debugLog('[BUG REPORT] Waiting ${delayMs}ms before upload...'); await Future.delayed(Duration(milliseconds: delayMs)); } @@ -188,16 +191,20 @@ class DebugSubmitService { if (success) { uploadedCount++; debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: SUCCESS'); - reportProgress('Uploaded $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress('Uploaded $filename', fileProgress + progressPerFile, + currentFile: i + 1); } else { failedCount++; debugError('[BUG REPORT] File ${i + 1}/$totalFiles: FAILED'); - reportProgress('Failed to upload $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress( + 'Failed to upload $filename', fileProgress + progressPerFile, + currentFile: i + 1); } } debugLog('[BUG REPORT] ----------------------------------------'); - debugLog('[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); + debugLog( + '[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); } reportProgress('Finalizing...', 0.95); @@ -205,7 +212,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] ========================================'); debugLog('[BUG REPORT] Bug report submission complete'); debugLog('[BUG REPORT] Issue: #$issueNumber'); - debugLog('[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); + debugLog( + '[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); debugLog('[BUG REPORT] ========================================'); reportProgress('Complete!', 1.0); @@ -250,13 +258,15 @@ class DebugSubmitService { } // File was split into chunks - debugLog('[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); + debugLog( + '[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); bool allSucceeded = true; try { for (int i = 0; i < chunks.length; i++) { final chunkName = chunks[i].path.split('/').last; - debugLog('[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); + debugLog( + '[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); if (i > 0) { // Delay between chunk uploads @@ -274,11 +284,13 @@ class DebugSubmitService { ); if (!success) { - debugError('[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); + debugError( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); allSucceeded = false; break; } - debugLog('[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); + debugLog( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); } } finally { // Always clean up temp chunk files @@ -306,7 +318,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(file); final fileSize = await file.length(); final fileSizeKb = (fileSize / 1024).toStringAsFixed(1); - debugLog('[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL debugLog('[BUG REPORT] Step 2/4: Requesting upload URL...'); @@ -320,10 +333,12 @@ class DebugSubmitService { ); if (session == null) { - debugError('[BUG REPORT] FAILED: Could not get upload URL for: $filename'); + debugError( + '[BUG REPORT] FAILED: Could not get upload URL for: $filename'); return false; } - debugLog('[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); + debugLog( + '[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); // Step 3: Upload the file (with retry logic) debugLog('[BUG REPORT] Step 3/4: Uploading file data...'); @@ -343,19 +358,22 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; // 2s, 4s backoff - debugWarn('[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); + debugError( + '[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); return false; } // Step 4: Complete the upload with GitHub issue reference debugLog('[BUG REPORT] Step 4/4: Confirming upload...'); - final userNotes = issueNumber != null ? 'GitHub Issue: $issueNumber' : null; + final userNotes = + issueNumber != null ? 'GitHub Issue: $issueNumber' : null; if (userNotes != null) { debugLog('[BUG REPORT] User notes: $userNotes'); } @@ -369,8 +387,10 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); - debugWarn('[BUG REPORT] File was uploaded but confirmation failed - treating as success'); + debugWarn( + '[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); + debugWarn( + '[BUG REPORT] File was uploaded but confirmation failed - treating as success'); } else { debugLog('[BUG REPORT] SUCCESS: Upload confirmed'); } @@ -407,20 +427,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] body: ${body.length} chars'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { debugError('[BUG REPORT] HTTP error: ${response.statusCode}'); debugError('[BUG REPORT] Response body: ${response.body}'); - return {'success': false, 'message': 'Server error: ${response.statusCode}'}; + return { + 'success': false, + 'message': 'Server error: ${response.statusCode}' + }; } final data = json.decode(response.body) as Map; @@ -466,21 +492,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] POST $url'); debugLog('[BUG REPORT] Request payload:'); debugLog('[BUG REPORT] device_id: $deviceId'); - debugLog('[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); - debugLog('[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); + debugLog( + '[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); + debugLog( + '[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); debugLog('[BUG REPORT] file_hash: ${fileHash.substring(0, 16)}...'); debugLog('[BUG REPORT] app_version: $appVersion'); debugLog('[BUG REPORT] platform: $platform'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -492,7 +523,8 @@ class DebugSubmitService { final data = json.decode(response.body) as Map; debugLog('[BUG REPORT] Response JSON:'); debugLog('[BUG REPORT] session_id: ${data['session_id']}'); - debugLog('[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); + debugLog( + '[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); debugLog('[BUG REPORT] expires_at: ${data['expires_at']}'); if (data['upload_url'] == null || data['session_id'] == null) { @@ -532,15 +564,19 @@ class DebugSubmitService { )); final stopwatch = Stopwatch()..start(); - final streamedResponse = await request.send().timeout(const Duration(seconds: 120)); + final streamedResponse = + await request.send().timeout(const Duration(seconds: 120)); final response = await http.Response.fromStream(streamedResponse); stopwatch.stop(); - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); final speedKbps = fileSize > 0 - ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)).toStringAsFixed(1) + ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)) + .toStringAsFixed(1) : '0'; - debugLog('[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); + debugLog( + '[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -556,7 +592,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] message: ${data['message']}'); } if (data['stored_hash'] != null) { - debugLog('[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); + debugLog( + '[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); } final success = data['success'] == true; @@ -598,14 +635,17 @@ class DebugSubmitService { } final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -658,7 +698,8 @@ class DebugSubmitService { if (isChunked) { final fileSize = await file.length(); - debugLog('[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); + debugLog( + '[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); } // Progress range: 0.1 to 0.9 divided across chunks @@ -676,9 +717,11 @@ class DebugSubmitService { } void reportChunkProgress(String status, double chunkProgress) { - final overallProgress = chunkBase + (chunkProgress * progressPerChunk); + final overallProgress = + chunkBase + (chunkProgress * progressPerChunk); onProgress?.call(BugReportProgress( - status: isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, + status: + isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, progress: overallProgress.clamp(0.0, 1.0), currentFile: isChunked ? i + 1 : 1, totalFiles: isChunked ? totalChunks : 1, @@ -697,7 +740,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(chunk); final chunkSize = await chunk.length(); final chunkSizeKb = (chunkSize / 1024).toStringAsFixed(1); - debugLog('[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL reportChunkProgress('Requesting upload...', 0.2); @@ -712,7 +756,8 @@ class DebugSubmitService { ); if (session == null) { - debugError('[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); allSucceeded = false; break; } @@ -737,13 +782,15 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; - debugWarn('[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); allSucceeded = false; break; } @@ -760,7 +807,8 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[DEBUG UPLOAD] Confirmation failed but file was uploaded'); + debugWarn( + '[DEBUG UPLOAD] Confirmation failed but file was uploaded'); } debugLog('[DEBUG UPLOAD] Chunk ${i + 1}/$totalChunks complete'); @@ -781,7 +829,8 @@ class DebugSubmitService { totalFiles: totalChunks, )); debugLog('[DEBUG UPLOAD] ========================================'); - debugLog('[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); + debugLog( + '[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); debugLog('[DEBUG UPLOAD] ========================================'); } else { debugLog('[DEBUG UPLOAD] ========================================'); diff --git a/lib/services/device_model_service.dart b/lib/services/device_model_service.dart index c9aecb2a..6e9f9aac 100644 --- a/lib/services/device_model_service.dart +++ b/lib/services/device_model_service.dart @@ -6,7 +6,7 @@ import '../models/device_model.dart'; /// Device model service for auto-power selection /// Ported from parseDeviceModel() and autoSetPowerLevel() in wardrive.js -/// +/// /// CRITICAL: Correct power configuration is essential for PA amplifier models /// to prevent hardware damage. class DeviceModelService { @@ -24,9 +24,10 @@ class DeviceModelService { if (_isLoaded) return; try { - final jsonString = await rootBundle.loadString('assets/device-models.json'); + final jsonString = + await rootBundle.loadString('assets/device-models.json'); final jsonData = json.decode(jsonString) as Map; - + final database = DeviceModelsDatabase.fromJson(jsonData); _models = database.devices; _isLoaded = true; @@ -39,7 +40,7 @@ class DeviceModelService { /// Match device manufacturer string to known model /// Reference: parseDeviceModel() in wardrive.js - /// + /// /// Strips build suffix (e.g., "nightly-e31c46f") and matches against database DeviceModel? matchDevice(String manufacturerString) { if (_models.isEmpty) return null; @@ -68,7 +69,7 @@ class DeviceModelService { final parts = cleanManufacturer.split(RegExp(r'[\s\-_()]+')); for (final model in _models) { final modelParts = model.manufacturer.split(RegExp(r'[\s\-_()]+')); - + // Check if key identifying parts match int matchCount = 0; for (final modelPart in modelParts) { @@ -76,7 +77,7 @@ class DeviceModelService { matchCount++; } } - + // Require at least 2 matching parts if (matchCount >= 2) { return model; diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 6eced5cd..43f951a1 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -15,15 +15,15 @@ import 'gps_simulator_service.dart'; class GpsService { /// Minimum distance (meters) from last ping before allowing new ping static const double minDistanceMeters = 25.0; - + /// Maximum GPS age for manual pings (60 seconds) /// Reference: GPS_WATCH_MAX_AGE_MS in wardrive.js static const Duration maxGpsAgeForManualPing = Duration(seconds: 60); - + /// Maximum GPS accuracy threshold for pings (100 meters) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js docs static const double maxAccuracyMetersForPing = 100.0; - + /// Maximum GPS accuracy threshold for zone checks (50 meters) /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; @@ -36,8 +36,10 @@ class GpsService { /// Set the minimum ping distance (clamped to 25m floor) void setMinPingDistance(double meters) { - _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; - debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + _configuredMinDistance = + meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog( + '[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); } final _statusController = StreamController.broadcast(); @@ -105,7 +107,8 @@ class GpsService { } if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); _updateStatus(GpsStatus.permissionDenied); return false; } @@ -143,7 +146,8 @@ class GpsService { // If denied forever, can't request again - user must go to settings if (current == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); return false; } @@ -176,7 +180,8 @@ class GpsService { // Ensure only one active position stream subscription exists. // startWatching() can be called multiple times (e.g. after permission flow). if (_positionSubscription != null) { - debugLog('[GPS] Existing position subscription found, restarting watcher'); + debugLog( + '[GPS] Existing position subscription found, restarting watcher'); await _positionSubscription?.cancel(); _positionSubscription = null; } @@ -185,7 +190,8 @@ class GpsService { final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); if (!serviceEnabled) { - debugLog('[GPS] Location services DISABLED at system level - user must enable in Settings'); + debugLog( + '[GPS] Location services DISABLED at system level - user must enable in Settings'); _updateStatus(GpsStatus.disabled); return; } @@ -199,18 +205,22 @@ class GpsService { final permission = await Geolocator.checkPermission(); final hasPermission = permission == LocationPermission.always || permission == LocationPermission.whileInUse; - debugLog('[GPS] Permission check: $permission (hasPermission=$hasPermission)'); + debugLog( + '[GPS] Permission check: $permission (hasPermission=$hasPermission)'); if (!hasPermission) { if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); } else { - debugLog('[GPS] Permission not granted - waiting for disclosure flow'); + debugLog( + '[GPS] Permission not granted - waiting for disclosure flow'); } _updateStatus(GpsStatus.permissionDenied); return; } } else { - debugLog('[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); + debugLog( + '[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); } debugLog('[GPS] Starting position stream listener...'); @@ -228,11 +238,13 @@ class GpsService { _positionSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, - distanceFilter: 10, // Trigger every 10m movement (check RX batches at 25m) + distanceFilter: + 10, // Trigger every 10m movement (check RX batches at 25m) ), ).listen( (position) { - debugLog('[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' 'lon=${position.longitude.toStringAsFixed(5)}, accuracy=${position.accuracy.toStringAsFixed(1)}m'); _lastPosition = position; _positionController.add(position); @@ -253,7 +265,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); - debugLog('[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; // Note: Don't emit via _positionController here — the stream listener @@ -261,7 +274,8 @@ class GpsService { // would cause duplicate position events (~0.15ms apart). _updateStatus(GpsStatus.locked); } catch (e) { - debugLog('[GPS] Initial position request failed: $e (will wait for stream updates)'); + debugLog( + '[GPS] Initial position request failed: $e (will wait for stream updates)'); // Will receive updates from stream } } @@ -303,19 +317,19 @@ class GpsService { final age = DateTime.now().difference(position.timestamp); return age <= maxGpsAgeForManualPing; } - + /// Check if GPS position has acceptable accuracy for pings (< 100m) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js bool isAccuracyAcceptableForPing(Position position) { return position.accuracy <= maxAccuracyMetersForPing; } - + /// Check if GPS position has acceptable accuracy for zone checks (< 50m) /// Reference: getValidGpsForZoneCheck() in wardrive.js bool isAccuracyAcceptableForZoneCheck(Position position) { return position.accuracy <= maxAccuracyMetersForZoneCheck; } - + /// Validate position for ping operation /// Checks freshness (< 60s old) and accuracy (< 100m) /// Returns null if valid, error message if invalid @@ -326,17 +340,17 @@ class GpsService { debugWarn('[GPS] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy if (!isAccuracyAcceptableForPing(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] Position too inaccurate: ${accuracy}m (max 100m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } - + /// Validate position for zone check operation /// Checks freshness (< 60s old) and accuracy (< 50m, stricter than ping) /// Returns null if valid, error message if invalid @@ -347,21 +361,22 @@ class GpsService { debugWarn('[GPS] [AUTH] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy (stricter for zone checks) if (!isAccuracyAcceptableForZoneCheck(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] [AUTH] Position too inaccurate: ${accuracy}m (max 50m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } /// Request a fresh GPS position from the hardware for auto-ping accuracy. /// On mobile, this forces a warm-start GPS read (typically < 1 second when /// GPS is already streaming). Falls back to lastPosition on timeout/error. - Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) async { // Simulator provides its own positions — use cached if (_simulatorEnabled) { return _lastPosition; @@ -372,7 +387,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: timeout, ); - debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; return position; diff --git a/lib/services/gps_simulator_service.dart b/lib/services/gps_simulator_service.dart index 92185b81..0c1b4499 100644 --- a/lib/services/gps_simulator_service.dart +++ b/lib/services/gps_simulator_service.dart @@ -10,10 +10,13 @@ import '../utils/debug_logger_io.dart'; enum SimulatorPattern { /// Move in a straight line in the configured direction straight, + /// Move in a circle around the start point circle, + /// Random walk with smooth direction changes randomWalk, + /// Follow a loaded route (KML/GPX) route, } @@ -143,7 +146,8 @@ class GpsSimulatorService { _circleAngle = 0; } - debugLog('[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); + debugLog( + '[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); } /// Start the simulator @@ -151,7 +155,8 @@ class GpsSimulatorService { if (_isRunning) return; _isRunning = true; - debugLog('[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); + debugLog( + '[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); // Emit initial position immediately _emitPosition(); @@ -184,7 +189,8 @@ class GpsSimulatorService { _targetHeading = 45; _routeIndex = 0; _routeProgress = 0; - debugLog('[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); + debugLog( + '[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); } /// Load route from KML file content @@ -236,7 +242,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing KML: $e'); @@ -263,10 +270,12 @@ class GpsSimulatorService { final lat = double.tryParse(pt.getAttribute('lat') ?? ''); final lon = double.tryParse(pt.getAttribute('lon') ?? ''); final eleElement = pt.findElements('ele').firstOrNull; - final alt = eleElement != null ? double.tryParse(eleElement.innerText) : null; + final alt = + eleElement != null ? double.tryParse(eleElement.innerText) : null; if (lat != null && lon != null) { - coordinates.add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); + coordinates + .add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); } } @@ -309,7 +318,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing GPX: $e'); @@ -320,18 +330,30 @@ class GpsSimulatorService { /// Extract route name from GPX document String _extractGpxName(XmlDocument document) { // Try track name first - final trkName = document.findAllElements('trk').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final trkName = document + .findAllElements('trk') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (trkName != null) return trkName; // Try route name - final rteName = document.findAllElements('rte').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final rteName = document + .findAllElements('rte') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (rteName != null) return rteName; // Try metadata name - final metaName = document.findAllElements('metadata').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final metaName = document + .findAllElements('metadata') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (metaName != null) return metaName; return 'Unnamed Route'; @@ -412,8 +434,10 @@ class GpsSimulatorService { // Calculate distance between current and next point final segmentDistanceM = _haversineDistance( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); if (segmentDistanceM < 1) { @@ -461,24 +485,31 @@ class GpsSimulatorService { final nextPoint = _routePoints[nextIndex]; final t = _routeProgress.clamp(0.0, 1.0); - _latitude = currentPoint.latitude + (nextPoint.latitude - currentPoint.latitude) * t; - _longitude = currentPoint.longitude + (nextPoint.longitude - currentPoint.longitude) * t; + _latitude = currentPoint.latitude + + (nextPoint.latitude - currentPoint.latitude) * t; + _longitude = currentPoint.longitude + + (nextPoint.longitude - currentPoint.longitude) * t; // Calculate heading towards next point _heading = _calculateBearing( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); } /// Haversine distance between two points in meters - double _haversineDistance(double lat1, double lon1, double lat2, double lon2) { + double _haversineDistance( + double lat1, double lon1, double lat2, double lon2) { const R = 6371000.0; // Earth radius in meters final dLat = (lat2 - lat1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180; final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * - sin(dLon / 2) * sin(dLon / 2); + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); final c = 2 * atan2(sqrt(a), sqrt(1 - a)); return R * c; } @@ -490,8 +521,8 @@ class GpsSimulatorService { final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; // Normalize to 0-360 } @@ -509,7 +540,8 @@ class GpsSimulatorService { // 1 degree latitude ≈ 111 km // 1 degree longitude ≈ 111 km * cos(latitude) final latChange = (distanceKm / 111) * cos(headingRad); - final lonChange = (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); + final lonChange = + (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); _latitude += latChange; _longitude += lonChange; @@ -530,7 +562,8 @@ class GpsSimulatorService { // Calculate position on circle final angleRad = _circleAngle * pi / 180; _latitude = _circleCenterLat + _circleRadius * cos(angleRad); - _longitude = _circleCenterLon + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); + _longitude = _circleCenterLon + + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); // Update heading to be tangent to circle _heading = (_circleAngle + 90) % 360; diff --git a/lib/services/meshcore/buffer_utils.dart b/lib/services/meshcore/buffer_utils.dart index 57345363..d7f80362 100644 --- a/lib/services/meshcore/buffer_utils.dart +++ b/lib/services/meshcore/buffer_utils.dart @@ -106,7 +106,6 @@ class BufferReader { } return value; } - } /// Buffer writer for creating binary data for MeshCore devices @@ -155,16 +154,16 @@ class BufferWriter { void writeCString(String string, int maxLength) { final encoded = utf8.encode(string); final bytes = Uint8List(maxLength); - + // Copy string bytes up to maxLength - 1 final copyLength = math.min(encoded.length, maxLength - 1); for (int i = 0; i < copyLength; i++) { bytes[i] = encoded[i]; } - + // Ensure last byte is null terminator bytes[maxLength - 1] = 0; - + writeBytes(bytes); } diff --git a/lib/services/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index 44873978..d92573c9 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -38,13 +38,17 @@ class ChannelService { // Always add #wardriving (required for TX) final wardrivingKey = CryptoService.getChannelKey(wardrivingChannelName); final wardrivingHash = CryptoService.computeChannelHash(wardrivingKey); - _allowedChannels[wardrivingChannelName] = _ChannelData(key: wardrivingKey, hash: wardrivingHash); + _allowedChannels[wardrivingChannelName] = + _ChannelData(key: wardrivingKey, hash: wardrivingHash); debugLog('[CHANNEL] Added: $wardrivingChannelName -> hash=$wardrivingHash'); // Add regional channels from API for (final name in channelNames) { - final channelName = name.toLowerCase() == 'public' ? 'Public' : - name.startsWith('#') ? name : '#$name'; + final channelName = name.toLowerCase() == 'public' + ? 'Public' + : name.startsWith('#') + ? name + : '#$name'; // Skip if already added if (_allowedChannels.containsKey(channelName)) continue; @@ -95,7 +99,8 @@ class ChannelService { /// Get all allowed channels for RX validation /// Returns a map of channel hash -> channel info for use with PacketValidator - static Map getAllowedChannelsForValidator() { + static Map + getAllowedChannelsForValidator() { final result = {}; for (final entry in _allowedChannels.entries) { result[entry.value.hash] = ( @@ -114,7 +119,8 @@ class ChannelService { /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the created channel /// @throws Exception if no empty slots or creation fails - static Future createWardrivingChannel(MeshCoreConnection connection) async { + static Future createWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Attempting to create channel: $wardrivingChannelName'); // Get all channels @@ -143,9 +149,11 @@ class ChannelService { final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); // Create the channel - debugLog('[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); + debugLog( + '[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); await connection.setChannel(emptyIdx, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); // Return channel info return ChannelInfo( @@ -161,7 +169,8 @@ class ChannelService { /// /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the wardriving channel - static Future ensureWardrivingChannel(MeshCoreConnection connection) async { + static Future ensureWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Looking up channel: $wardrivingChannelName'); // Scan ALL channels to find #wardriving or first empty slot @@ -179,7 +188,8 @@ class ChannelService { try { channel = await connection.getChannel(channelIdx); } catch (e) { - debugLog('[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); + debugLog( + '[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); await Future.delayed(const Duration(milliseconds: 100)); channel = await connection.getChannel(channelIdx); } @@ -189,7 +199,8 @@ class ChannelService { // Found existing #wardriving channel - return immediately! if (channel.name == wardrivingChannelName) { - debugLog('[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); + debugLog( + '[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); return channel; } @@ -211,16 +222,20 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { - debugError('[CHANNEL] No empty channel slots found in first $channelIdx channels'); + debugError( + '[CHANNEL] No empty channel slots found in first $channelIdx channels'); throw Exception( 'No empty channel slots available. Please free a channel slot on your companion first.', ); } - debugLog('[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); + debugLog( + '[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); - await connection.setChannel(firstEmptySlot, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); + await connection.setChannel( + firstEmptySlot, wardrivingChannelName, channelKey); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); return ChannelInfo( channelIndex: firstEmptySlot, @@ -230,7 +245,7 @@ class ChannelService { } /// Delete #wardriving channel on disconnect - /// + /// /// @param connection - Active MeshCore connection /// @param channelIdx - Index of the channel to delete static Future deleteWardrivingChannel( diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 66bc30c7..1ed79776 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,8 +17,10 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 - final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) - final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) + final String? + firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? + pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, @@ -47,7 +49,10 @@ class SelfInfo { }); /// Get public key as hex string - String get publicKeyHex => publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + String get publicKeyHex => publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// MeshCore connection manager @@ -67,10 +72,13 @@ class MeshCoreConnection { final BluetoothService _bluetooth; bool _disposed = false; final _stepController = StreamController.broadcast(); - final _channelMessageController = StreamController.broadcast(); + final _channelMessageController = + StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); - final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); - final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _logRxDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _controlDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -108,7 +116,8 @@ class MeshCoreConnection { int? _lastBatteryMilliVolts; // millivolts or null if not supported Timer? _batteryTimer; - MeshCoreConnection({required BluetoothService bluetooth}) : _bluetooth = bluetooth { + MeshCoreConnection({required BluetoothService bluetooth}) + : _bluetooth = bluetooth { _dataSubscription = _bluetooth.dataStream.listen(_onFrameReceived); } @@ -116,16 +125,19 @@ class MeshCoreConnection { Stream get stepStream => _stepController.stream; /// Stream of channel messages (for RX pings) - Stream get channelMessageStream => _channelMessageController.stream; + Stream get channelMessageStream => + _channelMessageController.stream; /// Stream of raw data pushes Stream> get rawDataStream => _rawDataController.stream; /// Stream of LogRxData packets (for unified RX handler) - Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => _logRxDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => + _logRxDataController.stream; /// Stream of ControlData packets (for discovery responses) - Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + _controlDataController.stream; /// Stream of TraceData packets (for trace path responses) /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly @@ -173,13 +185,16 @@ class MeshCoreConnection { /// Wardriving channel hash (for echo correlation) - null if not connected int? get wardrivingChannelHash { final channel = _wardrivingChannel; - return channel != null ? CryptoService.computeChannelHash(channel.secret) : null; + return channel != null + ? CryptoService.computeChannelHash(channel.secret) + : null; } void _updateStep(ConnectionStep step) { _currentStep = step; if (_disposed || _stepController.isClosed) { - debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); + debugLog( + '[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -189,7 +204,8 @@ class MeshCoreConnection { /// Execute the full connection workflow /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info - Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect( + String deviceId, List deviceModels) async { if (_disposed) { throw Exception('Connection instance has been disposed'); } @@ -206,7 +222,8 @@ class MeshCoreConnection { // Step 3: Device Query _updateStep(ConnectionStep.deviceQuery); - _deviceInfo = await deviceQuery(ProtocolConstants.supportedCompanionProtocolVersion); + _deviceInfo = await deviceQuery( + ProtocolConstants.supportedCompanionProtocolVersion); // Step 3b: Get Self Info (contains public key) // This is critical for geo-auth API authentication @@ -216,7 +233,8 @@ class MeshCoreConnection { if (pubKeyHex == null) { throw Exception('getSelfInfo() returned null public key'); } - debugLog('[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); } catch (e) { debugError('[CONN] Failed to get self info (public key): $e'); // Public key is REQUIRED for geo-auth API @@ -232,9 +250,11 @@ class MeshCoreConnection { final matchedModel = _deviceModel; if (matchedModel != null) { deviceModelMatched = true; - debugLog('[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); + debugLog( + '[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); } else { - debugLog('[CONN] Device model not recognized - user must manually select power level for reporting'); + debugLog( + '[CONN] Device model not recognized - user must manually select power level for reporting'); } // Step 5: Time Sync @@ -249,20 +269,24 @@ class MeshCoreConnection { if (authResult == null || authResult['success'] != true) { final reason = authResult?['reason'] ?? 'unknown'; final message = authResult?['message'] ?? 'Authentication failed'; - debugError('[CONN] API session acquisition failed: $reason - $message'); + debugError( + '[CONN] API session acquisition failed: $reason - $message'); // Throw with reason code prefix for proper error handling throw Exception('AUTH_FAILED:$reason:$message'); } - debugLog('[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); + debugLog( + '[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); } else { - debugLog('[CONN] No auth callback set, skipping API session acquisition'); + debugLog( + '[CONN] No auth callback set, skipping API session acquisition'); } // Step 7: Channel Setup _updateStep(ConnectionStep.channelSetup); debugLog('[CONN] Creating #wardriving channel'); _wardrivingChannel = await ChannelService.ensureWardrivingChannel(this); - debugLog('[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); + debugLog( + '[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); // Step 8: GPS Init (handled externally) _updateStep(ConnectionStep.gpsInit); @@ -282,7 +306,10 @@ class MeshCoreConnection { // This may fail on older firmware (< v1.11.0) _startNoiseFloorPolling(); - return (deviceModel: _deviceModel, deviceModelMatched: deviceModelMatched); + return ( + deviceModel: _deviceModel, + deviceModelMatched: deviceModelMatched + ); } catch (e) { debugError('[CONN] Connection failed: $e'); _updateStep(ConnectionStep.error); @@ -338,24 +365,25 @@ class MeshCoreConnection { /// Match manufacturer string to device model /// Reference: parseDeviceModel() in wardrive.js - DeviceModel? _matchDeviceModel(String manufacturer, List models) { + DeviceModel? _matchDeviceModel( + String manufacturer, List models) { // Strip build suffix (e.g., "nightly-e31c46f") final cleanManufacturer = manufacturer.split(' ').first; - + for (final model in models) { if (manufacturer.contains(model.manufacturer) || cleanManufacturer.contains(model.manufacturer)) { return model; } } - + // Try partial match on short name for (final model in models) { if (manufacturer.toLowerCase().contains(model.shortName.toLowerCase())) { return model; } } - + return null; } @@ -364,12 +392,14 @@ class MeshCoreConnection { if (frame.isEmpty) return; try { - debugLog('[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); - + debugLog( + '[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); + final reader = BufferReader(frame); final responseCode = reader.readByte(); - - debugLog('[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); + + debugLog( + '[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); switch (responseCode) { case ResponseCodes.ok: @@ -378,14 +408,17 @@ class MeshCoreConnection { _setTimeCompleter = null; break; case ResponseCodes.err: - final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; + final errorCode = + reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success if (_setTimeCompleter != null) { if (errorCode == 6) { - debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + debugLog( + '[CONN] Time sync not needed (error code 6) - treating as success'); } else { - debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + debugWarn( + '[CONN] Time sync error (code $errorCode) - continuing anyway'); } _setTimeCompleter?.complete(); _setTimeCompleter = null; @@ -440,7 +473,8 @@ class MeshCoreConnection { break; default: // Log unhandled response codes (like JS implementation) - debugLog('[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); + debugLog( + '[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); break; } } catch (e, stack) { @@ -490,7 +524,8 @@ class MeshCoreConnection { // path_hash_mode: 1 byte (v10+) if (reader.remainingBytesCount >= 1) { pathHashMode = reader.readByte(); - debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + debugLog( + '[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); } } @@ -513,12 +548,12 @@ class MeshCoreConnection { reader.readBytes(32); // skip public key debugLog('[CONN] Manufacturer: $manufacturer'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturer, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } @@ -539,14 +574,14 @@ class MeshCoreConnection { // Skip additional fields added in newer firmware versions // These fields exist between publicKey and name if (reader.remainingBytesCount >= 22) { - reader.readInt32LE(); // advLat - reader.readInt32LE(); // advLon - reader.readBytes(3); // reserved - reader.readByte(); // manualAddContacts + reader.readInt32LE(); // advLat + reader.readInt32LE(); // advLon + reader.readBytes(3); // reserved + reader.readByte(); // manualAddContacts reader.readUInt32LE(); // radioFreq reader.readUInt32LE(); // radioBw - reader.readByte(); // radioSf - reader.readByte(); // radioCr + reader.readByte(); // radioSf + reader.readByte(); // radioCr } // Read name from remaining bytes @@ -561,7 +596,8 @@ class MeshCoreConnection { ); _selfInfo = selfInfo; - debugLog('[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; @@ -697,7 +733,8 @@ class MeshCoreConnection { // Consume any remaining bytes (firmware may send extended format) if (reader.remainingBytesCount > 0) { final extraBytes = reader.readRemainingBytes(); - debugLog('[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); + debugLog( + '[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); } _batteryController.add(percent); // Emit percentage to stream @@ -719,10 +756,13 @@ class MeshCoreConnection { void _onExportContactResponse(BufferReader reader) { try { final advertPacketBytes = reader.readRemainingBytes(); - final hexString = advertPacketBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + final hexString = advertPacketBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); final contactUri = 'meshcore://$hexString'; - debugLog('[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); + debugLog( + '[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); _exportContactCompleter?.complete(contactUri); _exportContactCompleter = null; @@ -755,7 +795,8 @@ class MeshCoreConnection { /// Get device self info (includes public key) /// Reference: getSelfInfo() in connection.js - Future getSelfInfo({Duration timeout = const Duration(seconds: 5)}) async { + Future getSelfInfo( + {Duration timeout = const Duration(seconds: 5)}) async { _selfInfoCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -845,10 +886,11 @@ class MeshCoreConnection { final future = _channelInfoCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.getChannel); // 31 (0x1F) + data.writeByte(CommandCodes.getChannel); // 31 (0x1F) data.writeByte(channelIdx); final bytes = data.toBytes(); - debugLog('[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); + debugLog( + '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); await _bluetooth.write(bytes); return future.timeout( @@ -926,7 +968,8 @@ class MeshCoreConnection { Future findChannelBySecret(Uint8List secret) async { final channels = await getChannels(); try { - return channels.firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); + return channels + .firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); } catch (e) { return null; // Not found } @@ -943,7 +986,8 @@ class MeshCoreConnection { /// Send channel text message (for TX pings) /// Reference: sendCommandSendChannelTxtMsg in connection.js - Future sendChannelTextMessage(int txtType, int channelIdx, int senderTimestamp, String text) async { + Future sendChannelTextMessage( + int txtType, int channelIdx, int senderTimestamp, String text) async { _sentCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -982,7 +1026,8 @@ class MeshCoreConnection { debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await sendChannelTextMessage(TxtTypes.plain, channel.channelIndex, timestamp, message); + await sendChannelTextMessage( + TxtTypes.plain, channel.channelIndex, timestamp, message); } /// Send discovery request to find nearby repeaters/rooms @@ -1010,11 +1055,12 @@ class MeshCoreConnection { '${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendControlData); // 0x37 - data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ - data.writeByte(DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM - data.writeBytes(tag); // 4-byte random tag - data.writeUInt32LE(0); // timestamp = 0 (discover all) + data.writeByte(CommandCodes.sendControlData); // 0x37 + data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ + data.writeByte( + DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM + data.writeBytes(tag); // 4-byte random tag + data.writeUInt32LE(0); // timestamp = 0 (discover all) await _sendToRadio(data); return tag; @@ -1023,31 +1069,41 @@ class MeshCoreConnection { /// Send trace path to a specific repeater (targeted ping / zero-hop trace) /// Returns the 4-byte tag used for matching the response /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) - Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) async { final random = Random.secure(); final tag = Uint8List.fromList([ - random.nextInt(256), random.nextInt(256), - random.nextInt(256), random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), ]); // Trace uses bitshift encoding: actual_bytes = 1 << path_sz // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 final int pathSz; switch (hopBytes) { - case 4: pathSz = 2; break; - case 2: pathSz = 1; break; - default: pathSz = 0; break; + case 4: + pathSz = 2; + break; + case 2: + pathSz = 1; + break; + default: + pathSz = 0; + break; } final int flags = pathSz & 0x03; - debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + debugLog( + '[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendTracePath); // 0x24 - data.writeBytes(tag); // 4-byte tag - data.writeUInt32LE(0); // auth_code = 0 - data.writeByte(flags); // flags with path_sz in bits 0-1 - data.writeBytes(repeaterIdBytes); // target repeater ID + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID await _sendToRadio(data); return tag; } @@ -1061,12 +1117,13 @@ class MeshCoreConnection { /// Export signed contact URI for API authentication /// Returns meshcore:// URI containing signed ADVERT packet - Future exportContact({Duration timeout = const Duration(seconds: 5)}) async { + Future exportContact( + {Duration timeout = const Duration(seconds: 5)}) async { _exportContactCompleter = Completer(); final future = _exportContactCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.exportContact); // 0x11 + data.writeByte(CommandCodes.exportContact); // 0x11 await _sendToRadio(data); return future.timeout( @@ -1129,7 +1186,8 @@ class MeshCoreConnection { _noiseFloorFailCount++; debugLog('[CONN] Noise floor fetch failed ($_noiseFloorFailCount/3): $e'); if (_noiseFloorFailCount >= 3) { - debugLog('[CONN] Noise floor polling stopped after 3 consecutive failures'); + debugLog( + '[CONN] Noise floor polling stopped after 3 consecutive failures'); _stopNoiseFloorPolling(); } } finally { diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index 30da8862..ea6f559b 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -12,28 +12,43 @@ class CryptoService { /// Fixed key for "Public" channel (non-hashtag channels) /// From MeshCore default: 8b3387e9c5cdea6ac9e5edbaa115cd72 static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a channel name using SHA-256 - /// + /// /// Matches JS implementation: `sha256(channelName).subarray(0, 16)` - /// + /// /// @param channelName - Channel name (must start with # for hashtag channels) /// @returns 16-byte channel key /// @throws FormatException if channel name is invalid static Uint8List deriveChannelKey(String channelName) { debugLog('[CRYPTO] Deriving channel key for: $channelName'); - + // Validate channel name format: must start with # and contain only letters, numbers, and dashes if (!channelName.startsWith('#')) { - throw FormatException('Channel name must start with # (got: "$channelName")'); + throw FormatException( + 'Channel name must start with # (got: "$channelName")'); } - + // Normalize channel name to lowercase (MeshCore convention) final normalizedName = channelName.toLowerCase(); - + // Check that the part after # contains only letters, numbers, and dashes final nameWithoutHash = normalizedName.substring(1); if (!RegExp(r'^[a-z0-9-]+$').hasMatch(nameWithoutHash)) { @@ -42,16 +57,17 @@ class CryptoService { 'Only letters, numbers, and dashes are allowed.', ); } - + // Hash using SHA-256 final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); - + // Take the first 16 bytes of the hash as the channel key final channelKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - - debugLog('[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); - + + debugLog( + '[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); + return channelKey; } @@ -65,12 +81,13 @@ class CryptoService { final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + debugLog( + '[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); return scopeKey; } /// Get channel key for any channel (handles both Public and hashtag channels) - /// + /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") /// @returns 16-byte channel key static Uint8List getChannelKey(String channelName) { @@ -83,9 +100,9 @@ class CryptoService { } /// Compute channel hash from channel secret (first byte of SHA-256) - /// + /// /// Used for identifying echo packets that match our channel - /// + /// /// @param channelSecret - The 16-byte channel secret /// @returns Channel hash (first byte of SHA-256) static int computeChannelHash(Uint8List channelSecret) { @@ -94,9 +111,9 @@ class CryptoService { } /// Decrypt channel message using AES-ECB mode - /// + /// /// MeshCore uses AES-128-ECB for channel message encryption - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decrypted message bytes @@ -105,17 +122,18 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Decrypting message (${encryptedPayload.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(false, params); // false = decrypt mode - + // Decrypt the payload final decrypted = Uint8List(encryptedPayload.length); var offset = 0; @@ -137,7 +155,7 @@ class CryptoService { } /// Encrypt channel message using AES-ECB mode - /// + /// /// @param plaintext - The message bytes to encrypt /// @param channelKey - The 16-byte channel key /// @returns Encrypted message bytes @@ -146,29 +164,30 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Encrypting message (${plaintext.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Add PKCS7 padding final padded = _addPkcs7Padding(plaintext, 16); - + // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(true, params); // true = encrypt mode - + // Encrypt the payload final encrypted = Uint8List(padded.length); var offset = 0; - + while (offset < padded.length) { cipher.processBlock(padded, offset, encrypted, offset); offset += cipher.blockSize; } - + debugLog('[CRYPTO] Encrypted successfully (${encrypted.length} bytes)'); return encrypted; } catch (e) { @@ -189,9 +208,9 @@ class CryptoService { } /// Parse channel message to extract text content - /// + /// /// Decrypts and decodes the message, returning the text if printable - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decoded text or null if not printable @@ -202,15 +221,17 @@ class CryptoService { try { final decrypted = decryptChannelMessage(encryptedPayload, channelKey); final text = utf8.decode(decrypted, allowMalformed: true); - + // Check if text is printable (contains mostly ASCII printable characters) - final printableCount = text.codeUnits.where((c) => c >= 32 && c <= 126).length; + final printableCount = + text.codeUnits.where((c) => c >= 32 && c <= 126).length; final printableRatio = printableCount / text.length; - + if (printableRatio > 0.8) { return text; } else { - debugWarn('[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); + debugWarn( + '[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); return null; } } catch (e) { diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 23dd9d60..688eac4d 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -34,7 +34,10 @@ class DiscTracker { /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. final int hopBytes; - DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); + DiscTracker( + {this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -48,7 +51,8 @@ class DiscTracker { Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); - debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; startTime = DateTime.now(); @@ -58,12 +62,14 @@ class DiscTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking and return collected nodes List stopTracking() { - debugLog('[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); + debugLog( + '[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); final result = nodes.values.toList(); @@ -116,14 +122,16 @@ class DiscTracker { // Check if this is a discovery response (upper nibble = 0x90) if (upperNibble != DiscoveryConstants.discoverRespFlag) { - debugLog('[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); return false; } // Check node type (lower nibble must be REPEATER=0x01 or ROOM=0x02) if (lowerNibble != DiscoveryConstants.nodeTypeRepeater && lowerNibble != DiscoveryConstants.nodeTypeRoom) { - debugLog('[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); + debugLog( + '[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); return false; } @@ -135,28 +143,36 @@ class DiscTracker { // Extract public key (bytes 7-38) final pubkey = rawBytes.sublist(7, 39); - final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + final pubkeyHex = pubkey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); // Get repeater ID (first N hex chars based on hopBytes setting) final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); + debugLog( + '[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // Check RSSI (carpeater failsafe) if (disableRssiFilter) { - debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[DISC] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(localRssi)) { - debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); return false; } - final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater + ? 'REPEATER' + : 'ROOM'; debugLog('[DISC] Received response from $repeaterId ($nodeType): ' 'localSnr=${localSnr.toStringAsFixed(2)}, remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' @@ -212,12 +228,14 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") - final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String + pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) DiscoveredNode({ required this.repeaterId, @@ -229,8 +247,10 @@ class DiscoveredNode { }); /// Get node type as display string - String get nodeTypeName => nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + String get nodeTypeName => + nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; /// Get short display label: "(R)" for REPEATER, "(RM)" for ROOM - String get nodeTypeLabel => nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; + String get nodeTypeLabel => + nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; } diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index 49e1f0a4..6c0f41ab 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -67,14 +67,17 @@ class PacketMetadata { final int rssi = data['lastRssi'] as int; // Dump raw packet for debugging - final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); + final rawHex = raw + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); + debugLog( + '[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 @@ -92,7 +95,8 @@ class PacketMetadata { final int pathHashCount = pathLenRaw & 63; final int pathByteLen = pathHashCount * pathHashSize; - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); // Path data starts after path length byte @@ -105,11 +109,13 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; if (payloadOffset > raw.length) { - throw RangeError('Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + throw RangeError( + 'Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); } final Uint8List encryptedPayload = raw.sublist(payloadOffset); - debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' @@ -155,19 +161,22 @@ class PacketMetadata { /// Check if packet is GROUP_TEXT (channel message, header 0x15) bool get isGroupText { // Extract payload type from header - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.grpTxt; } /// Check if packet is ADVERT (node advertisement, header 0x11) bool get isAdvert { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.advert; } /// Check if packet is TRACE (trace path response, header 0x26) bool get isTrace { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.trace; } @@ -195,12 +204,18 @@ class PacketMetadata { /// Convert N bytes to uppercase hex string String _bytesToHex(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// Static version for use in factory constructor static String _bytesToHexStatic(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } @override diff --git a/lib/services/meshcore/packet_parser.dart b/lib/services/meshcore/packet_parser.dart index 6dfee5b6..84842eea 100644 --- a/lib/services/meshcore/packet_parser.dart +++ b/lib/services/meshcore/packet_parser.dart @@ -253,12 +253,12 @@ class ChannelInfo { final channelIndex = reader.readByte(); final name = reader.readCString(32); final remainingBytes = reader.remainingBytesCount; - + // Protocol v8 uses 16-byte (128-bit) keys, v1 used 32-byte keys if (remainingBytes != 16 && remainingBytes != 32) { throw Exception('ChannelInfo has unexpected key length: $remainingBytes'); } - + return ChannelInfo( channelIndex: channelIndex, name: name, diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index e9cec944..0b6af86f 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -12,7 +12,7 @@ class PacketValidator { /// Packets stronger than this are likely from co-located repeaters /// Reference: MAX_RX_RSSI_THRESHOLD in wardrive.js static const int maxRssiThreshold = -30; - + /// Minimum printable character ratio (60%) /// Lowered from 90% to allow emojis and Unicode in messages /// Still filters out completely corrupted data @@ -24,33 +24,40 @@ class PacketValidator { /// When true, skip RSSI carpeater check (user setting) final bool disableRssiFilter; - PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); + PacketValidator( + {required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) - Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { + Future validate(PacketMetadata metadata, + {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' '); debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); - debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); - debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' + debugLog( + '[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); + debugLog( + '[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) if (skipRssiCheck) { debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); } else if (isCarpeater(metadata.rssi)) { - debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' + debugLog( + '[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); } else { - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); + debugLog( + '[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } // VALIDATION 2: Check packet type @@ -83,7 +90,8 @@ class PacketValidator { // Extract channel hash final channelHash = metadata.channelHash!; - debugLog('[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); // Check if channel is in allowed list final channelInfo = allowedChannels[channelHash]; @@ -109,7 +117,8 @@ class PacketValidator { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); + debugLog( + '[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); return ValidationResult.failed('decrypted too short'); } @@ -122,21 +131,24 @@ class PacketValidator { // Remove trailing nulls and trim plaintext = plaintext.replaceAll(RegExp(r'\x00+$'), '').trim(); } catch (e) { - debugLog('[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); + debugLog( + '[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); return ValidationResult.failed('decode failed'); } // Sanitize for logging: remove replacement characters to avoid Flutter UTF-8 warnings final sanitizedForLog = plaintext - .replaceAll('\uFFFD', '') // Remove replacement characters - .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII - final logPreview = sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); + .replaceAll('\uFFFD', '') // Remove replacement characters + .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII + final logPreview = + sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); debugLog('[RX FILTER] Decrypted message (${plaintext.length} chars): ' '"$logPreview${sanitizedForLog.length > 60 ? '...' : ''}"'); // Check printable ratio final printableRatio = getPrintableRatio(plaintext); - debugLog('[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' + debugLog( + '[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' '(threshold: ${(minPrintableRatio * 100).toFixed(1)}%)'); if (printableRatio < minPrintableRatio) { @@ -163,7 +175,8 @@ class PacketValidator { return ValidationResult.failed(nameResult.reason); } - debugLog('[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); + debugLog( + '[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); return ValidationResult.success(); } @@ -199,7 +212,6 @@ class PacketValidator { return printableCount / text.length; } - /// Parse ADVERT packet name field /// Reference: parseAdvertName() in wardrive.js lines 3353-3419 static AdvertNameResult parseAdvertName(Uint8List payload) { @@ -221,7 +233,8 @@ class PacketValidator { // Read flags byte from appData final flags = payload[appDataOffset]; - debugLog('[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); // Flag masks (from advert.js) const advNameMask = 0x80; @@ -259,7 +272,8 @@ class PacketValidator { // Remove trailing nulls and whitespace name = name.replaceAll(RegExp(r'\x00+$'), '').trim(); - debugLog('[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); + debugLog( + '[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); if (name.isEmpty) { return const AdvertNameResult( @@ -271,7 +285,8 @@ class PacketValidator { // Check if name is printable (use same threshold as messages) final printableRatio = getPrintableRatio(name); - debugLog('[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); + debugLog( + '[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); if (printableRatio < minPrintableRatio) { return AdvertNameResult( diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 3dee89f4..1e2de5ed 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -18,12 +18,14 @@ class BleUuids { /// Nordic UART Service UUID static const String serviceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E'; - + /// RX Characteristic (we write to this, device reads from it) - static const String characteristicRxUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; - + static const String characteristicRxUuid = + '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; + /// TX Characteristic (device writes to this, we read from it) - static const String characteristicTxUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; + static const String characteristicTxUuid = + '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; } /// Command codes sent to device @@ -63,7 +65,8 @@ class CommandCodes { static const int signData = 34; static const int signFinish = 35; static const int sendTracePath = 36; - static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) + static const int sendControlData = + 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE @@ -115,7 +118,8 @@ class PushCodes { static const int newAdvert = 0x8A; static const int telemetryResponse = 0x8B; static const int binaryResponse = 0x8C; - static const int controlData = 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) + static const int controlData = + 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) } /// Text message types @@ -140,11 +144,11 @@ class StatsTypes { class PacketHeader { PacketHeader._(); - static const int routeMask = 0x03; // 2-bits + static const int routeMask = 0x03; // 2-bits static const int typeShift = 2; - static const int typeMask = 0x0F; // 4-bits + static const int typeMask = 0x0F; // 4-bits static const int verShift = 6; - static const int verMask = 0x03; // 2-bits + static const int verMask = 0x03; // 2-bits } /// Route types diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 0451fffc..c78829a2 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -9,14 +9,14 @@ import 'packet_validator.dart'; /// Reference: handleRxLogging() + handleRxBatching() in wardrive.js (lines 3812-4140) class RxLogger { bool isWardriving = false; - + /// Map of repeaterId (hex) -> RxBatch final Map _batchBuffer = {}; - + /// Configuration constants static const int batchDistanceMeters = 25; static const Duration batchTimeout = Duration(seconds: 30); - + /// Callback for batched/finalized RX entries (API queue posting) final Future Function(RxApiEntry) onRxEntry; @@ -67,14 +67,15 @@ class RxLogger { PacketValidator validator, ) async { if (!isWardriving) return false; - + try { debugLog('[RX LOG] Processing packet for passive logging'); - + // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info if (metadata.pathHashCount == 0) { - debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); + debugLog( + '[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } @@ -88,7 +89,8 @@ class RxLogger { // CARpeater check: the carpeater is co-located with us, so it only // appears as the last hop (the delivery repeater) on RX packets - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); return false; @@ -98,7 +100,8 @@ class RxLogger { carpeaterStripped = true; reportedSnr = null; reportedRssi = null; - debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + debugLog( + '[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); } else { repeaterId = lastHopHex; } @@ -114,14 +117,18 @@ class RxLogger { // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore // Skip for CARpeater pass-through (CARpeater itself was already handled) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(repeaterId)) { + debugLog( + '[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging // Skip RSSI check for CARpeater pass-through - final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); + final validation = + await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -131,12 +138,14 @@ class RxLogger { // Log carpeater drops to error log (without auto-switching) if (validation.reason == 'carpeater-rssi') { - onCarpeaterDrop?.call(repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); + onCarpeaterDrop?.call( + repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); } return false; } - debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + debugLog( + '[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); @@ -172,15 +181,17 @@ class RxLogger { // IMPORTANT: Use the batch's bestObservation which has the FIRST location // where we heard this repeater, not the current GPS location. // This ensures map pins stay at the original location. - final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; + final batchedObservation = + _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { - debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' + debugLog( + '[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } - + return true; } catch (error, stackTrace) { debugError('[RX LOG] Error processing passive RX: $error'); @@ -223,7 +234,8 @@ class RxLogger { ); _batchBuffer[repeaterId] = buffer; wasKept = true; // New repeater, observation is kept - debugLog('[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); + debugLog( + '[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); // Start 30-second timeout timer for this repeater buffer.timeoutTimer = Timer(batchTimeout, () { @@ -250,8 +262,8 @@ class RxLogger { rssi: rssi, pathLength: pathLength, header: header, - lat: buffer.firstLocation.lat, // Keep original location - lon: buffer.firstLocation.lon, // Keep original location + lat: buffer.firstLocation.lat, // Keep original location + lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, ); @@ -276,7 +288,8 @@ class RxLogger { '(threshold=${batchDistanceMeters}m)'); if (distance >= batchDistanceMeters) { - debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); + debugLog( + '[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); await _flushRepeater(repeaterId); } @@ -285,43 +298,45 @@ class RxLogger { /// Check all active RX batches for distance threshold on GPS position update /// Called from GPS service when position changes - Future checkDistanceTriggers(({double lat, double lon}) currentLocation) async { + Future checkDistanceTriggers( + ({double lat, double lon}) currentLocation) async { if (_batchBuffer.isEmpty) { return; // No active batches to check } - debugLog('[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); - + debugLog( + '[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); + final repeatersToFlush = []; - + // Check each active batch for (final entry in _batchBuffer.entries) { final repeaterId = entry.key; final buffer = entry.value; - + final distance = _calculateHaversineDistance( currentLocation.lat, currentLocation.lon, buffer.firstLocation.lat, buffer.firstLocation.lon, ); - + debugLog('[RX BATCH] Distance check for repeater $repeaterId: ' '${distance.toStringAsFixed(2)}m from first observation ' '(threshold=${batchDistanceMeters}m)'); - + if (distance >= batchDistanceMeters) { debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, ' 'marking for flush'); repeatersToFlush.add(repeaterId); } } - + // Flush all repeaters that met the distance threshold for (final repeaterId in repeatersToFlush) { await _flushRepeater(repeaterId); } - + if (repeatersToFlush.isNotEmpty) { debugLog('[RX BATCH] Flushed ${repeatersToFlush.length} repeater(s) ' 'due to GPS movement'); @@ -331,20 +346,20 @@ class RxLogger { /// Flush a single repeater's batch - post best observation to API Future _flushRepeater(String repeaterId) async { debugLog('[RX BATCH] Flushing repeater $repeaterId'); - + final buffer = _batchBuffer[repeaterId]; if (buffer == null) { debugLog('[RX BATCH] No buffer to flush for repeater $repeaterId'); return; } - + // Clear timeout timer if it exists buffer.timeoutTimer?.cancel(); buffer.timeoutTimer = null; debugLog('[RX BATCH] Cleared timeout timer for repeater $repeaterId'); - + final best = buffer.bestObservation; - + // Build API entry using BEST observation's location final entry = RxApiEntry( repeaterId: repeaterId, @@ -357,13 +372,13 @@ class RxLogger { timestamp: best.timestamp, metadata: best.metadata, ); - + debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' 'location=${best.lat.toStringAsFixed(5)},${best.lon.toStringAsFixed(5)}'); - + // Queue for API posting await onRxEntry(entry); - + // Remove from buffer _batchBuffer.remove(repeaterId); debugLog('[RX BATCH] Repeater $repeaterId removed from buffer'); @@ -373,18 +388,18 @@ class RxLogger { Future flushAllBatches({String trigger = 'session_end'}) async { debugLog('[RX BATCH] Flushing all repeaters, trigger=$trigger, ' 'active_repeaters=${_batchBuffer.length}'); - + if (_batchBuffer.isEmpty) { debugLog('[RX BATCH] No repeaters to flush'); return; } - + // Iterate all repeaters and flush each one final repeaterIds = _batchBuffer.keys.toList(); for (final repeaterId in repeaterIds) { await _flushRepeater(repeaterId); } - + debugLog('[RX BATCH] All repeaters flushed: ${repeaterIds.length} total'); } @@ -397,18 +412,18 @@ class RxLogger { double lon2, ) { const earthRadiusM = 6371000.0; - + final dLat = _degreesToRadians(lat2 - lat1); final dLon = _degreesToRadians(lon2 - lon1); - + final a = sin(dLat / 2) * sin(dLat / 2) + cos(_degreesToRadians(lat1)) * cos(_degreesToRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2); - + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - + return earthRadiusM * c; } @@ -427,12 +442,12 @@ class RxLogger { /// Dispose of resources void dispose() { debugLog('[RX LOG] Disposing RX Logger'); - + // Cancel all timeout timers for (final buffer in _batchBuffer.values) { buffer.timeoutTimer?.cancel(); } - + _batchBuffer.clear(); isWardriving = false; } @@ -454,8 +469,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -481,8 +496,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart index ad7b5297..265c6e42 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -6,9 +6,9 @@ import '../../utils/debug_logger_io.dart'; /// Result of a trace path probe to a specific repeater class TraceResult { final String targetRepeaterId; - final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) - final int localRssi; // RSSI from BLE event metadata - final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) final bool success; const TraceResult({ @@ -52,7 +52,8 @@ class TraceTracker { Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); - debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; _expectedTag = tag; @@ -65,7 +66,8 @@ class TraceTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); } /// Handle incoming trace data packet (0x89) @@ -86,7 +88,8 @@ class TraceTracker { try { // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes if (rawBytes.length < 11) { - debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + debugLog( + '[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); return false; } @@ -99,7 +102,8 @@ class TraceTracker { final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; - debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + debugLog( + '[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); // Extract tag (bytes 3-6) final tag = rawBytes.sublist(3, 7); @@ -127,7 +131,8 @@ class TraceTracker { final pathEnd = pathStart + (hopCount * hashSize); if (rawBytes.length < pathEnd) { - debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + debugLog( + '[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); return false; } @@ -135,7 +140,10 @@ class TraceTracker { String repeaterId = ''; if (hopCount > 0) { final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); - repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + repeaterId = idBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } // Extract path SNRs (hopCount+1 bytes after path hashes) @@ -179,7 +187,8 @@ class TraceTracker { /// Stop tracking and return result TraceResult? stopTracking() { - debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + debugLog( + '[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); final result = _result; isListening = false; @@ -192,7 +201,8 @@ class TraceTracker { /// Handle trace window completion void _endWindow() { - debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + debugLog( + '[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); final result = _result; isListening = false; diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 575536ee..b4090e26 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -29,7 +29,8 @@ class TxTracker { /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) - void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? + onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -43,7 +44,7 @@ class TxTracker { bool disableRssiFilter = false; /// Start tracking echoes for a sent ping - /// + /// /// @param payload - The message text sent (for content verification) /// @param channelIdx - Channel index where ping was sent /// @param channelHash - Expected channel hash for validation @@ -58,8 +59,9 @@ class TxTracker { }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); - debugLog('[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); - + debugLog( + '[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + isListening = true; sentTimestamp = DateTime.now(); sentPayload = payload; @@ -67,26 +69,29 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); - + // Start window timer _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, stopTracking); - - debugLog('[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); + + debugLog( + '[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking echoes void stopTracking() { - debugLog('[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); - + debugLog( + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); + isListening = false; _windowTimer?.cancel(); _windowTimer = null; - + // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + debugLog( + '[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); } } } @@ -95,12 +100,13 @@ class TxTracker { /// Returns true if packet was an echo and tracked Future handlePacket(PacketMetadata metadata) async { if (!isListening) return false; - + final originalPayload = sentPayload; final expectedHash = expectedChannelHash; - + try { - debugLog('[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog( + '[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); // VALIDATION STEP 1: Header validation (must be GROUP_TEXT) if (!metadata.isGroupText) { @@ -108,12 +114,14 @@ class TxTracker { '(header=0x${metadata.header.toRadixString(16).padLeft(2, '0')})'); return false; } - debugLog('[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops if (metadata.pathHashCount == 0) { - debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); + debugLog( + '[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } @@ -125,14 +133,16 @@ class TxTracker { double? reportedSnr = metadata.snr; int? reportedRssi = metadata.rssi; - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); return false; } // Multi-hop: strip CARpeater, report underlying repeater (second hop) final underlyingHex = metadata.getHopHex(1)!; - debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + debugLog( + '[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); pathHex = underlyingHex; carpeaterStripped = true; reportedSnr = null; @@ -143,15 +153,19 @@ class TxTracker { // that heard our TX: the radio reports last-hop link quality, so for any // multi-hop relay the metrics describe a different link entirely. if (!carpeaterStripped && metadata.pathHashCount > 1) { - debugLog('[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' + debugLog( + '[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); return false; } // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { - debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(pathHex.toUpperCase())) { + debugLog( + '[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } @@ -160,20 +174,26 @@ class TxTracker { if (carpeaterStripped) { debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[TX LOG] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(metadata.rssi)) { - debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); - debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); - onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); + debugLog( + '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); + onCarpeaterDrop?.call( + pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) } else { - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); + debugLog( + '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { - debugLog('[TX LOG] Ignoring: payload too short to contain channel hash'); + debugLog( + '[TX LOG] Ignoring: payload too short to contain channel hash'); return false; } @@ -186,11 +206,13 @@ class TxTracker { debugLog('[TX LOG] Ignoring: channel hash mismatch'); return false; } - debugLog('[TX LOG] Channel hash match confirmed - this is a message on our channel'); + debugLog( + '[TX LOG] Channel hash match confirmed - this is a message on our channel'); // VALIDATION STEP 3: Message content verification if (channelKey != null && originalPayload != null) { - debugLog('[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); + debugLog( + '[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); try { // Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] @@ -204,18 +226,24 @@ class TxTracker { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); return false; } final messageBytes = decryptedBytes.sublist(5); // Convert bytes to string and strip null terminators - var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); - decryptedMessage = decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); - - debugLog('[MESSAGE_CORRELATION] Decryption successful, comparing content...'); - debugLog('[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); - debugLog('[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); + var decryptedMessage = + utf8.decode(messageBytes, allowMalformed: true); + decryptedMessage = + decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); + + debugLog( + '[MESSAGE_CORRELATION] Decryption successful, comparing content...'); + debugLog( + '[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); + debugLog( + '[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); // Check if our expected message is contained in the decrypted text // This handles both exact matches and messages with sender prefixes @@ -223,29 +251,37 @@ class TxTracker { decryptedMessage.contains(originalPayload); if (!messageMatches) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); - debugLog('[MESSAGE_CORRELATION] This is a different message on the same channel'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); + debugLog( + '[MESSAGE_CORRELATION] This is a different message on the same channel'); return false; } if (decryptedMessage == originalPayload) { - debugLog('[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); + debugLog( + '[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); } else { - debugLog('[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' + debugLog( + '[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' '- this is an echo of our ping!'); } } catch (e) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); return false; } } else { - debugWarn('[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); - debugWarn('[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); + debugWarn( + '[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); + debugWarn( + '[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); } // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + debugLog( + '[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater @@ -260,7 +296,8 @@ class TxTracker { ? reportedSnr > existing.snr! : reportedSnr != null && existing.snr == null; if (shouldUpdate) { - debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' + debugLog( + '[PING] Deduplication decision: updating path $pathHex with better SNR: ' '${existing.snr} -> $reportedSnr'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, @@ -269,7 +306,8 @@ class TxTracker { seenCount: existing.seenCount + 1, ); } else { - debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' + debugLog( + '[PING] Deduplication decision: keeping existing SNR for path $pathHex ' '(existing ${existing.snr} >= new $reportedSnr)'); // Still increment seen count existing.seenCount++; @@ -277,7 +315,8 @@ class TxTracker { } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + debugLog( + '[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, @@ -289,7 +328,8 @@ class TxTracker { // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; final bestRssi = repeaters[pathHex]!.rssi; - debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); @@ -312,10 +352,10 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { - final String repeaterId; // Hex string - double? snr; // Best SNR seen (null for CARpeater pass-through) - int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) - int seenCount; // Times observed + final String repeaterId; // Hex string + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) + int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 1c95dd2c..2f5e71df 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -41,7 +41,7 @@ class UnifiedRxHandler { /// Start unified RX listening void startListening() { if (isListening) return; - + debugLog('[UNIFIED RX] Starting unified RX listening'); isListening = true; debugLog('[UNIFIED RX] ✅ Unified listening started successfully'); @@ -50,7 +50,7 @@ class UnifiedRxHandler { /// Stop unified RX listening void stopListening() { if (!isListening) return; - + debugLog('[UNIFIED RX] Stopping unified RX listening'); isListening = false; debugLog('[UNIFIED RX] ✅ Unified listening stopped'); @@ -62,17 +62,18 @@ class UnifiedRxHandler { try { // Defensive check: ensure listener is marked as active if (!isListening) { - debugWarn('[UNIFIED RX] Received event but listener marked inactive - reactivating'); + debugWarn( + '[UNIFIED RX] Received event but listener marked inactive - reactivating'); isListening = true; } - + // Parse metadata ONCE final metadata = PacketMetadata.fromRawPacket( raw: rawPacket, snr: snr, rssi: rssi, ); - + debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); @@ -83,7 +84,8 @@ class UnifiedRxHandler { if (metadata.isTrace) { final tt = traceTracker; if (tt != null && tt.isListening) { - debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + debugLog( + '[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); tt.pendingBleSnr = metadata.snr; tt.pendingBleRssi = metadata.rssi; } @@ -99,16 +101,15 @@ class UnifiedRxHandler { return; } } - + // Route to RX wardriving if active if (rxLogger.isWardriving) { debugLog('[UNIFIED RX] RX wardriving active - logging observation'); await rxLogger.handlePacket(metadata, validator); } - + // If neither active, packet is received but ignored // Listener stays on, just not processing for wardriving - } catch (error, stackTrace) { debugError('[UNIFIED RX] Error processing rx_log entry: $error'); debugError('[UNIFIED RX] Stack trace: $stackTrace'); diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 56df1f9f..6f5d3a63 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -45,9 +45,9 @@ class OfflineMapRegion { bounds: region.definition.bounds, minZoom: region.definition.minZoom, maxZoom: region.definition.maxZoom, - createdAt: DateTime.tryParse( - (meta[_MetaKeys.createdAt] as String?) ?? '') ?? - DateTime.now(), + createdAt: + DateTime.tryParse((meta[_MetaKeys.createdAt] as String?) ?? '') ?? + DateTime.now(), // Platform channel JSON round-trip can return int as num/double. estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, ); @@ -154,8 +154,7 @@ class OfflineMapService extends ChangeNotifier { try { await _initNotifications(); final prefs = await SharedPreferences.getInstance(); - _storageLimitMb = - prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; + _storageLimitMb = prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; await refreshRegions(); _initialized = true; notifyListeners(); @@ -332,13 +331,11 @@ class OfflineMapService extends ChangeNotifier { int total = 0; for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { final tilesPerSide = 1 << z; // 2^z - final lonFraction = (bounds.northeast.longitude - - bounds.southwest.longitude) - .abs() / - 360.0; + final lonFraction = + (bounds.northeast.longitude - bounds.southwest.longitude).abs() / + 360.0; final latFraction = - (bounds.northeast.latitude - bounds.southwest.latitude).abs() / - 180.0; + (bounds.northeast.latitude - bounds.southwest.latitude).abs() / 180.0; final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); total += xTiles * yTiles; @@ -464,8 +461,7 @@ class OfflineMapService extends ChangeNotifier { // Throttle notification updates to every 2% to avoid flooding final percent = status.progress.round(); if (percent % 2 == 0) { - _showProgressNotification( - _downloadingRegionName ?? 'Region', percent); + _showProgressNotification(_downloadingRegionName ?? 'Region', percent); } } else { // Error status diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index d37cd8db..761a315f 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -10,10 +10,10 @@ class OfflineSession { final DateTime createdAt; final int pingCount; final Map data; - final String? devicePublicKey; // Device public key for auth during upload - final String? deviceName; // Device name for display - final String? contactUri; // Signed contact URI for registration during upload - final bool uploaded; // Track upload status + final String? devicePublicKey; // Device public key for auth during upload + final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload + final bool uploaded; // Track upload status OfflineSession({ required this.filename, @@ -106,14 +106,18 @@ class OfflineSessionService { /// Load sessions from storage Future _loadSessions() async { final sessionsJson = _prefs?.getStringList(_sessionsKey) ?? []; - _sessions = sessionsJson.map((json) { - try { - return OfflineSession.fromJson(jsonDecode(json) as Map); - } catch (e) { - debugError('[OFFLINE] Failed to parse session: $e'); - return null; - } - }).whereType().toList(); + _sessions = sessionsJson + .map((json) { + try { + return OfflineSession.fromJson( + jsonDecode(json) as Map); + } catch (e) { + debugError('[OFFLINE] Failed to parse session: $e'); + return null; + } + }) + .whereType() + .toList(); // Sort by date, newest first _sessions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); @@ -129,10 +133,12 @@ class OfflineSessionService { /// Generate filename for new session String _generateFilename() { final now = DateTime.now(); - final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; // Check if we already have sessions for today - final todaySessions = _sessions.where((s) => s.filename.startsWith(dateStr)).length; + final todaySessions = + _sessions.where((s) => s.filename.startsWith(dateStr)).length; if (todaySessions == 0) { return '$dateStr.json'; @@ -183,7 +189,8 @@ class OfflineSessionService { _sessions.insert(0, session); // Add at beginning (newest first) await _saveSessions(); - debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); + debugLog( + '[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } /// Update the current in-progress session with the latest pings snapshot. @@ -202,7 +209,8 @@ class OfflineSessionService { // If we have a tracked session, update it in-place if (_currentSessionFilename != null) { - final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + final index = + _sessions.indexWhere((s) => s.filename == _currentSessionFilename); if (index != -1) { final existing = _sessions[index]; final updatedData = Map.from(existing.data); @@ -219,11 +227,13 @@ class OfflineSessionService { contactUri: contactUri ?? existing.contactUri, ); await _saveSessions(); - debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + debugLog( + '[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); return; } // Session was deleted externally — fall through to create new - debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + debugWarn( + '[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); _currentSessionFilename = null; } @@ -237,7 +247,8 @@ class OfflineSessionService { // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { _currentSessionFilename = _sessions.first.filename; - debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + debugLog( + '[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); } } diff --git a/lib/services/permission_disclosure_service.dart b/lib/services/permission_disclosure_service.dart index 1fd5d8ad..c6ca111a 100644 --- a/lib/services/permission_disclosure_service.dart +++ b/lib/services/permission_disclosure_service.dart @@ -44,7 +44,8 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Track where you send pings on the mesh network'), + _BulletPoint( + text: 'Track where you send pings on the mesh network'), _BulletPoint(text: 'Map coverage areas for the community'), _BulletPoint(text: 'Record which repeaters hear your device'), SizedBox(height: 16), @@ -79,7 +80,8 @@ class PermissionDisclosureService { /// Show the background location disclosure (for "Always" permission) /// Returns true if user accepts, false if they decline - static Future showBackgroundLocationDisclosure(BuildContext context) async { + static Future showBackgroundLocationDisclosure( + BuildContext context) async { final result = await showDialog( context: context, barrierDismissible: false, @@ -103,8 +105,12 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Continue tracking coverage while the app is minimized'), - _BulletPoint(text: 'Send automatic pings during extended wardriving sessions'), + _BulletPoint( + text: + 'Continue tracking coverage while the app is minimized'), + _BulletPoint( + text: + 'Send automatic pings during extended wardriving sessions'), SizedBox(height: 16), Text( 'This grants "always on" location access, but we only collect what\'s needed: tagging pings while wardriving and checking if you\'re in a supported zone.', diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 50bc46eb..0482f818 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -42,12 +42,16 @@ import 'wakelock_service.dart'; class PingService { /// RX listening window duration (5 seconds - matches cooldown duration) static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); + /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); @@ -102,7 +106,7 @@ class PingService { bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; bool _targetedModeEnabled = false; - bool _nextPingIsDiscovery = true; // Start hybrid with discovery + bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; // Targeted mode tracking @@ -128,7 +132,8 @@ class PingService { StreamSubscription? _controlDataSubscription; Timer? _discoveryTimer; Position? _discoveryStartPosition; - Position? _lastDiscoveryPosition; // Track last discovery position for 25m check + Position? + _lastDiscoveryPosition; // Track last discovery position for 25m check // Validation callbacks bool Function()? checkExternalAntennaConfigured; @@ -150,6 +155,7 @@ class PingService { void Function(TxPing)? onTxPing; void Function(RxPing)? onRxPing; void Function(PingStats)? onStatsUpdated; + /// Called in real-time when each echo is received during tracking window /// Parameters: (TxPing txPing, HeardRepeater repeater, bool isNew) void Function(TxPing, HeardRepeater, bool isNew)? onEchoReceived; @@ -160,7 +166,8 @@ class PingService { /// Called in real-time when each node is discovered during tracking window /// Parameters: (DiscLogEntry discPing, DiscoveredNodeEntry nodeEntry, bool isNew) - void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? onDiscNodeDiscovered; + void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? + onDiscNodeDiscovered; /// Callback when TX window ends (for noise floor graph) /// Parameters: (bool success) - true if any repeaters heard, false if none @@ -252,7 +259,8 @@ class PingService { String? get skipReason => _skipReason; /// Get the manual ping cooldown timer (for UI display) - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; /// Set auto-ping interval (15000, 30000, or 60000 ms) /// Reference: getSelectedIntervalMs() in wardrive.js @@ -477,7 +485,8 @@ class PingService { // Guard: don't send pings if connection is not in connected state // Handles race where timer callback fires after reconnect started if (_connection.currentStep != ConnectionStep.connected) { - debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + debugLog( + '[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); return false; } @@ -502,7 +511,8 @@ class PingService { // Manual ping: 15-second cooldown, no distance check if (isInManualCooldown()) { final remainingSec = getRemainingManualCooldownSeconds(); - debugLog('[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -519,7 +529,8 @@ class PingService { // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -530,7 +541,8 @@ class PingService { if (_autoPingEnabled && !_passiveModeEnabled) { if (validation == PingValidation.tooCloseToLastPing) { _skipReason = 'too close'; - debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); + debugLog( + '[PING] Auto ping blocked: too close to last ping, scheduling next'); } if (_hybridModeEnabled) { _scheduleNextHybridPing(); @@ -556,7 +568,8 @@ class PingService { // Build ping message (same format used for TxTracker correlation) // Power is no longer included in the mesh message — sent per-ping in API payload - final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; + final coordsStr = + '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time @@ -586,13 +599,17 @@ class PingService { final channelHash = _connection.wardrivingChannelHash; final channelKey = _connection.wardrivingChannelKey; - if (_txTracker != null && channelIndex != null && channelHash != null && channelKey != null) { + if (_txTracker != null && + channelIndex != null && + channelHash != null && + channelKey != null) { debugLog('[PING] Starting TX echo tracking for: "$pingMessage"'); // Wire up real-time echo callback before starting tracking final txTracker = _txTracker; txTracker.onEchoReceived = (repeaterId, snr, rssi, isNew) { - debugLog('[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); + debugLog( + '[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); final txPing = _lastTxPing; if (txPing != null) { final repeater = HeardRepeater( @@ -605,18 +622,22 @@ class PingService { if (isNew) { // Add new repeater to the list txPing.heardRepeaters.add(repeater); - debugLog('[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); + debugLog( + '[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); } else { // Update existing repeater's SNR if better - final idx = txPing.heardRepeaters.indexWhere((r) => r.repeaterId == repeaterId); + final idx = txPing.heardRepeaters + .indexWhere((r) => r.repeaterId == repeaterId); if (idx >= 0) { txPing.heardRepeaters[idx] = repeater; - debugLog('[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); + debugLog( + '[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); } } // Notify for real-time UI updates - debugLog('[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); onEchoReceived?.call(txPing, repeater, isNew); debugLog('[PING] onEchoReceived callback completed'); } else { @@ -632,7 +653,8 @@ class PingService { windowDuration: _rxListeningWindow, ); } else { - debugWarn('[PING] TX tracking not available - channel info missing or no tracker'); + debugWarn( + '[PING] TX tracking not available - channel info missing or no tracker'); } // Play transmit sound immediately before sending @@ -706,7 +728,8 @@ class PingService { final txTracker = _txTracker; final txSuccess = txTracker != null && txTracker.repeaters.isNotEmpty; if (txSuccess) { - debugLog('[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); + debugLog( + '[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); // Format heard_repeats: "repeaterId(snr),repeaterId(snr)" // Reference: buildHeardRepeatsString() in wardrive.js @@ -723,7 +746,8 @@ class PingService { heardRepeats = repeaterStrings.join(','); // Update RX count stat for the echoes heard - _stats = _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); + _stats = + _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); onStatsUpdated?.call(_stats); } else { debugLog('[PING] No repeater echoes detected during listening window'); @@ -782,7 +806,7 @@ class PingService { debugLog('[PING] Pending disable complete, cooldown started'); // Notify AppStateProvider to update its state and cleanup await onPendingDisableComplete?.call(); - return; // Don't schedule next auto ping + return; // Don't schedule next auto ping } // Schedule next ping based on mode @@ -791,10 +815,12 @@ class PingService { // Reference: scheduleNextAutoPing() called after RX window in wardrive.js if (_autoPingEnabled && !isInCooldown()) { if (_hybridModeEnabled) { - debugLog('[HYBRID] Scheduling next hybrid ping after RX window completion'); + debugLog( + '[HYBRID] Scheduling next hybrid ping after RX window completion'); _scheduleNextHybridPing(); } else if (!_passiveModeEnabled) { - debugLog('[ACTIVE MODE] Scheduling next auto ping after RX window completion'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping after RX window completion'); _scheduleNextAutoPing(); } } else if (isInCooldown()) { @@ -808,7 +834,8 @@ class PingService { /// Reference: scheduleNextAutoPing() in wardrive.js void _scheduleNextAutoPing() { if (!_autoPingEnabled || _passiveModeEnabled) { - debugLog('[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); + debugLog( + '[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); return; } @@ -817,7 +844,8 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; - debugLog('[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); // Start countdown display (with skip reason if applicable) // The AutoPingTimer in countdown_timer_service.dart handles the display @@ -887,7 +915,8 @@ class PingService { bool targetedMode = false, String? targetRepeaterId, }) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); + debugLog( + '[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); @@ -895,7 +924,8 @@ class PingService { } // Targeted mode requires a repeater ID - if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + if (targetedMode && + (targetRepeaterId == null || targetRepeaterId.isEmpty)) { debugLog('[AUTO] Targeted mode requires a repeater ID'); return false; } @@ -920,7 +950,7 @@ class PingService { _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; _targetedModeEnabled = targetedMode; - _nextPingIsDiscovery = true; // Always start hybrid with discovery + _nextPingIsDiscovery = true; // Always start hybrid with discovery if (targetedMode) { _targetRepeaterId = targetRepeaterId; @@ -933,17 +963,20 @@ class PingService { if (targetedMode) { // Targeted Mode: send trace path to specific repeater - debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + debugLog( + '[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); await _startTargetedMode(); } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery - debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); + debugLog( + '[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); // First ping was discovery, so next should be TX _nextPingIsDiscovery = false; } else if (passiveMode) { // Passive Mode: send discovery requests instead of TX pings - debugLog('[PASSIVE MODE] Passive Mode started - using discovery protocol'); + debugLog( + '[PASSIVE MODE] Passive Mode started - using discovery protocol'); await _startDiscoveryMode(); } else { // Active Mode: send first ping immediately, then schedule timer @@ -970,14 +1003,15 @@ class PingService { if (_pingInProgress) { debugLog('[PING] Ping in progress, queuing disable for after RX window'); _pendingDisable = true; - return true; // Return true to indicate disable was accepted (pending) + return true; // Return true to indicate disable was accepted (pending) } // Check cooldown before stopping (unless forced) // Reference: isInCooldown() check in stopAutoPing() in wardrive.js if (!_passiveModeEnabled && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); return false; } @@ -1015,7 +1049,7 @@ class PingService { /// Force disable auto-ping (ignores cooldown, used for disconnect) Future forceDisableAutoPing() async { debugLog('[PING] Force disabling auto-ping'); - _pendingDisable = false; // Clear any pending disable + _pendingDisable = false; // Clear any pending disable _autoTimer?.cancel(); _autoTimer = null; _skipReason = null; @@ -1052,7 +1086,8 @@ class PingService { _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { - debugLog('[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); + debugLog( + '[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); final discPing = _lastDiscPing; if (discPing != null) { final nodeEntry = DiscoveredNodeEntry( @@ -1066,7 +1101,8 @@ class PingService { if (isNew) { discPing.discoveredNodes.add(nodeEntry); } else { - final idx = discPing.discoveredNodes.indexWhere((n) => n.repeaterId == node.repeaterId); + final idx = discPing.discoveredNodes + .indexWhere((n) => n.repeaterId == node.repeaterId); if (idx >= 0) discPing.discoveredNodes[idx] = nodeEntry; } onDiscNodeDiscovered?.call(discPing, nodeEntry, isNew); @@ -1099,7 +1135,8 @@ class PingService { _discTracker?.dispose(); _discTracker = null; _discoveryStartPosition = null; - _lastDiscoveryPosition = null; // Reset so first discovery always sends on next start + _lastDiscoveryPosition = + null; // Reset so first discovery always sends on next start _lastDiscPing = null; } @@ -1107,7 +1144,8 @@ class PingService { Future _sendDiscoveryRequest() async { // Guard: don't send discovery during reconnect (race with timer queue) if (_connection.currentStep != ConnectionStep.connected) { - debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + debugLog( + '[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); return; } @@ -1135,7 +1173,8 @@ class PingService { position.longitude, ); if (distance < _gpsService.configuredMinDistance) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + debugLog( + '[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1171,7 +1210,8 @@ class PingService { debugLog('[DISC] Created DiscLogEntry, ready for node tracking'); onDiscPing?.call(discPing); - debugLog('[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound immediately before sending @@ -1194,7 +1234,6 @@ class PingService { // Update last discovery position for 25m check _lastDiscoveryPosition = position; - } catch (e) { _pingInProgress = false; debugError('[DISC] Failed to send discovery request: $e'); @@ -1264,7 +1303,8 @@ class PingService { // Fire noise floor callback (entry already in _discLogEntries via onDiscPing) onDiscoveryWindowComplete?.call(discoverySuccess); - debugLog('[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); + debugLog( + '[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); _lastDiscPing = null; _scheduleNextDiscovery(); @@ -1295,7 +1335,8 @@ class PingService { // Notify callback for countdown display (30 seconds hardcoded for discovery) onAutoPingScheduled?.call(_discoveryInterval.inMilliseconds, _skipReason); - debugLog('[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); + debugLog( + '[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); } /// Schedule next hybrid ping (alternates discovery ↔ TX) @@ -1311,10 +1352,12 @@ class PingService { final listenMs = _nextPingIsDiscovery ? _discoveryListeningWindow.inMilliseconds : _rxListeningWindow.inMilliseconds; - final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); + final waitMs = + (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; - debugLog('[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); + debugLog( + '[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); onAutoPingScheduled?.call(waitMs, _skipReason); @@ -1353,10 +1396,12 @@ class PingService { final tracker = TraceTracker(); _traceTracker = tracker; tracker.onTraceReceived = (result) { - debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + debugLog( + '[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); }; tracker.onWindowComplete = (result) { - debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + debugLog( + '[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); _handleTraceWindowComplete(result); }; @@ -1416,11 +1461,14 @@ class PingService { final lastPos = _lastTargetedPosition; if (lastPos != null) { final distance = Geolocator.distanceBetween( - lastPos.latitude, lastPos.longitude, - position.latitude, position.longitude, + lastPos.latitude, + lastPos.longitude, + position.latitude, + position.longitude, ); if (distance < _gpsService.configuredMinDistance) { - debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + debugLog( + '[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextTargetedPing(); @@ -1450,7 +1498,8 @@ class PingService { ); onTracePing?.call(traceEntry); - debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound @@ -1460,11 +1509,13 @@ class PingService { final traceBytes = _traceHopBytes; final repeaterIdBytes = Uint8List(traceBytes); for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { - repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + repeaterIdBytes[i] = + int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); } // Send trace path and get tag - final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); + final tag = await _connection.sendTracePath(repeaterIdBytes, + hopBytes: traceBytes); // Start tracking with the tag _traceTracker?.startTracking( @@ -1481,7 +1532,6 @@ class PingService { // Update last targeted position for 25m check _lastTargetedPosition = position; - } catch (e) { _pingInProgress = false; debugError('[TRACE] Failed to send trace: $e'); @@ -1496,7 +1546,8 @@ class PingService { final targetId = _targetRepeaterId ?? ''; if (result != null && result.success && position != null) { - debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + debugLog( + '[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); // Queue to API (only successful traces) _apiQueue.enqueueTrace( @@ -1556,7 +1607,8 @@ class PingService { // Notify callback for countdown display onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); - debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + debugLog( + '[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); } /// Stop any active TX echo tracking window @@ -1590,32 +1642,32 @@ class PingService { enum PingValidation { /// All conditions met, can ping valid, - + /// Not connected to device notConnected, - + /// External antenna not configured externalAntennaRequired, - + /// Power level not set (unknown device model) powerLevelRequired, - + /// No GPS lock noGpsLock, - + /// GPS data too old (> 60 seconds) gpsDataStale, - + /// GPS accuracy too low (> 100 meters) gpsInaccurate, - + /// Outside service area (zone validation handled by API) /// Reserved for future use with dynamic zone boundaries outsideGeofence, - + /// Too close to last ping (< 25m) tooCloseToLastPing, - + /// Cooldown period active (< 5s since last ping) cooldownActive, diff --git a/lib/utils/debug_logger.dart b/lib/utils/debug_logger.dart index 2b4d3990..f5d5cd5e 100644 --- a/lib/utils/debug_logger.dart +++ b/lib/utils/debug_logger.dart @@ -9,10 +9,10 @@ import 'package:flutter/foundation.dart'; import 'package:web/web.dart' as web; /// Debug logging utility that mirrors MeshMapper_WebClient debug system. -/// +/// /// Logs are only output when DEBUG_ENABLED is true (set via `?debug=1` URL param). /// All log messages should use tagged format: `[TAG] message` -/// +/// /// Common tags: [BLE], [GPS], [PING], [API], [RX], [UI], [CONN] class DebugLogger { static bool _debugEnabled = false; @@ -30,7 +30,7 @@ class DebugLogger { final uri = Uri.base; final debugParam = uri.queryParameters['debug']; _debugEnabled = debugParam == '1' || debugParam == 'true'; - + if (_debugEnabled) { _consoleLog('[DEBUG] Debug logging ENABLED via URL param'); } @@ -56,9 +56,14 @@ class DebugLogger { /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleLog(args.join(' ')); } else { @@ -70,9 +75,15 @@ class DebugLogger { /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleWarn(args.join(' ')); } else { @@ -82,11 +93,18 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleError(args.join(' ')); } else { diff --git a/lib/utils/debug_logger_io.dart b/lib/utils/debug_logger_io.dart index d26799da..21fa3070 100644 --- a/lib/utils/debug_logger_io.dart +++ b/lib/utils/debug_logger_io.dart @@ -10,6 +10,4 @@ // debugError('[TAG] error'); // ``` -export 'debug_logger_stub.dart' - if (dart.library.html) 'debug_logger.dart'; - +export 'debug_logger_stub.dart' if (dart.library.html) 'debug_logger.dart'; diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index 4dc5a98d..c702fc74 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -22,7 +22,7 @@ class DebugLogger { // Enable debug logging by default on all builds _debugEnabled = true; - + if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); } @@ -39,7 +39,12 @@ class DebugLogger { /// Log a general info message to the console. /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -54,7 +59,13 @@ class DebugLogger { /// Log a warning message to the console. /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -68,8 +79,15 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart index ec8f0d91..001da568 100644 --- a/lib/utils/ping_colors.dart +++ b/lib/utils/ping_colors.dart @@ -7,11 +7,11 @@ import '../utils/debug_logger_io.dart'; /// The app adapts all semantic colors (ping types, signal quality, /// repeater status, noise floor) to a distinguishable palette. enum ColorVisionType { - none, // Default — current palette - protanopia, // Red-blind (~1% males) - deuteranopia, // Green-blind (~1% males) - tritanopia, // Blue-blind (~0.003%) - achromatopsia, // Total color blindness (monochrome) + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) } /// Immutable palette holding every semantic color the app uses. @@ -119,20 +119,20 @@ class ColorPalettes { /// Protanopia (red-blind) — replaces red/green axis with blue/orange. /// Also used for deuteranopia since both are red-green CVD. static const protanopia = ColorPalette( - txSuccess: Color(0xFF0072B2), // Wong blue + txSuccess: Color(0xFF0072B2), // Wong blue txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue - txFail: Color(0xFFD55E00), // Wong vermillion - rx: Color(0xFFCC79A7), // Wong reddish purple - discSuccess: Color(0xFF56B4E9), // Wong sky blue - discFail: Color(0xFF9E9E9E), // Grey (unchanged) - traceSuccess: Color(0xFF009E73), // Wong bluish green - noResponse: Color(0xFF9E9E9E), // Grey (unchanged) - signalGood: Color(0xFF0072B2), // Blue - signalMedium: Color(0xFFF0E442), // Wong yellow - signalBad: Color(0xFFD55E00), // Vermillion - repeaterActive: Color(0xFFCC79A7), // Reddish purple - repeaterNew: Color(0xFFF0E442), // Yellow - repeaterDead: Color(0xFF9E9E9E), // Grey + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey repeaterDuplicate: Color(0xFFD55E00), // Vermillion noiseFloorGood: Color(0xFF0072B2), noiseFloorMedium: Color(0xFFF0E442), @@ -148,20 +148,20 @@ class ColorPalettes { /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. /// Red/green distinction is preserved since tritan users can see those. static const tritanopia = ColorPalette( - txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccess: Color(0xFF009E73), // Wong bluish green txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) - txFail: Color(0xFFD55E00), // Wong vermillion - rx: Color(0xFFCC79A7), // Wong reddish purple - discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) - discFail: Color(0xFF9E9E9E), // Grey (unchanged) - traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) - noResponse: Color(0xFF9E9E9E), // Grey (unchanged) - signalGood: Color(0xFF009E73), // Bluish green - signalMedium: Color(0xFFE69F00), // Orange - signalBad: Color(0xFFD55E00), // Vermillion - repeaterActive: Color(0xFFCC79A7), // Reddish purple - repeaterNew: Color(0xFFE69F00), // Orange - repeaterDead: Color(0xFF9E9E9E), // Grey + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey repeaterDuplicate: Color(0xFFD55E00), // Vermillion noiseFloorGood: Color(0xFF009E73), noiseFloorMedium: Color(0xFFE69F00), @@ -178,20 +178,20 @@ class ColorPalettes { /// Relies on maximum brightness contrast between categories. /// Secondary indicators (icons, text) are essential with this palette. static const achromatopsia = ColorPalette( - txSuccess: Color(0xFFE0E0E0), // Light + txSuccess: Color(0xFFE0E0E0), // Light txSuccessLegend: Color(0xFFE0E0E0), - txFail: Color(0xFF616161), // Dark - rx: Color(0xFF9E9E9E), // Medium - discSuccess: Color(0xFFBDBDBD), // Medium-light - discFail: Color(0xFF757575), // Medium-dark - traceSuccess: Color(0xFF757575), // Medium-dark - noResponse: Color(0xFF616161), // Dark - signalGood: Color(0xFFE0E0E0), // Light - signalMedium: Color(0xFF9E9E9E), // Medium - signalBad: Color(0xFF424242), // Very dark - repeaterActive: Color(0xFFE0E0E0), // Light - repeaterNew: Color(0xFFBDBDBD), // Medium-light - repeaterDead: Color(0xFF616161), // Dark + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark repeaterDuplicate: Color(0xFF424242), // Very dark noiseFloorGood: Color(0xFFE0E0E0), noiseFloorMedium: Color(0xFF9E9E9E), diff --git a/lib/widgets/bug_report_dialog.dart b/lib/widgets/bug_report_dialog.dart index 9c34d2ba..ecf1c020 100644 --- a/lib/widgets/bug_report_dialog.dart +++ b/lib/widgets/bug_report_dialog.dart @@ -165,7 +165,8 @@ class _BugReportSheetState extends State { 'not-connected'; // Use last connected device name (companion name without MeshCore- prefix) - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; // Format description with username if provided final username = _usernameController.text.trim(); @@ -193,7 +194,8 @@ class _BugReportSheetState extends State { if (!mounted) return; if (result.success) { - debugLog('[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); + debugLog( + '[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); Navigator.of(context).pop(result); } else { setState(() { @@ -245,13 +247,15 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submit Feedback', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -270,272 +274,295 @@ class _BugReportSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - // Ticket type selector - SegmentedButton - _buildSectionLabel(theme, Icons.category, 'Report Type'), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'bug', - label: Text('Bug'), - icon: Icon(Icons.bug_report, size: 18), - ), - ButtonSegment( - value: 'enhancement', - label: Text('Feature'), - icon: Icon(Icons.lightbulb_outline, size: 18), - ), - ], - selected: {_ticketType}, - onSelectionChanged: _isSubmitting - ? null - : (selected) => setState(() => _ticketType = selected.first), - showSelectedIcon: false, - ), - const SizedBox(height: 24), - - // Username field (optional, auto-populated from remembered device) - _buildSectionLabel(theme, Icons.person, 'Username (optional)'), - const SizedBox(height: 8), - TextFormField( - controller: _usernameController, - textCapitalization: TextCapitalization.words, - decoration: _buildInputDecoration( - theme, - hintText: 'Your MeshCore companion name', - ), - maxLength: 50, - enabled: !_isSubmitting, - ), - const SizedBox(height: 16), - - // Title field - _buildSectionLabel(theme, Icons.title, 'Title'), - const SizedBox(height: 8), - TextFormField( - controller: _titleController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Brief summary of the issue', + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Ticket type selector - SegmentedButton + _buildSectionLabel(theme, Icons.category, 'Report Type'), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'bug', + label: Text('Bug'), + icon: Icon(Icons.bug_report, size: 18), + ), + ButtonSegment( + value: 'enhancement', + label: Text('Feature'), + icon: Icon(Icons.lightbulb_outline, size: 18), + ), + ], + selected: {_ticketType}, + onSelectionChanged: _isSubmitting + ? null + : (selected) => + setState(() => _ticketType = selected.first), + showSelectedIcon: false, ), - maxLength: 100, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Title is required'; - } - if (value.trim().length < 5) { - return 'Title must be at least 5 characters'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Description field - _buildSectionLabel(theme, Icons.description, 'Description'), - const SizedBox(height: 8), - TextFormField( - controller: _descriptionController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Describe the issue or feature request...', - alignLabelWithHint: true, + const SizedBox(height: 24), + + // Username field (optional, auto-populated from remembered device) + _buildSectionLabel( + theme, Icons.person, 'Username (optional)'), + const SizedBox(height: 8), + TextFormField( + controller: _usernameController, + textCapitalization: TextCapitalization.words, + decoration: _buildInputDecoration( + theme, + hintText: 'Your MeshCore companion name', + ), + maxLength: 50, + enabled: !_isSubmitting, ), - maxLines: 5, - maxLength: 2000, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Description is required'; - } - if (value.trim().length < 20) { - return 'Please provide more detail (at least 20 characters)'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Platform selector - _buildSectionLabel(theme, Icons.devices, 'Platform'), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _buildPlatformChip(theme, 'App', 'app', Icons.phone_android), - _buildPlatformChip(theme, 'Map', 'map', Icons.map), - _buildPlatformChip(theme, 'Other', 'other', Icons.more_horiz), - ], - ), + const SizedBox(height: 16), - // Debug logs section (mobile only) - if (!kIsWeb && _isLoadingFiles) ...[ - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), + // Title field + _buildSectionLabel(theme, Icons.title, 'Title'), + const SizedBox(height: 8), + TextFormField( + controller: _titleController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Brief summary of the issue', ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Text( - 'Preparing log files...', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + maxLength: 100, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + if (value.trim().length < 5) { + return 'Title must be at least 5 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Description field + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Describe the issue or feature request...', + alignLabelWithHint: true, ), + maxLines: 5, + maxLength: 2000, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Description is required'; + } + if (value.trim().length < 20) { + return 'Please provide more detail (at least 20 characters)'; + } + return null; + }, ), - ], - // Debug logs section - always visible when files available - if (!kIsWeb && !_isLoadingFiles && _availableLogFiles.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 16), + + // Platform selector + _buildSectionLabel(theme, Icons.devices, 'Platform'), const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + Wrap( + spacing: 8, + children: [ + _buildPlatformChip( + theme, 'App', 'app', Icons.phone_android), + _buildPlatformChip(theme, 'Map', 'map', Icons.map), + _buildPlatformChip( + theme, 'Other', 'other', Icons.more_horiz), + ], + ), + + // Debug logs section (mobile only) + if (!kIsWeb && _isLoadingFiles) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), - ), - child: Column( - children: [ - // Header with attach toggle - SwitchListTile( - title: const Text('Include with feedback'), - subtitle: Text( - 'Select logs to attach to this report', - style: theme.textTheme.bodySmall?.copyWith( + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - value: _uploadLogs, - onChanged: _isSubmitting - ? null - : (value) { - setState(() { - _uploadLogs = value; - if (!_uploadLogs) { - _selectedLogFiles.clear(); - } - }); - }, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - ), - Divider( - height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + ], + ), + ), + ], + // Debug logs section - always visible when files available + if (!kIsWeb && + !_isLoadingFiles && + _availableLogFiles.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), - // Log file list - only shown when toggle is on - if (_uploadLogs) - ...List.generate(_availableLogFiles.length, (index) { - final file = _availableLogFiles[index]; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - - return ListTile( - dense: true, - leading: Checkbox( - value: isSelected, - onChanged: _isSubmitting - ? null - : (_) => _toggleFile(file.path), - ), - title: Text( - filename, - style: const TextStyle(fontSize: 13), + ), + child: Column( + children: [ + // Header with attach toggle + SwitchListTile( + title: const Text('Include with feedback'), + subtitle: Text( + 'Select logs to attach to this report', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), + ), + value: _uploadLogs, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _uploadLogs = value; + if (!_uploadLogs) { + _selectedLogFiles.clear(); + } + }); + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + ), + Divider( + height: 1, + color: theme.colorScheme.outline + .withValues(alpha: 0.3), + ), + // Log file list - only shown when toggle is on + if (_uploadLogs) + ...List.generate(_availableLogFiles.length, + (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = + _selectedLogFiles.contains(file.path); + + // Format size and show part count for oversized files + String sizeDisplay; + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024) + .toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), ), - child: Text( - sizeDisplay, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme + .colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), - ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), - ); - }), - ], - ), - ), - ], - - // Error message - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.3), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), + ); + }), + ], ), ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme.colorScheme.error, + ], + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), ), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle(color: theme.colorScheme.error), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), ), - ), - ], + ], - // Bottom padding for safe area - SizedBox(height: MediaQuery.of(context).padding.bottom + 80), - ], + // Bottom padding for safe area + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), ), ), ), - ), // Sticky bottom action bar Container( @@ -557,7 +584,8 @@ class _BugReportSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -613,7 +641,8 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submitting...', style: theme.textTheme.titleLarge), ], @@ -635,7 +664,8 @@ class _BugReportSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -653,7 +683,9 @@ class _BugReportSheetState extends State { // Status text Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -680,7 +712,8 @@ class _BugReportSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), diff --git a/lib/widgets/connection_panel.dart b/lib/widgets/connection_panel.dart index 490d5e87..47c2c9d6 100644 --- a/lib/widgets/connection_panel.dart +++ b/lib/widgets/connection_panel.dart @@ -31,7 +31,8 @@ class ConnectionPanel extends StatelessWidget { return _buildAntennaSelector(context, appState, prefs); } - Widget _buildAntennaSelector(BuildContext context, AppStateProvider appState, prefs) { + Widget _buildAntennaSelector( + BuildContext context, AppStateProvider appState, prefs) { final isSet = prefs.externalAntennaSet; final hasExternal = prefs.externalAntenna; final colorScheme = Theme.of(context).colorScheme; @@ -64,7 +65,8 @@ class ConnectionPanel extends StatelessWidget { child: Icon( Icons.settings_input_antenna, size: 20, - color: isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, + color: + isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, ), ), const SizedBox(width: 12), @@ -84,7 +86,8 @@ class ConnectionPanel extends StatelessWidget { if (appState.antennaRestoredFromDevice) Text( 'Remembered for ${appState.displayDeviceName}', - style: TextStyle(fontSize: 11, color: colorScheme.onSurfaceVariant), + style: TextStyle( + fontSize: 11, color: colorScheme.onSurfaceVariant), ), ], ), @@ -108,7 +111,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: No'); appState.updatePreferences( - prefs.copyWith(externalAntenna: false, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: false, externalAntennaSet: true), ); }, ), @@ -119,7 +123,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: Yes'); appState.updatePreferences( - prefs.copyWith(externalAntenna: true, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: true, externalAntennaSet: true), ); }, ), @@ -153,7 +158,12 @@ class ConnectionPanel extends StatelessWidget { : Colors.transparent, borderRadius: BorderRadius.circular(6), boxShadow: isSelected && !isDark - ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2, offset: const Offset(0, 1))] + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(0, 1)) + ] : null, ), child: Text( @@ -162,8 +172,12 @@ class ConnectionPanel extends StatelessWidget { fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected - ? (isDark ? Colors.white : const Color(0xFF1E293B)) // slate-800 for light - : (isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B)), // slate-400/500 + ? (isDark + ? Colors.white + : const Color(0xFF1E293B)) // slate-800 for light + : (isDark + ? const Color(0xFF94A3B8) + : const Color(0xFF64748B)), // slate-400/500 ), ), ), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 8496e198..3c349b27 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -24,13 +24,15 @@ import 'repeater_id_chip.dart'; /// use `textField`, and MapLibre iOS wedges its resource loader with /// NSURLError -1002 if it tries to resolve glyphs against a style that /// doesn't declare a glyphs URL. -const _satelliteStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; +const _satelliteStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; /// Blank style with dark background — used when mapTilesEnabled is false /// (saves mobile data while still showing markers and overlays). /// Includes a `glyphs` URL so native annotations using textField (repeater /// hex IDs, distance labels) can render their text even when tiles are off. -const _blankStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; +const _blankStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). @@ -252,7 +254,6 @@ extension MapStyleExtension on MapStyle { } } - /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID /// matches multiple repeaters, [ambiguous] is true and the line gets a @@ -302,11 +303,14 @@ class _MapWidgetState extends State { bool _prefsApplied = false; // Guard to load saved prefs only once bool _isMapReady = false; LatLng? _lastGpsPosition; - bool _hasInitialZoomed = false; // Track if we've done the one-time initial zoom to GPS - bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _hasInitialZoomed = + false; // Track if we've done the one-time initial zoom to GPS + bool _hasZoomedToLastKnown = + false; // Track if we've zoomed to last known position (before GPS) // Map rotation mode - bool _alwaysNorth = true; // true = north always up, false = rotate with heading + bool _alwaysNorth = + true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation // Desired camera zoom while auto-follow is active. Set when the user taps @@ -323,8 +327,8 @@ class _MapWidgetState extends State { // effectively 0 or -1 when stationary or walking slowly. We keep our own // anchor-to-current bearing as a fallback so the arrow/walk marker and // heading-mode map rotation behave correctly at low speeds. - LatLng? _bearingAnchor; // last fix used as the bearing origin - double? _computedHeading; // last known-good bearing in degrees 0..360 + LatLng? _bearingAnchor; // last fix used as the bearing origin + double? _computedHeading; // last known-good bearing in degrees 0..360 // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; @@ -365,7 +369,8 @@ class _MapWidgetState extends State { // errors in the native log. bool _coverageRefreshScheduled = false; bool _styleLoaded = false; - bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) + bool _hasStyleLoadedOnce = + false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) // Tracks the last marker data version we synced to native annotations. // The build() method computes a version hash from app state and only triggers @@ -410,8 +415,9 @@ class _MapWidgetState extends State { // NOTE: repeaters do NOT use the annotation manager — they live in a custom // cluster-enabled GeoJSON source so MapLibre can group nearby markers into // count bubbles at low zoom. See _setupRepeaterClusterLayers(). - final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" - final Map _distanceLabelSymbols = {}; // key: focused repeater id + final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + final Map _distanceLabelSymbols = + {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: // the image size (for hit-box overlap tests) and the repeater lat/lon (so // we can slide the label along the ping→repeater line at a new parameter t). @@ -421,7 +427,7 @@ class _MapWidgetState extends State { // style-reload path can drop stale names from the map's image cache if ever // needed. Right now we just re-addImage on each sync (idempotent). final Set _registeredDistanceLabelImages = {}; - Symbol? _gpsSymbol; // single GPS marker + Symbol? _gpsSymbol; // single GPS marker // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) static const _repeaterSourceId = 'repeaters-source'; @@ -454,8 +460,12 @@ class _MapWidgetState extends State { // clear. Remove them explicitly so an in-flight tap that gets queued // before the platform channel is torn down can't reach into a disposed // State. try/catch swallows the edge case where _onMapCreated never ran. - try { controller.onSymbolTapped.remove(_handleSymbolTap); } catch (_) {} - try { controller.onFeatureTapped.remove(_handleFeatureTap); } catch (_) {} + try { + controller.onSymbolTapped.remove(_handleSymbolTap); + } catch (_) {} + try { + controller.onFeatureTapped.remove(_handleFeatureTap); + } catch (_) {} } super.dispose(); } @@ -483,15 +493,16 @@ class _MapWidgetState extends State { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && _autoFollow && _isMapReady && _lastGpsPosition != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow && _lastGpsPosition != null) { - final double targetBearing = (!_alwaysNorth && _computedHeading != null) - ? _computedHeading! - : 0.0; + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; final double targetZoom = _autoFollowDesiredZoom ?? _mapController?.cameraPosition?.zoom ?? _defaultZoom; @@ -548,10 +559,14 @@ class _MapWidgetState extends State { } /// Zoom to fit a focused ping and its connected repeaters on screen - void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + void _zoomToFocusBounds( + LatLng pingLocation, List<_ResolvedRepeater> repeaters) { if (_mapController == null || !_isMapReady || !mounted) return; - final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; + final points = [ + pingLocation, + ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) + ]; if (points.length < 2) return; // Build bounding box from all points @@ -570,7 +585,8 @@ class _MapWidgetState extends State { final bottomPad = MediaQuery.of(context).size.height * 0.4; _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, left: 60, top: 60, right: 60, bottom: bottomPad), + CameraUpdate.newLatLngBounds(bounds, + left: 60, top: 60, right: 60, bottom: bottomPad), duration: const Duration(milliseconds: 500), ); } @@ -578,14 +594,19 @@ class _MapWidgetState extends State { /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) return; + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) + return; final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; // Calculate shortest rotation path double delta = targetHeading - currentBearing; - while (delta > 180) { delta -= 360; } - while (delta < -180) { delta += 360; } + while (delta > 180) { + delta -= 360; + } + while (delta < -180) { + delta += 360; + } // Skip if rotation change is very small (less than 2 degrees) if (delta.abs() < 2) return; @@ -623,13 +644,17 @@ class _MapWidgetState extends State { _bearingAnchor = here; } else { final moved = Geolocator.distanceBetween( - _bearingAnchor!.latitude, _bearingAnchor!.longitude, - here.latitude, here.longitude, + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, ); if (moved >= 5.0) { final bearing = Geolocator.bearingBetween( - _bearingAnchor!.latitude, _bearingAnchor!.longitude, - here.latitude, here.longitude, + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, ); // bearingBetween returns -180..180; normalize to 0..360. _computedHeading = (bearing + 360) % 360; @@ -665,7 +690,8 @@ class _MapWidgetState extends State { // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; - final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * + final metersPerPixel = 40075000 / + (256 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); // Start with the offset expressed as if the map were north-up @@ -679,7 +705,8 @@ class _MapWidgetState extends State { } if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; - lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); + lonOffset = -(meterOffset / + (111000 * math.cos(position.latitude * math.pi / 180))); } // When the map is rotated, "screen-down" no longer points geographic @@ -691,7 +718,8 @@ class _MapWidgetState extends State { // the world direction that corresponds to screen-down at the given // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not // -bearing as the previous implementation did. - final bearingDeg = atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; + final bearingDeg = + atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; if (bearingDeg.abs() > 0.1) { final rotationRad = bearingDeg * math.pi / 180; final cosR = math.cos(rotationRad); @@ -702,7 +730,8 @@ class _MapWidgetState extends State { lonOffset = rotatedLon; } - return LatLng(position.latitude + latOffset, position.longitude + lonOffset); + return LatLng( + position.latitude + latOffset, position.longitude + lonOffset); } @override @@ -768,9 +797,14 @@ class _MapWidgetState extends State { if (_autoFollow) { // Auto-follow is on and panel may be open — apply panel offset so // the marker appears centered in the visible map area. - final adjustedPosition = _offsetPositionForPadding(initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0); + final adjustedPosition = _offsetPositionForPadding( + initialPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + 16.0); _animateToPositionWithZoom(adjustedPosition, 16.0); - debugLog('[MAP] Initial zoom to GPS position (with panel offset)'); + debugLog( + '[MAP] Initial zoom to GPS position (with panel offset)'); } else { _animateToPositionWithZoom(initialPosition, 16.0); debugLog('[MAP] Initial zoom to GPS position'); @@ -791,9 +825,10 @@ class _MapWidgetState extends State { _lastGpsPosition!.latitude != newPosition.latitude || _lastGpsPosition!.longitude != newPosition.longitude) { _lastGpsPosition = newPosition; - final double targetBearing = (!_alwaysNorth && _computedHeading != null) - ? _computedHeading! - : 0.0; + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; final double targetZoom = _autoFollowDesiredZoom ?? _mapController?.cameraPosition?.zoom ?? _defaultZoom; @@ -825,7 +860,10 @@ class _MapWidgetState extends State { // Handle map rotation based on heading when NOT auto-following. // When auto-follow is on, rotation is bundled into the combined // camera update above so we don't race two animateCamera calls. - if (!_autoFollow && !_alwaysNorth && _isMapReady && _computedHeading != null) { + if (!_autoFollow && + !_alwaysNorth && + _isMapReady && + _computedHeading != null) { final heading = _computedHeading!; if (_lastHeading == null) { // First heading after startup — store without rotating so the @@ -833,7 +871,8 @@ class _MapWidgetState extends State { // panel offset was computed). Heading mode will begin rotating // on the next GPS update when heading changes. _lastHeading = heading; - debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); + debugLog( + '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -852,17 +891,18 @@ class _MapWidgetState extends State { // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { + if (_isMapReady && + appState.mapNavigationTrigger != _lastNavigationTrigger) { _lastNavigationTrigger = appState.mapNavigationTrigger; final target = appState.mapNavigationTarget; if (target != null) { // Reset map controls to default state - _autoFollow = false; // Disable center on GPS + _autoFollow = false; // Disable center on GPS _autoFollowDesiredZoom = null; - _alwaysNorth = true; // Set to north-up mode - _rotationLocked = false; // Unlock rotation - _lastHeading = null; // Reset heading tracking - _bearingAnchor = null; // Reset derived-heading anchor + _alwaysNorth = true; // Set to north-up mode + _rotationLocked = false; // Unlock rotation + _lastHeading = null; // Reset heading tracking + _bearingAnchor = null; // Reset derived-heading anchor _computedHeading = null; // Navigate to the coordinates with close zoom (18 = street level view) @@ -895,7 +935,10 @@ class _MapWidgetState extends State { // window between _registerMapImages and _setupRepeaterClusterLayers // (inside _onStyleLoaded) would race ahead and call setGeoJsonSource on // a not-yet-created source, throwing "sourceNotFound". - if (_isMapReady && _styleLoaded && _imagesRegistered && _clusterLayersReady) { + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _clusterLayersReady) { final dataVersion = _computeMarkerDataVersion(appState); if (dataVersion != _lastMarkerDataVersion) { _lastMarkerDataVersion = dataVersion; @@ -919,7 +962,8 @@ class _MapWidgetState extends State { } } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; final topPadding = isLandscape ? 16.0 : 8.0; @@ -1010,7 +1054,8 @@ class _MapWidgetState extends State { Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + final onToggle = widget.onMapControlsToggle ?? + () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); return Column( mainAxisSize: MainAxisSize.min, @@ -1034,14 +1079,14 @@ class _MapWidgetState extends State { ), ), // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), + if (isExpanded) _buildMapControls(appState), ], ); } Widget _buildMap(AppStateProvider appState, LatLng center) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); // Always use the real style so downloaded offline tiles can render from // cache. Network access is controlled via setOffline() instead. final newStyleUrl = mapStyle.styleUrl; @@ -1056,7 +1101,8 @@ class _MapWidgetState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setOffline(!tilesEnabled); - debugPrint('[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + debugPrint( + '[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); }); } @@ -1076,10 +1122,12 @@ class _MapWidgetState extends State { // gps_inaccurate, the style loads with zoneCode=null and the overlay is // skipped. When a later retry sets the zone, nothing else would trigger // the raster layer. - final cacheBustChanged = - appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded; - final zoneChanged = - appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded; + final cacheBustChanged = appState.overlayCacheBust != _lastCacheBust && + _isMapReady && + _styleLoaded; + final zoneChanged = appState.zoneCode != _lastOverlayZoneCode && + _isMapReady && + _styleLoaded; if (cacheBustChanged || zoneChanged) { if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; @@ -1123,7 +1171,7 @@ class _MapWidgetState extends State { scrollGesturesEnabled: true, zoomGesturesEnabled: true, tiltGesturesEnabled: false, // 2D wardriving map - compassEnabled: false, // We have our own controls + compassEnabled: false, // We have our own controls // CRITICAL: must be true so the controller's `cameraPosition` getter // stays synced with the platform side. Without this, the Dart-side // _cameraPosition is set once at construction and never updated, which @@ -1251,8 +1299,7 @@ class _MapWidgetState extends State { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; final newZoom = math.min(currentZoom + 2, 17.0); _mapController?.animateCamera( CameraUpdate.newLatLngZoom(coordinates, newZoom), @@ -1369,7 +1416,8 @@ class _MapWidgetState extends State { // double-registers images. Bail any nested call so the first invocation // runs to completion uninterrupted. if (_styleLoadInProgress) { - debugLog('[MAP] _onStyleLoaded re-entered while already running, skipping'); + debugLog( + '[MAP] _onStyleLoaded re-entered while already running, skipping'); return; } _styleLoadInProgress = true; @@ -1438,9 +1486,11 @@ class _MapWidgetState extends State { setOffline(!tilesEnabled); if (tilesEnabled) { _tileLoadFailed = false; - _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { + _tileLoadTimeoutTimer = + Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { if (mounted && !_tileLoadFailed) { - debugWarn('[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); + debugWarn( + '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); setState(() => _tileLoadFailed = true); } }); @@ -1522,7 +1572,8 @@ class _MapWidgetState extends State { final cvdParam = appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''; - final url = 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + final url = + 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; try { await _mapController!.addSource( @@ -1557,7 +1608,8 @@ class _MapWidgetState extends State { belowLayerId: belowLayer, ); _lastAppliedCoverageOpacity = opacity; - debugLog('[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + debugLog( + '[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); } catch (e) { debugLog('[MAP] Failed to add coverage overlay: $e'); } @@ -1573,7 +1625,8 @@ class _MapWidgetState extends State { RasterLayerProperties(rasterOpacity: opacity), ); _lastAppliedCoverageOpacity = opacity; - debugLog('[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); + debugLog( + '[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); } catch (e) { // Layer may not exist yet (e.g. before first style load or when the // overlay is hidden). Safe to ignore — next _addCoverageOverlay call @@ -1718,7 +1771,8 @@ class _MapWidgetState extends State { } _imagesRegistered = true; - debugLog('[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); + debugLog( + '[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); // NOTE: do NOT trigger _syncAllAnnotations here. The repeater cluster // source/layers haven't been created yet — _onStyleLoaded calls // _setupRepeaterClusterLayers AFTER us, then triggers the initial sync @@ -1781,7 +1835,8 @@ class _MapWidgetState extends State { /// per-feature properties used by the data-driven symbol layer expressions /// (iconImage, color, opacity, hex). Re-pushed to the cluster source whenever /// the marker data version changes — MapLibre handles re-clustering natively. - Map _buildRepeaterFeatureCollection(AppStateProvider appState) { + Map _buildRepeaterFeatureCollection( + AppStateProvider appState) { final duplicates = _getDuplicateRepeaterIds(appState.repeaters); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; @@ -1862,7 +1917,10 @@ class _MapWidgetState extends State { await _mapController!.addSource( _repeaterSourceId, const GeojsonSourceProperties( - data: {'type': 'FeatureCollection', 'features': []}, + data: { + 'type': 'FeatureCollection', + 'features': [] + }, cluster: true, clusterRadius: 50, clusterMaxZoom: 14, @@ -1893,7 +1951,10 @@ class _MapWidgetState extends State { textIgnorePlacement: true, textFont: _defaultFontStack, ), - filter: ['!', ['has', 'point_count']], + filter: [ + '!', + ['has', 'point_count'] + ], belowLayerId: belowLayer, ); @@ -1911,8 +1972,10 @@ class _MapWidgetState extends State { 'step', ['get', 'point_count'], 18, - 10, 22, - 50, 26, + 10, + 22, + 50, + 26, ], circleStrokeColor: '#FFFFFF', circleStrokeWidth: 2, @@ -2096,7 +2159,8 @@ class _MapWidgetState extends State { } // Remove symbols for pings that no longer exist (e.g., user cleared markers) - final toRemove = _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + final toRemove = + _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); for (final key in toRemove) { final sym = _coverageSymbols.remove(key); if (sym != null) { @@ -2292,7 +2356,11 @@ class _MapWidgetState extends State { lineDasharray: [2, 4], lineCap: 'round', ), - filter: ['==', ['get', 'ambiguous'], true], + filter: [ + '==', + ['get', 'ambiguous'], + true + ], belowLayerId: belowLayer, ); @@ -2395,8 +2463,7 @@ class _MapWidgetState extends State { } } _distanceLabelImageSize[key] = imageSize; - _distanceLabelRepeaterPos[key] = - LatLng(r.repeater.lat, r.repeater.lon); + _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); final options = SymbolOptions( geometry: LatLng(midLat, midLon), @@ -2425,8 +2492,9 @@ class _MapWidgetState extends State { } // Remove labels for repeaters no longer in focus - final toRemove = - _distanceLabelSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + final toRemove = _distanceLabelSymbols.keys + .where((k) => !wantedKeys.contains(k)) + .toList(); for (final key in toRemove) { final sym = _distanceLabelSymbols.remove(key); _distanceLabelImageSize.remove(key); @@ -2492,8 +2560,7 @@ class _MapWidgetState extends State { var cursor = 0; for (final id in orderedIds) { final repeaterPos = _distanceLabelRepeaterPos[id]; - final labelSize = - _distanceLabelImageSize[id] ?? const Size(60, 18); + final labelSize = _distanceLabelImageSize[id] ?? const Size(60, 18); if (repeaterPos == null) { cursor += candidateTs.length; continue; @@ -2693,14 +2760,15 @@ class _MapWidgetState extends State { columnWidths: const { 0: IntrinsicColumnWidth(), // dot 1: IntrinsicColumnWidth(), // ID - 2: FixedColumnWidth(8), // spacer + 2: FixedColumnWidth(8), // spacer 3: IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + _overlayRow(rxSlot.repeaterId, rxSlot.snr, + _overlayTypeColor(OverlayPingType.rx)), ], ), ], @@ -2733,11 +2801,15 @@ class _MapWidgetState extends State { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps + ? formatMeters(position.accuracy, + isImperial: appState.preferences.isImperial) + : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, + color: + hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), // Distance since last TX ping (like wardrive.js) @@ -2750,7 +2822,8 @@ class _MapWidgetState extends State { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, + isImperial: appState.preferences.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -2771,7 +2844,8 @@ class _MapWidgetState extends State { /// Map controls (always vertical, used inside collapsible wrapper) Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); return Container( decoration: BoxDecoration( @@ -2793,7 +2867,9 @@ class _MapWidgetState extends State { _buildControlDivider(), _buildControlButton( icon: Icons.layers, - tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay', + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', onPressed: _toggleMeshMapperOverlay, isActive: _showMeshMapperOverlay, ), @@ -2803,14 +2879,17 @@ class _MapWidgetState extends State { _buildControlButton( icon: _autoFollow ? Icons.my_location : Icons.location_searching, tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: appState.currentPosition != null ? _centerOnPosition : null, + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, isActive: _autoFollow, ), _buildControlDivider(), // Always North toggle _buildControlButton( icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth ? 'Always North (Click to Rotate with Heading)' : 'Rotating with Heading (Click for Always North)', + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', onPressed: _toggleNorthMode, isActive: !_alwaysNorth, ), @@ -2872,7 +2951,8 @@ class _MapWidgetState extends State { void _cycleMapStyle(AppStateProvider appState) { const styles = MapStyle.values; - final currentStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final currentStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); final currentIndex = styles.indexOf(currentStyle); final newStyle = styles[(currentIndex + 1) % styles.length]; appState.setMapStyle(newStyle.name); @@ -2905,9 +2985,8 @@ class _MapWidgetState extends State { appState.setMapAutoFollow(true); // Bundle target + zoom + bearing into one animation so the // initial centering can't be half-cancelled by a racing GPS tick. - final double targetBearing = (!_alwaysNorth && _computedHeading != null) - ? _computedHeading! - : 0.0; + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) ? _computedHeading! : 0.0; final adjustedPosition = _offsetPositionForPadding( targetPosition, widget.bottomPaddingPixels, @@ -2955,7 +3034,8 @@ class _MapWidgetState extends State { _lastHeading = null; // Force initial rotation // Prefer our derived heading; fall back to whatever GPS reports (may // be 0 if we haven't moved yet — better than no rotation at all). - final initialHeading = _computedHeading ?? appState.currentPosition!.heading; + final initialHeading = + _computedHeading ?? appState.currentPosition!.heading; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_alwaysNorth && appState.currentPosition != null) { _animateToRotation(initialHeading); @@ -2972,7 +3052,10 @@ class _MapWidgetState extends State { _rotationLocked = !_rotationLocked; // When enabling lock in "Always North" mode, rotate back to north - if (_rotationLocked && _isMapReady && _alwaysNorth && _mapController != null) { + if (_rotationLocked && + _isMapReady && + _alwaysNorth && + _mapController != null) { final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; if (currentBearing.abs() > 2) { _mapController!.animateCamera( @@ -3009,7 +3092,8 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), child: const Icon(Icons.map, color: Colors.blue, size: 24), ), @@ -3018,8 +3102,8 @@ class _MapWidgetState extends State { child: Text( 'Legend & Info', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -3041,246 +3125,374 @@ class _MapWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Map Markers section - Text( - 'Map Markers', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLegendItem( - context: context, - color: PingColors.txSuccessLegend, - label: 'TX', - description: 'Location where you sent a ping and heard a repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.txFail, - label: 'TX', - description: 'Location where you sent a ping but no repeater was heard', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.rx, - label: 'RX', - description: 'Location where you received a message from the mesh', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.discSuccess, - label: 'DISC', - description: 'Location where you sent a discovery request and a repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.traceSuccess, - label: 'TRC', - description: 'Location where a trace reached the repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.discFail, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.noResponse, - label: 'TRC', - description: 'Location where a trace got no response', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Coverage Layer section - Text( - 'Coverage Layer', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLayerItem( - context: context, - color: PingColors.coverageBidir, - label: 'BIDIR', - description: 'Heard repeats from the mesh AND successfully routed through it', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDisc, - label: 'DISC', - description: 'Wardriving app sent a discovery packet and heard a reply', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageTx, - label: 'TX', - description: 'Successfully routed through, but no repeats heard back', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageRx, - label: 'RX', - description: 'Heard mesh traffic but did not transmit', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDead, - label: 'DEAD', - description: 'Repeater heard it, but no other radio received the repeat', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDrop, - label: 'DROP', - description: 'No repeats heard AND no successful route', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Sound Notifications section - Text( - 'Sound Notifications', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildSoundItem( - context: context, - icon: Icons.cell_tower, - label: 'TX Sound', - description: 'Plays when sending a ping or discovery request', - onPlay: () { - final appState = context.read(); - appState.audioService.playTransmitSound(); - }, - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildSoundItem( - context: context, - icon: Icons.hearing, - label: 'RX Sound', - description: 'Plays when a repeater echo or mesh message is received', - onPlay: () { - final appState = context.read(); - appState.audioService.playReceiveSound(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), - - // Map Controls section - Text( - 'Map Controls', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildHelpItem( - context: context, - icon: Icons.dark_mode, - label: 'Map Style', - description: 'Cycle between Dark, Light, and Satellite map styles', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.layers, - label: 'Coverage Overlay', - description: 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.my_location, - label: 'Center/Follow', - description: 'Center map on GPS position. Tap again to toggle auto-follow mode', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.navigation, - label: 'Always North', - description: 'Toggle between always-north orientation or rotate with heading', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.sync_disabled, - label: 'Lock Rotation', - description: 'Prevent accidental rotation of the map', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.info_outline, - label: 'Legend & Info', - description: 'Show this help popup with legend and control explanations', - ), - ], - ), - ), + Text( + 'Map Markers', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLegendItem( + context: context, + color: PingColors.txSuccessLegend, + label: 'TX', + description: + 'Location where you sent a ping and heard a repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.txFail, + label: 'TX', + description: + 'Location where you sent a ping but no repeater was heard', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.rx, + label: 'RX', + description: + 'Location where you received a message from the mesh', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.discSuccess, + label: 'DISC', + description: + 'Location where you sent a discovery request and a repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.traceSuccess, + label: 'TRC', + description: + 'Location where a trace reached the repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.discFail, + label: 'DISC', + description: + 'Location where you sent a discovery request but no repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.noResponse, + label: 'TRC', + description: + 'Location where a trace got no response', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Coverage Layer section + Text( + 'Coverage Layer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLayerItem( + context: context, + color: PingColors.coverageBidir, + label: 'BIDIR', + description: + 'Heard repeats from the mesh AND successfully routed through it', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDisc, + label: 'DISC', + description: + 'Wardriving app sent a discovery packet and heard a reply', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageTx, + label: 'TX', + description: + 'Successfully routed through, but no repeats heard back', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageRx, + label: 'RX', + description: + 'Heard mesh traffic but did not transmit', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDead, + label: 'DEAD', + description: + 'Repeater heard it, but no other radio received the repeat', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDrop, + label: 'DROP', + description: + 'No repeats heard AND no successful route', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Sound Notifications section + Text( + 'Sound Notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildSoundItem( + context: context, + icon: Icons.cell_tower, + label: 'TX Sound', + description: + 'Plays when sending a ping or discovery request', + onPlay: () { + final appState = + context.read(); + appState.audioService.playTransmitSound(); + }, + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildSoundItem( + context: context, + icon: Icons.hearing, + label: 'RX Sound', + description: + 'Plays when a repeater echo or mesh message is received', + onPlay: () { + final appState = + context.read(); + appState.audioService.playReceiveSound(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Map Controls section + Text( + 'Map Controls', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildHelpItem( + context: context, + icon: Icons.dark_mode, + label: 'Map Style', + description: + 'Cycle between Dark, Light, and Satellite map styles', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.layers, + label: 'Coverage Overlay', + description: + 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.my_location, + label: 'Center/Follow', + description: + 'Center map on GPS position. Tap again to toggle auto-follow mode', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.navigation, + label: 'Always North', + description: + 'Toggle between always-north orientation or rotate with heading', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.sync_disabled, + label: 'Lock Rotation', + description: + 'Prevent accidental rotation of the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.info_outline, + label: 'Legend & Info', + description: + 'Show this help popup with legend and control explanations', + ), + ], + ), + ), ], ), ), @@ -3297,8 +3509,13 @@ class _MapWidgetState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0), - Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, ], ), ), @@ -3510,7 +3727,8 @@ class _MapWidgetState extends State { snrValues: [entry.localSnr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -3531,7 +3749,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3544,9 +3763,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.cyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + border: Border.all( + color: Colors.cyan.withValues(alpha: 0.4)), ), - child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + child: const Icon(Icons.gps_fixed, + color: Colors.cyan, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3555,15 +3776,20 @@ class _MapWidgetState extends State { children: [ Text( 'Trace', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -3581,15 +3807,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3626,13 +3860,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -3642,7 +3881,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3653,7 +3894,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3664,7 +3907,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3675,14 +3920,17 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data row Builder(builder: (context) { final localSnr = entry.localSnr ?? 0; @@ -3691,15 +3939,24 @@ class _MapWidgetState extends State { final rxSnrColor = PingColors.snrColor(localSnr); final rssiColor = PingColors.rssiColor(localRssi); - final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // RX SNR Expanded( child: Center( @@ -3777,7 +4034,8 @@ class _MapWidgetState extends State { ? fullHex.substring(0, 8) : hexIds[i]; final matches = allRepeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); final ambiguous = matches.length > 1; resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); @@ -3786,7 +4044,8 @@ class _MapWidgetState extends State { } /// Activate ping focus mode — draw lines, fade markers, zoom to fit. - void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; @@ -3888,10 +4147,7 @@ class _MapWidgetState extends State { for (final repeater in repeaters) { idCounts[repeater.id] = (idCounts[repeater.id] ?? 0) + 1; } - return idCounts.entries - .where((e) => e.value > 1) - .map((e) => e.key) - .toSet(); + return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } /// Get marker color for a repeater based on status priority: @@ -3910,7 +4166,9 @@ class _MapWidgetState extends State { /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { final appState = context.read(); - final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + final hopBytes = appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes; switch (hopBytes) { case 2: return 70 + extraPadding; @@ -3933,7 +4191,8 @@ class _MapWidgetState extends State { snrValues: heardRepeaters.map((r) => r.snr).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } } @@ -3954,7 +4213,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3968,9 +4228,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), + child: Icon(Icons.arrow_upward, + color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3979,15 +4241,20 @@ class _MapWidgetState extends State { children: [ Text( 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -4005,15 +4272,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4032,7 +4307,9 @@ class _MapWidgetState extends State { // Repeaters section header Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + heardRepeaters.isEmpty + ? 'No repeaters heard' + : 'Heard Repeaters (${heardRepeaters.length})', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -4048,13 +4325,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4064,7 +4346,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4075,7 +4359,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4086,32 +4372,49 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; - final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', + value: + repeater.snr?.toStringAsFixed(1) ?? + '-', color: snrColor, ), ), @@ -4120,7 +4423,9 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', color: rssiColor, ), ), @@ -4153,7 +4458,8 @@ class _MapWidgetState extends State { snrValues: [ping.snr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -4167,7 +4473,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4181,9 +4488,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, color: Colors.blue, size: 24), + child: const Icon(Icons.arrow_downward, + color: Colors.blue, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4193,8 +4502,8 @@ class _MapWidgetState extends State { Text( 'RX Ping', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), @@ -4222,11 +4531,17 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4260,13 +4575,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4276,7 +4596,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4287,7 +4609,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4298,7 +4622,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4308,13 +4634,19 @@ class _MapWidgetState extends State { Divider(height: 1, color: Theme.of(context).dividerColor), // Data row InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -4329,12 +4661,12 @@ class _MapWidgetState extends State { child: Center( child: _buildStatChip( value: '${ping.rssi}', - color: rssiColor, + color: rssiColor, + ), ), ), - ), - ], - ), + ], + ), ), ), ], @@ -4353,10 +4685,12 @@ class _MapWidgetState extends State { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -4377,7 +4711,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4391,9 +4726,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: _discMarkerColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + border: Border.all( + color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: + Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4402,15 +4739,20 @@ class _MapWidgetState extends State { children: [ Text( 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -4428,15 +4770,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4473,13 +4823,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4489,7 +4844,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4500,7 +4857,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4511,7 +4870,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4522,24 +4883,36 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { final rxSnrColor = PingColors.snrColor(node.localSnr); - final rssiColor = PingColors.rssiColor(node.localRssi); - final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Node ID with type @@ -4547,7 +4920,9 @@ class _MapWidgetState extends State { width: _nodeColumnWidth(extraPadding: 20), child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), Text( node.nodeTypeLabel, style: TextStyle( @@ -4581,7 +4956,8 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), + value: + node.remoteSnr.toStringAsFixed(1), color: txSnrColor, ), ), @@ -4624,7 +5000,8 @@ class _MapWidgetState extends State { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + void _showRepeaterDetails(Repeater repeater, + {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -4650,7 +5027,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4660,7 +5038,8 @@ class _MapWidgetState extends State { children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); final isLongId = displayId.length > 2; return Container( constraints: const BoxConstraints(minWidth: 44), @@ -4690,8 +5069,8 @@ class _MapWidgetState extends State { child: Text( repeater.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -4711,7 +5090,8 @@ class _MapWidgetState extends State { Row( children: [ if (isDuplicate) ...[ - _buildRepeaterStatusChip('Duplicate', _repeaterDuplicateColor), + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), const SizedBox(width: 8), ], _buildRepeaterStatusChip(statusLabel, statusColor), @@ -4725,14 +5105,21 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Location row Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4750,7 +5137,10 @@ class _MapWidgetState extends State { // Last heard row Row( children: [ - Icon(Icons.access_time, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.access_time, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4930,7 +5320,13 @@ class _BikeMarkerPainter extends CustomPainter { ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear ..moveTo(cx, cy - 5) ..lineTo(cx + 2, cy - 7); // Handlebar - canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + canvas.drawPath( + framePath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round); // Blue wheels canvas.drawCircle(leftWheel, wheelR, bikePaint); @@ -4984,11 +5380,21 @@ class _BoatMarkerPainter extends CustomPainter { canvas.drawPath(hull, fillPaint); // Mast outline - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round); // Mast - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = const Color(0xFF2196F3) + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round); // Sail outline final sailOutline = ui.Path() @@ -5004,7 +5410,11 @@ class _BoatMarkerPainter extends CustomPainter { ..lineTo(cx + 6, cy - 0.5) ..lineTo(cx + 1, cy - 0.5) ..close(); - canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + canvas.drawPath( + sail, + Paint() + ..color = const Color(0xFF64B5F6) + ..style = PaintingStyle.fill); } @override @@ -5037,7 +5447,12 @@ class _WalkMarkerPainter extends CustomPainter { ..style = PaintingStyle.fill; // Head outline + fill - canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle( + Offset(cx, cy - 7), + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); // Body outline @@ -5046,9 +5461,11 @@ class _WalkMarkerPainter extends CustomPainter { canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); // Arms outline - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); // Arms - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); // Left leg outline canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); @@ -5128,7 +5545,9 @@ class _PinMarkerPainter extends CustomPainter { final cx = size.width / 2; final cy = size.height / 2; - final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; final outlinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke @@ -5164,11 +5583,13 @@ class _PinMarkerPainter extends CustomPainter { canvas.drawCircle(headCenter, headRadius, outlinePaint); // Inner dot - canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + canvas.drawCircle( + headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); } @override - bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a diamond marker for coverage dots @@ -5208,7 +5629,8 @@ class _DiamondMarkerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a repeater marker shape (filled colored rounded box with white border @@ -5275,7 +5697,7 @@ class _RepeaterShapePainter extends CustomPainter { /// styles. Used at startup to generate bitmap variants for native MapLibre /// symbols. Reuses _PinMarkerPainter and _DiamondMarkerPainter for those styles. class _CoverageMarkerPainter extends CustomPainter { - final String style; // 'circle' / 'pin' / 'diamond' / 'dot' + final String style; // 'circle' / 'pin' / 'diamond' / 'dot' final Color color; const _CoverageMarkerPainter({required this.style, required this.color}); @@ -5308,7 +5730,8 @@ class _CoverageMarkerPainter extends CustomPainter { canvas.restore(); } - void _paintCircle(Canvas canvas, Size size, {required double borderAlpha, required double borderWidth}) { + void _paintCircle(Canvas canvas, Size size, + {required double borderAlpha, required double borderWidth}) { final center = Offset(size.width / 2, size.height / 2); final radius = math.min(size.width, size.height) / 2 - 2; @@ -5387,7 +5810,9 @@ class _SoundItemWidgetState extends State<_SoundItemWidget> { : Colors.blue.withValues(alpha: 0.2), shape: BoxShape.circle, border: Border.all( - color: _isPlaying ? Colors.blue : Colors.blue.withValues(alpha: 0.5), + color: _isPlaying + ? Colors.blue + : Colors.blue.withValues(alpha: 0.5), width: _isPlaying ? 2 : 1, ), ), diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 568807cf..dc92a09d 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -12,13 +12,16 @@ class InteractiveNoiseFloorChart extends StatefulWidget { final NoiseFloorSession session; final bool isLive; - const InteractiveNoiseFloorChart({super.key, required this.session, this.isLive = false}); + const InteractiveNoiseFloorChart( + {super.key, required this.session, this.isLive = false}); @override - State createState() => InteractiveNoiseFloorChartState(); + State createState() => + InteractiveNoiseFloorChartState(); } -class InteractiveNoiseFloorChartState extends State { +class InteractiveNoiseFloorChartState + extends State { // View window in seconds late double _viewStart; late double _viewEnd; @@ -68,7 +71,8 @@ class InteractiveNoiseFloorChartState extends State final effectiveTotal = newTotal < 60 ? 60.0 : newTotal; // Detect if user is at full (unzoomed) view: start near 0 and end near total - final isFullView = _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; + final isFullView = + _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; _totalDuration = effectiveTotal; @@ -92,14 +96,18 @@ class InteractiveNoiseFloorChartState extends State double get _visibleDuration => _viewEnd - _viewStart; double get _zoomLevel => _totalDuration / _visibleDuration; - void _handleScaleStart(ScaleStartDetails details, double chartWidth, double chartLeft) { + void _handleScaleStart( + ScaleStartDetails details, double chartWidth, double chartLeft) { _gestureStartViewStart = _viewStart; _gestureStartViewEnd = _viewEnd; _gestureStartFocalX = details.localFocalPoint.dx; } - void _handleScaleUpdate(ScaleUpdateDetails details, double chartWidth, double chartLeft) { - if (_gestureStartViewStart == null || _gestureStartViewEnd == null || _gestureStartFocalX == null) { + void _handleScaleUpdate( + ScaleUpdateDetails details, double chartWidth, double chartLeft) { + if (_gestureStartViewStart == null || + _gestureStartViewEnd == null || + _gestureStartFocalX == null) { return; } @@ -110,7 +118,8 @@ class InteractiveNoiseFloorChartState extends State newDuration = newDuration.clamp(_minVisibleSeconds, _totalDuration); // Calculate focal point ratio in chart space - final focalRatio = ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); + final focalRatio = + ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); // Time at focal point in original view final focalTime = _gestureStartViewStart! + (startDuration * focalRatio); @@ -150,7 +159,8 @@ class InteractiveNoiseFloorChartState extends State } /// Check if tap hit a marker and show popup if so - void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, double chartHeight, double chartTop) { + void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, + double chartHeight, double chartTop) { final session = widget.session; if (session.markers.isEmpty || session.samples.isEmpty) return; @@ -161,7 +171,8 @@ class InteractiveNoiseFloorChartState extends State // Find if tap is within any marker for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < _viewStart || elapsed > _viewEnd) continue; @@ -176,7 +187,8 @@ class InteractiveNoiseFloorChartState extends State final tapX = details.localPosition.dx; final tapY = details.localPosition.dy; - final distance = ((tapX - markerX) * (tapX - markerX) + (tapY - markerY) * (tapY - markerY)); + final distance = ((tapX - markerX) * (tapX - markerX) + + (tapY - markerY) * (tapY - markerY)); if (distance <= _markerTapRadius * _markerTapRadius) { _showMarkerDetails(marker, noiseFloorOnLine.round()); return; @@ -185,9 +197,12 @@ class InteractiveNoiseFloorChartState extends State } /// Interpolate noise floor at given elapsed time - double _interpolateNoiseFloor(double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + double _interpolateNoiseFloor( + double elapsedSeconds, NoiseFloorSession session) { + if (session.samples.isEmpty) + return widget.session.noiseFloorRange.min.toDouble(); + if (session.samples.length == 1) + return session.samples.first.noiseFloor.toDouble(); NoiseFloorSample? before; NoiseFloorSample? after; @@ -195,7 +210,8 @@ class InteractiveNoiseFloorChartState extends State double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -210,8 +226,10 @@ class InteractiveNoiseFloorChartState extends State if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } /// Show marker details popup as a modern bottom sheet @@ -260,7 +278,10 @@ class InteractiveNoiseFloorChartState extends State width: 40, height: 4, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), @@ -282,8 +303,8 @@ class InteractiveNoiseFloorChartState extends State Text( eventTypeLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), Text( @@ -325,7 +346,8 @@ class InteractiveNoiseFloorChartState extends State context, icon: Icons.location_on, label: 'Location', - value: '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', + value: + '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', compact: true, ), ), @@ -334,19 +356,26 @@ class InteractiveNoiseFloorChartState extends State ), // Repeaters section (table format like TX log) - if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ + if (marker.repeaters != null && + marker.repeaters!.isNotEmpty) ...[ const SizedBox(height: 16), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ SizedBox( @@ -356,7 +385,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -367,7 +398,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -378,16 +411,20 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows - ...marker.repeaters!.map((r) => _buildRepeaterRow(context, r)), + ...marker.repeaters! + .map((r) => _buildRepeaterRow(context, r)), ], ), ), @@ -401,7 +438,8 @@ class InteractiveNoiseFloorChartState extends State child: FilledButton.icon( onPressed: () { // Get references before popping - final appState = Provider.of(context, listen: false); + final appState = Provider.of(context, + listen: false); final navigator = Navigator.of(context); // Pop the bottom sheet first @@ -412,7 +450,8 @@ class InteractiveNoiseFloorChartState extends State navigator.popUntil((route) => route.isFirst); // Navigate to map and center on location - appState.navigateToMapCoordinates(marker.latitude!, marker.longitude!); + appState.navigateToMapCoordinates( + marker.latitude!, marker.longitude!); }, icon: const Icon(Icons.map, size: 18), label: const Text('View on Map'), @@ -444,7 +483,10 @@ class InteractiveNoiseFloorChartState extends State return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -487,17 +529,21 @@ class InteractiveNoiseFloorChartState extends State final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fullHexId: repeater.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ // Node ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip( + repeaterId: repeater.repeaterId, fontSize: 11, width: 50), // SNR chip Expanded( child: Center( - child: _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), + child: + _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), ), ), // RSSI chip @@ -575,24 +621,32 @@ class InteractiveNoiseFloorChartState extends State Expanded( child: LayoutBuilder( builder: (context, constraints) { - final chartWidth = constraints.maxWidth - leftPadding - rightPadding; + final chartWidth = + constraints.maxWidth - leftPadding - rightPadding; - final chartHeight = constraints.maxHeight - topPadding - 36.0; // 36 = bottom axis reserved + final chartHeight = constraints.maxHeight - + topPadding - + 36.0; // 36 = bottom axis reserved return RawGestureDetector( gestures: { - ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + ScaleGestureRecognizer>( () => ScaleGestureRecognizer(), (ScaleGestureRecognizer instance) { - instance.onStart = (details) => _handleScaleStart(details, chartWidth, leftPadding); - instance.onUpdate = (details) => _handleScaleUpdate(details, chartWidth, leftPadding); + instance.onStart = (details) => + _handleScaleStart(details, chartWidth, leftPadding); + instance.onUpdate = (details) => + _handleScaleUpdate(details, chartWidth, leftPadding); instance.onEnd = _handleScaleEnd; }, ), - TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + TapGestureRecognizer>( () => TapGestureRecognizer(), (TapGestureRecognizer instance) { - instance.onTapUp = (details) => _handleTap(details, chartWidth, leftPadding, chartHeight, topPadding); + instance.onTapUp = (details) => _handleTap(details, + chartWidth, leftPadding, chartHeight, topPadding); }, ), }, @@ -601,7 +655,8 @@ class InteractiveNoiseFloorChartState extends State children: [ // Line chart - wrapped in IgnorePointer so it doesn't steal gestures Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: LineChart( LineChartData( @@ -622,7 +677,8 @@ class InteractiveNoiseFloorChartState extends State ), // Marker overlay Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: CustomPaint( size: Size.infinite, @@ -664,13 +720,15 @@ class InteractiveNoiseFloorChartState extends State LineChartBarData _buildLineData(NoiseFloorSession session) { // Return cached data if session hasn't changed (prevents rebuilding during zoom) - if (_cachedLineData != null && _cachedSession == session && + if (_cachedLineData != null && + _cachedSession == session && _cachedSampleCount == session.samples.length) { return _cachedLineData!; } final spots = session.samples.map((s) { - final elapsed = s.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + s.timestamp.difference(session.startTime).inSeconds.toDouble(); return FlSpot(elapsed, s.noiseFloor.toDouble()); }).toList(); @@ -703,9 +761,9 @@ class InteractiveNoiseFloorChartState extends State ]; final stops = [ 0.0, - yToStop(-100), // Start fading from green - yToStop(-90), // Orange in middle - yToStop(-80), // Fade to red + yToStop(-100), // Start fading from green + yToStop(-90), // Orange in middle + yToStop(-80), // Fade to red 1.0, ]; @@ -919,7 +977,8 @@ class _MarkerPainter extends CustomPainter { if (visibleRange <= 0 || chartWidth <= 0 || chartHeight <= 0) return; for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < minX || elapsed > maxX) continue; @@ -948,7 +1007,8 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + if (session.samples.length == 1) + return session.samples.first.noiseFloor.toDouble(); NoiseFloorSample? before; NoiseFloorSample? after; @@ -956,7 +1016,8 @@ class _MarkerPainter extends CustomPainter { double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -971,8 +1032,10 @@ class _MarkerPainter extends CustomPainter { if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } @override diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart index b54aec58..af3ed03c 100644 --- a/lib/widgets/offline_mode_toggle.dart +++ b/lib/widgets/offline_mode_toggle.dart @@ -84,16 +84,18 @@ class OfflineModeToggle extends StatelessWidget { } /// Show confirmation dialog explaining what the mode does - static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { - final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + static Future _showConfirmDialog( + BuildContext context, bool switchingToOffline) { + final title = + switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; final iconColor = switchingToOffline ? Colors.orange : Colors.green; final description = switchingToOffline ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' - 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' - 'You can upload saved data later from the Settings tab.' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' - 'This requires an active internet connection.'; + 'This requires an active internet connection.'; final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; return showDialog( @@ -147,7 +149,8 @@ class OfflineModeToggle extends StatelessWidget { return Material( color: Colors.transparent, child: InkWell( - onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + onTap: () => handleOfflineModeToggle( + context, appState, offlineMode, isConnected), borderRadius: BorderRadius.circular(10), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index c05b96f0..fe44f970 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -15,29 +15,44 @@ class PingControls extends StatelessWidget { Widget build(BuildContext context) { final appState = context.watch(); final validation = appState.pingValidation; - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete - final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode + final isPendingDisable = appState + .isPendingDisable; // Disable pending, waiting for RX window to complete + final cooldownActive = appState + .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; // RX listening window after ping + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = + appState.rxWindowTimer.isRunning; // RX listening window after ping final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; // True immediately when manual ping button clicked - final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) - final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping + final isPingSending = appState + .isPingSending; // True immediately when manual ping button clicked + final isPingInProgress = appState + .isPingInProgress; // True during entire ping + RX window (includes auto pings) + final autoPingWaiting = + appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) + final autoPingSkipped = appState.autoPingTimer.skipReason != + null; // Last ping was skipped (e.g. distance) + final discoveryWindowActive = appState.discoveryWindowTimer + .isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; // TX is blocked when offline mode is active and connected @@ -53,7 +68,9 @@ class PingControls extends StatelessWidget { Color? blockingColor; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; if (!appState.isConnected) { // Don't show hint when disconnected - buttons are obviously disabled @@ -87,89 +104,135 @@ class PingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // Send Ping button - // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" - // Manual pings use 15-second cooldown, no distance requirement - // When Active/Passive Mode is running, just shows "Send Ping" (disabled) - Expanded( - child: _ActionButton( - icon: Icons.cell_tower, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isTxModeRunning - ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running - : isPingSending - ? 'Sending...' - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) - : manualCooldownActive - ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown - : discoveryWindowActive - ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow - onPressed: () => _sendPing(context, appState), - showCooldown: false, // No longer needed - countdown shown in label - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : null, // No "Move Xm" - manual pings have no distance requirement - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : null, + // Send Ping button + // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" + // Manual pings use 15-second cooldown, no distance requirement + // When Active/Passive Mode is running, just shows "Send Ping" (disabled) + Expanded( + child: _ActionButton( + icon: Icons.cell_tower, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isTxModeRunning + ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running + : isPingSending + ? 'Sending...' + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) + : manualCooldownActive + ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown + : discoveryWindowActive + ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: (isPingSending || rxWindowActive) && + !isTxModeRunning, // Only active during manual ping flow + onPressed: () => _sendPing(context, appState), + showCooldown: + false, // No longer needed - countdown shown in label + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : null, // No "Move Xm" - manual pings have no distance requirement + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : null, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), - // Active/Hybrid Mode button (toggle) - // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon - // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle - // When OFF after being ON: shows "Cooldown Xs" like other buttons - // During manual ping: shows "Cooldown Xs" (disabled) - Expanded( - child: _ActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isPendingDisable - ? (rxWindowActive - ? 'Stopping ${rxWindowRemaining}s' - : discoveryWindowActive - ? 'Stopping ${discoveryWindowRemaining}s' - : 'Stopping...') - : isTxModeRunning - ? (isPingInProgress && !rxWindowActive && !discoveryWindowActive - ? 'Sending...' - : discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // TX RX window - : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode', - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - showCooldown: false, - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : (isPendingDisable ? 'Stopping' : null), - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : Colors.orange, + // Active/Hybrid Mode button (toggle) + // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon + // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle + // When OFF after being ON: shows "Cooldown Xs" like other buttons + // During manual ping: shows "Cooldown Xs" (disabled) + Expanded( + child: _ActionButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isPendingDisable + ? (rxWindowActive + ? 'Stopping ${rxWindowRemaining}s' + : discoveryWindowActive + ? 'Stopping ${discoveryWindowRemaining}s' + : 'Stopping...') + : isTxModeRunning + ? (isPingInProgress && + !rxWindowActive && + !discoveryWindowActive + ? 'Sending...' + : discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // TX RX window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next ping ${autoPingRemaining}s') + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode') + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode', + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + showCooldown: false, + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : (isPendingDisable ? 'Stopping' : null), + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : Colors.orange, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), ], // Passive Mode button (toggle) @@ -182,24 +245,35 @@ class PingControls extends StatelessWidget { icon: Icons.hearing, label: isPassiveModeRunning ? (discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window + ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery - : 'Passive Mode') // Initial state before first discovery + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable - ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping + ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening + ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Passive Mode', color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || + autoPingWaiting), // Active during listening/waiting phases onPressed: () => _toggleRxAuto(context, appState), ), ), @@ -231,7 +305,9 @@ class PingControls extends StatelessWidget { // Targeted Ping controls _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, ), @@ -239,7 +315,8 @@ class PingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -249,17 +326,20 @@ class PingControls extends StatelessWidget { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -274,8 +354,8 @@ class _ActionButton extends StatefulWidget { final bool isActive; final bool showCooldown; final VoidCallback onPressed; - final String? subtitle; // Optional subtitle text (e.g., "Move 5m") - final Color? subtitleColor; // Optional subtitle color + final String? subtitle; // Optional subtitle text (e.g., "Move 5m") + final Color? subtitleColor; // Optional subtitle color const _ActionButton({ required this.icon, @@ -338,7 +418,8 @@ class _ActionButtonState extends State<_ActionButton> // Use color when enabled, active (RX listening), or during cooldown // This prevents the button from going grey during cooldown final showColor = widget.enabled || widget.isActive || widget.showCooldown; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; return AnimatedBuilder( @@ -378,7 +459,8 @@ class _ActionButtonState extends State<_ActionButton> size: 26, color: showColor ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Active indicator dot if (widget.isActive) @@ -407,9 +489,12 @@ class _ActionButtonState extends State<_ActionButton> widget.label, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, color: showColor - ? (widget.isActive ? effectiveColor : colorScheme.onSurface) + ? (widget.isActive + ? effectiveColor + : colorScheme.onSurface) : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), @@ -431,7 +516,8 @@ class _ActionButtonState extends State<_ActionButton> style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: widget.subtitleColor ?? Colors.orange.shade600, + color: widget.subtitleColor ?? + Colors.orange.shade600, ), ) : null, @@ -475,7 +561,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { WidgetsBinding.instance.addPostFrameCallback((_) { final appState = context.read(); final existing = appState.targetRepeaterId; - if (existing != null && existing.isNotEmpty && _controller.text != existing) { + if (existing != null && + existing.isNotEmpty && + _controller.text != existing) { _controller.text = existing; } }); @@ -545,14 +633,17 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { final buttonColor = (isTargetedRunning || _isStarting) ? const Color(0xFF22C55E) // green-500 when running/starting : Colors.cyan; - final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + final effectiveColor = + isEnabled ? buttonColor : colorScheme.onSurfaceVariant; return Container( decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), border: Border.all( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), width: isTargetedRunning ? 1.5 : 1, ), ), @@ -567,7 +658,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { HapticFeedback.lightImpact(); if (!isTargetedRunning) { setState(() => _isStarting = true); - appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + appState.setTargetRepeaterId( + _controller.text.trim().toUpperCase()); } await appState.toggleAutoPing(AutoMode.targeted); if (mounted) setState(() => _isStarting = false); @@ -594,8 +686,13 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : 'Trace Mode', style: TextStyle( fontSize: 13, - fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, - color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: isTargetedRunning + ? FontWeight.w600 + : FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), overflow: TextOverflow.ellipsis, ), @@ -622,14 +719,16 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : colorScheme.onSurface, ), decoration: InputDecoration( - hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintText: + 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', hintStyle: TextStyle( fontSize: 12, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), counterText: '', isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), @@ -705,21 +804,28 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -737,12 +843,17 @@ class _CompactPingControlsState extends State { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Determine which button is currently active (not during cooldown) - final sendPingCurrentlyActive = (isPingSending || rxWindowActive || manualCooldownActive) && !isTxModeRunning; + final sendPingCurrentlyActive = + (isPingSending || rxWindowActive || manualCooldownActive) && + !isTxModeRunning; final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; - final passiveModeCurrentlyActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeCurrentlyActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); // Track the last active button for cooldown if (sendPingCurrentlyActive) { @@ -755,14 +866,20 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { + if (!cooldownActive && + !manualCooldownActive && + !sendPingCurrentlyActive && + !activeModeCurrentlyActive && + !passiveModeCurrentlyActive && + !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } // Determine which button should be expanded // During cooldown, the last active button stays expanded final sendPingExpanded = sendPingCurrentlyActive || - (manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing) || + (manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing) || (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); final activeModeExpanded = activeModeCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.activeMode); @@ -770,36 +887,80 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; - final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; + final sendPingEnabled = canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable; + final sendPingActive = (isPingSending || rxWindowActive) && + !isTxModeRunning && + !cooldownActive && + !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet); - final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeEnabled = isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet); + final passiveModeActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; // Trace Mode (only relevant when a repeater ID has been entered) - final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final hasTargetRepeaterId = appState.targetRepeaterId != null && + appState.targetRepeaterId!.isNotEmpty; final targetedCurrentlyActive = isTargetedRunning; final traceModeExpanded = targetedCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); - final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && - !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && - !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeEnabled = hasTargetRepeaterId && + !isTxModeRunning && + !isPassiveModeRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + !manualCooldownActive && + appState.isConnected && + prefs.externalAntennaSet && + isPowerSet; final traceModeActive = isTargetedRunning; final traceModeShowColor = traceModeEnabled || traceModeActive; // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; + final anyExpanded = sendPingExpanded || + activeModeExpanded || + passiveModeExpanded || + traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); + final allDisabled = !sendPingShowColor && + !activeModeShowColor && + !passiveModeShowColor && + (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -822,9 +983,11 @@ class _CompactPingControlsState extends State { isExpanded: sendPingExpanded, progress: rxWindowActive && !isTxModeRunning ? appState.rxWindowTimer.progress - : manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.manualPingCooldownTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : cooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.cooldownTimer.progress : null, onPressed: () => _sendPing(context, appState), @@ -857,13 +1020,18 @@ class _CompactPingControlsState extends State { isActive: activeModeActive, isExpanded: activeModeExpanded, progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning - ? (discoveryWindowActive ? appState.discoveryWindowTimer.progress : appState.rxWindowTimer.progress) + ? (discoveryWindowActive + ? appState.discoveryWindowTimer.progress + : appState.rxWindowTimer.progress) : autoPingWaiting && isTxModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.activeMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode ? appState.cooldownTimer.progress : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), ); final passiveModeButton = _CompactActionButton( @@ -890,7 +1058,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isPassiveModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode ? appState.cooldownTimer.progress : null, onPressed: () => _toggleRxAuto(context, appState), @@ -921,7 +1090,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isTargetedRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + : cooldownActive && + _lastActiveButton == _LastActiveButton.targeted ? appState.cooldownTimer.progress : null, onPressed: () { @@ -937,23 +1107,23 @@ class _CompactPingControlsState extends State { return Row( children: [ if (!txNotAllowed) ...[ - // Send Ping - expanded buttons stay big even when grey (cooldown) - if (sendPingExpanded) - Expanded(child: sendPingButton) - else if (!anyExpanded && (sendPingShowColor || allDisabled)) - Expanded(child: sendPingButton) - else - sendPingButton, - const SizedBox(width: 6), - - // Active Mode - if (activeModeExpanded) - Expanded(child: activeModeButton) - else if (!anyExpanded && (activeModeShowColor || allDisabled)) - Expanded(child: activeModeButton) - else - activeModeButton, - const SizedBox(width: 6), + // Send Ping - expanded buttons stay big even when grey (cooldown) + if (sendPingExpanded) + Expanded(child: sendPingButton) + else if (!anyExpanded && (sendPingShowColor || allDisabled)) + Expanded(child: sendPingButton) + else + sendPingButton, + const SizedBox(width: 6), + + // Active Mode + if (activeModeExpanded) + Expanded(child: activeModeButton) + else if (!anyExpanded && (activeModeShowColor || allDisabled)) + Expanded(child: activeModeButton) + else + activeModeButton, + const SizedBox(width: 6), ], // Passive Mode @@ -993,10 +1163,22 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + if (rxWindowActive) + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + if (manualCooldownActive) + return showFullText + ? 'Cooldown ${manualCooldownRemaining}s' + : '${manualCooldownRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Cooldown ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (cooldownActive) + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; return null; } @@ -1019,19 +1201,39 @@ class _CompactPingControlsState extends State { int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (rxWindowActive) + return showFullText + ? 'Stopping ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Stopping ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (isPingInProgress && !rxWindowActive) + return showFullText ? 'Sending...' : '...'; + if (rxWindowActive) + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + if (autoPingWaiting) + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1051,12 +1253,22 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1076,18 +1288,29 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isTargetedRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s') + : '${autoPingRemaining}s'; return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -1097,17 +1320,20 @@ class _CompactPingControlsState extends State { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1123,21 +1349,28 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -1153,7 +1386,9 @@ class LandscapePingControls extends StatelessWidget { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1172,58 +1407,85 @@ class LandscapePingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // TX Ping button - Expanded( - child: _LandscapeIconButton( - icon: Icons.cell_tower, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, - countdown: isPingSending - ? null - : rxWindowActive && !isTxModeRunning - ? rxWindowRemaining - : manualCooldownActive - ? manualCooldownRemaining - : discoveryWindowActive - ? discoveryWindowRemaining - : cooldownActive - ? cooldownRemaining - : null, - onPressed: () => _sendPing(context, appState), + // TX Ping button + Expanded( + child: _LandscapeIconButton( + icon: Icons.cell_tower, + tooltip: + txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: + (isPingSending || rxWindowActive) && !isTxModeRunning, + countdown: isPingSending + ? null + : rxWindowActive && !isTxModeRunning + ? rxWindowRemaining + : manualCooldownActive + ? manualCooldownRemaining + : discoveryWindowActive + ? discoveryWindowRemaining + : cooldownActive + ? cooldownRemaining + : null, + onPressed: () => _sendPing(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), - // Active/Hybrid Mode button - Expanded( - child: _LandscapeIconButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - countdown: isTxModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : rxWindowActive - ? rxWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : isPendingDisable && (rxWindowActive || discoveryWindowActive) - ? (rxWindowActive ? rxWindowRemaining : discoveryWindowRemaining) - : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + // Active/Hybrid Mode button + Expanded( + child: _LandscapeIconButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + countdown: isTxModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : rxWindowActive + ? rxWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : isPendingDisable && + (rxWindowActive || discoveryWindowActive) + ? (rxWindowActive + ? rxWindowRemaining + : discoveryWindowRemaining) + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), ], // Passive Mode button @@ -1234,10 +1496,18 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || autoPingWaiting), countdown: isPassiveModeRunning ? (discoveryWindowActive ? discoveryWindowRemaining @@ -1254,7 +1524,9 @@ class LandscapePingControls extends StatelessWidget { // Targeted Ping controls (Trace Mode) _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, compact: true, @@ -1263,22 +1535,26 @@ class LandscapePingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); await appState.sendPing(); } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1321,20 +1597,26 @@ class _LandscapeAntennaSelector extends StatelessWidget { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: externalAntennaSet ? colorScheme.onSurfaceVariant : notSetColor, + color: externalAntennaSet + ? colorScheme.onSurfaceVariant + : notSetColor, ), ), if (!externalAntennaSet) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: notSetColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text( 'Required', - style: TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: notSetColor), + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: notSetColor), ), ), ], @@ -1347,7 +1629,8 @@ class _LandscapeAntennaSelector extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), + border: + Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), ), child: Row( children: [ @@ -1361,22 +1644,30 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (!externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(left: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'Internal', style: TextStyle( fontSize: 11, - fontWeight: (!externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (!externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (!externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (!externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), ), ), // Divider - Container(width: 1, height: 18, color: colorScheme.outline.withValues(alpha: 0.3)), + Container( + width: 1, + height: 18, + color: colorScheme.outline.withValues(alpha: 0.3)), // External option Expanded( child: GestureDetector( @@ -1387,15 +1678,20 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(right: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'External', style: TextStyle( fontSize: 11, - fontWeight: (externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), @@ -1475,7 +1771,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; return AnimatedBuilder( animation: _pulseAnimation, @@ -1495,7 +1792,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.25), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.25), width: widget.isActive ? 1.5 : 1, ), ), @@ -1506,7 +1804,9 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Icon( widget.icon, size: 24, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), // Countdown badge (bottom right) if (widget.countdown != null) @@ -1514,7 +1814,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> bottom: 4, right: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), decoration: BoxDecoration( color: effectiveColor, borderRadius: BorderRadius.circular(6), @@ -1540,7 +1841,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> decoration: BoxDecoration( color: const Color(0xFF22C55E), shape: BoxShape.circle, - border: Border.all(color: colorScheme.surface, width: 1.5), + border: Border.all( + color: colorScheme.surface, width: 1.5), ), ), ), @@ -1566,7 +1868,8 @@ class _CompactActionButton extends StatefulWidget { final bool isActive; final bool isExpanded; // When true, show icon + label with wider width final VoidCallback onPressed; - final double? progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar + final double? + progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar const _CompactActionButton({ required this.icon, @@ -1625,7 +1928,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); @@ -1647,7 +1951,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(16), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.3), width: widget.isActive ? 1.5 : 1, ), ), @@ -1683,7 +1988,10 @@ class _CompactActionButtonState extends State<_CompactActionButton> Icon( widget.icon, size: 18, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Animated label - show when label is provided AnimatedSize( @@ -1698,8 +2006,13 @@ class _CompactActionButtonState extends State<_CompactActionButton> widget.label!, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), ), ], diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 10a909e1..b149cde9 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -27,7 +27,8 @@ class RegionalConfigCard extends StatelessWidget { } // When offline mode is enabled, show "-" for zone fields - final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); + final displayZoneName = + isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; return Card( @@ -41,19 +42,22 @@ class RegionalConfigCard extends StatelessWidget { children: [ Icon( isOfflineMode ? Icons.cloud_off : Icons.public, - color: isOfflineMode ? Colors.orange : Theme.of(context).colorScheme.primary, + color: isOfflineMode + ? Colors.orange + : Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( 'Regional Configuration', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), if (isOfflineMode) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -137,16 +141,16 @@ class RegionalConfigCard extends StatelessWidget { Text( 'Regional Settings', style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), if (displayZone != null) Text( displayZone, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), @@ -172,7 +176,8 @@ class RegionalConfigCard extends StatelessWidget { } /// Compact labeled row: small label on left, chips on right - Widget _buildCompactRow(BuildContext context, String label, List chips) { + Widget _buildCompactRow( + BuildContext context, String label, List chips) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -201,7 +206,8 @@ class RegionalConfigCard extends StatelessWidget { ); } - Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, + Widget _buildInfoRow( + BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( children: [ @@ -211,20 +217,26 @@ class RegionalConfigCard extends StatelessWidget { if (value != null) ...[ const SizedBox(width: 8), Expanded( - child: Text(value, style: TextStyle( - color: isOffline - ? Colors.orange.shade700 - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - )), + child: Text(value, + style: TextStyle( + color: isOffline + ? Colors.orange.shade700 + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + )), ), ], ], ); } - Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { + Widget _buildChannelChip(BuildContext context, String name, + {bool isDefault = false}) { // Public channel doesn't use # prefix; scope/plain values pass through as-is - final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + final displayName = + name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); // If it doesn't look like a channel name, show raw value (e.g. scope "Global") final isChannel = name.startsWith('#') || name == 'Public'; final label = isChannel ? displayName : name; @@ -247,7 +259,9 @@ class RegionalConfigCard extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, + color: isDefault + ? Colors.grey + : Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 43a4ca42..2144f049 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -34,10 +34,10 @@ class RepeaterIdChip extends StatelessWidget { Widget build(BuildContext context) { // Scale font size down for longer IDs final effectiveFontSize = repeaterId.length > 4 - ? fontSize - 2.0 // 6-char IDs (3-byte) + ? fontSize - 2.0 // 6-char IDs (3-byte) : repeaterId.length > 2 - ? fontSize - 1.0 // 4-char IDs (2-byte) - : fontSize; // 2-char IDs (1-byte) + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) final child = Row( mainAxisSize: MainAxisSize.min, @@ -57,7 +57,10 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.info_outline, size: fontSize - 1, - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ), ], ); @@ -80,7 +83,8 @@ class RepeaterIdChip extends StatelessWidget { /// /// When [fromLatLng] is provided, distances are measured from that point /// (e.g. the ping's GPS location) instead of the user's current position. - static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId, ({double lat, double lon})? fromLatLng}) { + static void showRepeaterPopup(BuildContext context, String repeaterId, + {String? fullHexId, ({double lat, double lon})? fromLatLng}) { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; @@ -106,7 +110,8 @@ class RepeaterIdChip extends StatelessWidget { ? fullHexId.substring(0, 8) : repeaterId; final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); if (matches.isEmpty) { @@ -130,17 +135,23 @@ class RepeaterIdChip extends StatelessWidget { // Sort by distance (closest first) when a reference point is available if (refLat != null && refLon != null) { matches.sort((a, b) { - final distA = GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); - final distB = GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); + final distA = + GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = + GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); return distA.compareTo(distB); }); } - final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, refLat: refLat, refLon: refLon, regionHopBytesOverride: regionOverride)) + .map((r) => _buildRepeaterRow(context, r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -205,7 +216,8 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; @@ -213,7 +225,10 @@ class RepeaterIdChip extends StatelessWidget { String? distanceText; if (refLat != null && refLon != null) { final meters = GpsService.distanceBetween( - refLat, refLon, repeater.lat, repeater.lon, + refLat, + refLon, + repeater.lat, + repeater.lon, ); debugLog('[UI] Distance to ${repeater.name}: ' 'from (${refLat.toStringAsFixed(5)}, ${refLon.toStringAsFixed(5)}) ' @@ -225,8 +240,7 @@ class RepeaterIdChip extends StatelessWidget { if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -235,7 +249,9 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), + _buildHexBadge( + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), + badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -268,7 +284,8 @@ class RepeaterIdChip extends StatelessWidget { distanceText, style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -314,9 +331,8 @@ class RepeaterIdChip extends StatelessWidget { return Container( constraints: const BoxConstraints(minWidth: 28), height: 28, - padding: isLong - ? const EdgeInsets.symmetric(horizontal: 5) - : EdgeInsets.zero, + padding: + isLong ? const EdgeInsets.symmetric(horizontal: 5) : EdgeInsets.zero, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(14), diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index f78950b5..4bc45fa8 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -69,10 +69,16 @@ class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { // By distance if GPS available if (position != null) { final distA = GpsService.distanceBetween( - position.latitude, position.longitude, a.lat, a.lon, + position.latitude, + position.longitude, + a.lat, + a.lon, ); final distB = GpsService.distanceBetween( - position.latitude, position.longitude, b.lat, b.lon, + position.latitude, + position.longitude, + b.lat, + b.lon, ); return distA.compareTo(distB); } @@ -227,20 +233,23 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; if (position != null) { final meters = GpsService.distanceBetween( - position!.latitude, position!.longitude, repeater.lat, repeater.lon, + position!.latitude, + position!.longitude, + repeater.lat, + repeater.lon, ); if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -323,8 +332,7 @@ class _RepeaterTile extends StatelessWidget { decoration: BoxDecoration( color: badgeColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: - Border.all(color: badgeColor.withValues(alpha: 0.4)), + border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 403b6d99..9b139b7a 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -58,7 +58,8 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -119,51 +120,102 @@ class _StatusBarState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); + return ( + 'Discovery Requests', + 'Discovery requests we have heard a response for.', + Icons.radar, + PingColors.discSuccess + ); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -180,7 +232,11 @@ class _StatusBarState extends State { icon = Icons.flight; color = Colors.grey; text = '-'; - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } // Show GPS region (e.g., "YOW") when locked and inside a zone @@ -191,7 +247,8 @@ class _StatusBarState extends State { icon = Icons.flight; color = appState.isConnected ? (appState.txAllowed ? Colors.green : Colors.red) - : Colors.grey; // Grey when not connected, red when zone is at TX capacity + : Colors + .grey; // Grey when not connected, red when zone is at TX capacity text = appState.zoneCode!; } else if (appState.inZone == false) { // GPS locked but outside any zone @@ -229,7 +286,11 @@ class _StatusBarState extends State { break; } - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } Widget _buildStatsIndicator(BuildContext context, AppStateProvider appState) { @@ -392,7 +453,8 @@ class _AnimatedStatChipState extends State<_AnimatedStatChip> child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: widget.color.withValues(alpha: _highlightAnimation.value), + color: + widget.color.withValues(alpha: _highlightAnimation.value), borderRadius: BorderRadius.circular(8), border: Border.all(color: widget.color.withValues(alpha: 0.4)), ), diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart index 4ba980ea..99660b15 100644 --- a/lib/widgets/upload_logs_dialog.dart +++ b/lib/widgets/upload_logs_dialog.dart @@ -162,15 +162,16 @@ class _UploadLogsSheetState extends State { // Build the upload list using the user's selection applied to the freshly rotated files. // Selected paths from before rotation still match, plus any newly rotated file is included. final selectedPaths = Set.from(_selectedLogFiles); - final filesToUpload = freshFiles - .where((f) => selectedPaths.contains(f.path)) - .toList(); + final filesToUpload = + freshFiles.where((f) => selectedPaths.contains(f.path)).toList(); // If the rotation produced a new file that wasn't in the original selection // (i.e. the previously-active log that just got rotated), include it too // since the user selected "all" initially and this file has new content. - final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); - if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + final newFiles = + freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && + selectedPaths.length == _availableLogFiles.length) { filesToUpload.addAll(newFiles); } @@ -191,7 +192,8 @@ class _UploadLogsSheetState extends State { final publicKey = widget.appState.devicePublicKey ?? widget.appState.lastConnectedPublicKey ?? 'not-connected'; - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; final userNotes = _descriptionController.text.trim(); int uploadedCount = 0; @@ -220,7 +222,8 @@ class _UploadLogsSheetState extends State { onProgress: (p) { _onProgressUpdate(BugReportProgress( status: p.status, - progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + progress: + (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), currentFile: i + 1, totalFiles: totalFiles, )); @@ -242,7 +245,8 @@ class _UploadLogsSheetState extends State { success: uploadedCount > 0, uploadedCount: uploadedCount, failedCount: failedCount, - errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + errorMessage: + failedCount > 0 ? '$failedCount file(s) failed to upload' : null, ); Navigator.of(context).pop(result); @@ -287,13 +291,15 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Upload Logs', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -312,13 +318,15 @@ class _UploadLogsSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, children: [ // Explanation text Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), @@ -355,7 +363,8 @@ class _UploadLogsSheetState extends State { textCapitalization: TextCapitalization.sentences, decoration: _buildInputDecoration( theme, - hintText: 'Briefly describe why you\'re uploading these logs...', + hintText: + 'Briefly describe why you\'re uploading these logs...', alignLabelWithHint: true, ), maxLines: 3, @@ -381,10 +390,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -411,10 +422,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -437,17 +450,20 @@ class _UploadLogsSheetState extends State { else Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Column( children: [ // Select all / deselect all header Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), child: Row( children: [ Text( @@ -460,7 +476,8 @@ class _UploadLogsSheetState extends State { TextButton( onPressed: () { setState(() { - if (_selectedLogFiles.length == _availableLogFiles.length) { + if (_selectedLogFiles.length == + _availableLogFiles.length) { _selectedLogFiles.clear(); } else { _selectedLogFiles.clear(); @@ -471,7 +488,8 @@ class _UploadLogsSheetState extends State { }); }, child: Text( - _selectedLogFiles.length == _availableLogFiles.length + _selectedLogFiles.length == + _availableLogFiles.length ? 'Deselect All' : 'Select All', ), @@ -481,22 +499,28 @@ class _UploadLogsSheetState extends State { ), Divider( height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: theme.colorScheme.outline + .withValues(alpha: 0.3), ), // File list ...List.generate(_availableLogFiles.length, (index) { final file = _availableLogFiles[index]; final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); + final isSelected = + _selectedLogFiles.contains(file.path); String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } return ListTile( @@ -512,9 +536,11 @@ class _UploadLogsSheetState extends State { style: const TextStyle(fontSize: 13), ), trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, + color: + theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( @@ -524,7 +550,9 @@ class _UploadLogsSheetState extends State { ), ), ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), ); }), ], @@ -589,7 +617,8 @@ class _UploadLogsSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -642,7 +671,8 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Uploading...', style: theme.textTheme.titleLarge), ], @@ -663,7 +693,8 @@ class _UploadLogsSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -678,16 +709,16 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 32), - Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), - if (_totalFiles != null && _currentFile != null) Text( 'File $_currentFile of $_totalFiles', @@ -696,7 +727,6 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 24), - SizedBox( width: 250, child: Column( @@ -705,7 +735,8 @@ class _UploadLogsSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), From 41f0adb3ac250b6821755d2178ef0b97fb2b3862 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 21:29:34 -0400 Subject: [PATCH 070/360] format with dart format using `dart format .` manual add missing curly braces in control flow statements with dartls adds check in ci to confirm formatting and remove tests and there are none # Conflicts: # lib/models/user_preferences.dart # lib/widgets/map_widget.dart --- .github/workflows/ci.yml | 4 +- bin/test_message.dart | 42 +- lib/main.dart | 49 +- lib/models/api_queue_item.dart | 21 +- lib/models/connection_state.dart | 32 +- lib/models/device_model.dart | 15 +- lib/models/log_entry.dart | 74 +- lib/models/noise_floor_session.dart | 10 +- lib/models/ping_data.dart | 15 +- lib/models/user_preferences.dart | 43 +- lib/providers/app_state_provider.dart | 1023 ++++++--- lib/screens/connection_screen.dart | 278 ++- lib/screens/graph_screen.dart | 18 +- lib/screens/home_screen.dart | 139 +- lib/screens/log_screen.dart | 378 +++- lib/screens/main_scaffold.dart | 16 +- lib/screens/settings_screen.dart | 515 +++-- lib/services/api_queue_service.dart | 99 +- lib/services/api_service.dart | 253 ++- lib/services/audio_service.dart | 18 +- lib/services/background_service.dart | 6 +- lib/services/bluetooth/mobile_bluetooth.dart | 90 +- lib/services/bluetooth/web_bluetooth.dart | 35 +- lib/services/countdown_timer_service.dart | 12 +- lib/services/custom_api_service.dart | 21 +- lib/services/debug_file_logger.dart | 4 +- lib/services/debug_submit_service.dart | 159 +- lib/services/device_model_service.dart | 13 +- lib/services/gps_service.dart | 70 +- lib/services/gps_simulator_service.dart | 85 +- lib/services/meshcore/buffer_utils.dart | 7 +- lib/services/meshcore/channel_service.dart | 45 +- lib/services/meshcore/connection.dart | 204 +- lib/services/meshcore/crypto_service.dart | 95 +- lib/services/meshcore/disc_tracker.dart | 58 +- lib/services/meshcore/packet_metadata.dart | 35 +- lib/services/meshcore/packet_parser.dart | 4 +- lib/services/meshcore/packet_validator.dart | 55 +- lib/services/meshcore/protocol_constants.dart | 22 +- lib/services/meshcore/rx_logger.dart | 113 +- lib/services/meshcore/trace_tracker.dart | 32 +- lib/services/meshcore/tx_tracker.dart | 144 +- lib/services/meshcore/unified_rx_handler.dart | 19 +- lib/services/offline_session_service.dart | 49 +- .../permission_disclosure_service.dart | 14 +- lib/services/ping_service.dart | 178 +- lib/utils/debug_logger.dart | 44 +- lib/utils/debug_logger_io.dart | 4 +- lib/utils/debug_logger_stub.dart | 28 +- lib/utils/ping_colors.dart | 88 +- lib/widgets/bug_report_dialog.dart | 533 ++--- lib/widgets/connection_panel.dart | 30 +- lib/widgets/map_widget.dart | 1921 +++++++++++++---- lib/widgets/noise_floor_chart.dart | 176 +- lib/widgets/offline_mode_toggle.dart | 15 +- lib/widgets/ping_controls.dart | 899 +++++--- lib/widgets/regional_config_card.dart | 52 +- lib/widgets/repeater_id_chip.dart | 54 +- lib/widgets/repeater_picker_sheet.dart | 24 +- lib/widgets/status_bar.dart | 104 +- lib/widgets/upload_logs_dialog.dart | 109 +- 61 files changed, 5978 insertions(+), 2714 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 951a208f..08f6a819 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,7 @@ jobs: - run: flutter pub get - run: dart run build_runner build --delete-conflicting-outputs + - run: dart format --output=none --set-exit-if-changed . - run: flutter analyze - - run: flutter test + #- run: flutter test + # no tests yet, fails without ./test directory diff --git a/bin/test_message.dart b/bin/test_message.dart index f111341e..48ab857b 100644 --- a/bin/test_message.dart +++ b/bin/test_message.dart @@ -107,8 +107,22 @@ class PayloadType { class CryptoService { /// Fixed key for "Public" channel static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a hashtag channel name using SHA-256 @@ -228,8 +242,10 @@ class PacketMetadata { final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - final int payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; - final int protocolVersion = (header >> PacketHeader.verShift) & PacketHeader.verMask; + final int payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final int protocolVersion = + (header >> PacketHeader.verShift) & PacketHeader.verMask; // Calculate offset for Path Length based on route type int pathLengthOffset = 1; @@ -427,9 +443,12 @@ void main(List arguments) { // Print packet metadata print('PACKET METADATA'); - print(' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); - print(' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); - print(' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); print(' Protocol Version: ${metadata.protocolVersion}'); print(' Path Length: ${metadata.pathLength} bytes'); @@ -444,10 +463,12 @@ void main(List arguments) { print(' Path: (empty)'); } - print(' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); + print( + ' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); if (metadata.channelHash != null) { - print(' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); } print(''); @@ -514,7 +535,8 @@ void main(List arguments) { print(''); print(' Known channel hashes:'); for (final entry in channels.entries) { - print(' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); + print( + ' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); } printValidationResults(steps, false, 'Unknown channel hash'); return; diff --git a/lib/main.dart b/lib/main.dart index 08ef6efb..985fb78c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -114,7 +114,8 @@ Future _requestPermissions() async { Future _requestiOSPermissions() async { // Note: Location permission is now requested AFTER showing the prominent disclosure // dialog in MainScaffold (required for Google Play compliance) - debugLog('[APP] iOS: Skipping location permission (handled after disclosure)'); + debugLog( + '[APP] iOS: Skipping location permission (handled after disclosure)'); // Trigger Core Bluetooth authorization by checking adapter state // This will cause iOS to show the Bluetooth permission prompt if not already granted @@ -132,7 +133,8 @@ Future _requestiOSPermissions() async { .where((state) => state == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 3), onTimeout: () { - debugLog('[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); + debugLog( + '[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); return fbp.BluetoothAdapterState.off; }); } @@ -165,36 +167,39 @@ Future _requestAndroidPermissions() async { // Dark theme - Tailwind Slate palette const darkColorScheme = ColorScheme.dark( - primary: Color(0xFF059669), // emerald-600 (main actions) + primary: Color(0xFF059669), // emerald-600 (main actions) onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 (TX ping) + secondary: Color(0xFF0284C7), // sky-600 (TX ping) onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) + tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) onTertiary: Colors.white, - surface: Color(0xFF1E293B), // slate-800 (cards/panels) - onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) - onSurfaceVariant: Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) + surface: Color(0xFF1E293B), // slate-800 (cards/panels) + onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) + onSurfaceVariant: + Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) surfaceContainerHighest: Color(0xFF0F172A), // slate-900 (main bg) - outline: Color(0xFF334155), // slate-700 (borders) - error: Color(0xFFF87171), // red-400 + outline: Color(0xFF334155), // slate-700 (borders) + error: Color(0xFFF87171), // red-400 onError: Colors.white, ); // Light theme - Tailwind Slate palette (inverted) // Note: Using darker grays for better text contrast const lightColorScheme = ColorScheme.light( - primary: Color(0xFF059669), // emerald-600 + primary: Color(0xFF059669), // emerald-600 onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 + secondary: Color(0xFF0284C7), // sky-600 onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 + tertiary: Color(0xFF4F46E5), // indigo-600 onTertiary: Colors.white, - surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) - onSurface: Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) - onSurfaceVariant: Color(0xFF475569), // slate-600 (muted text - darker for readability) + surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) + onSurface: + Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) + onSurfaceVariant: + Color(0xFF475569), // slate-600 (muted text - darker for readability) surfaceContainerHighest: Color(0xFFFFFFFF), // white (main bg) - outline: Color(0xFFCBD5E1), // slate-300 (borders) - error: Color(0xFFDC2626), // red-600 + outline: Color(0xFFCBD5E1), // slate-300 (borders) + error: Color(0xFFDC2626), // red-600 onError: Colors.white, ); @@ -206,9 +211,8 @@ class MeshMapperApp extends StatelessWidget { @override Widget build(BuildContext context) { // Create platform-appropriate Bluetooth service - final BluetoothService bluetoothService = kIsWeb - ? WebBluetoothService() - : MobileBluetoothService(); + final BluetoothService bluetoothService = + kIsWeb ? WebBluetoothService() : MobileBluetoothService(); return MultiProvider( providers: [ @@ -260,7 +264,8 @@ class _ThemedAppState extends State<_ThemedApp> { scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 appBarTheme: const AppBarTheme( backgroundColor: Color(0xFFF8FAFC), // slate-50 - foregroundColor: Color(0xFF0F172A), // slate-900 (darker for contrast) + foregroundColor: + Color(0xFF0F172A), // slate-900 (darker for contrast) ), cardTheme: CardThemeData( color: const Color(0xFFF8FAFC), // slate-50 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 0f58d317..3a197358 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -87,7 +87,8 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing + canUploadAfter: DateTime.now() + .millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, @@ -135,7 +136,8 @@ class ApiQueueItem extends HiveObject { double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" - final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; + final heardRepeats = + '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; return ApiQueueItem( type: 'DISC', latitude: latitude, @@ -163,7 +165,8 @@ class ApiQueueItem extends HiveObject { int? noiseFloor, double? power, }) { - final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + final heardRepeats = + '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( type: 'TRACE', latitude: latitude, @@ -249,7 +252,8 @@ class ApiQueueItem extends HiveObject { 'local_rssi': parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0, 'remote_snr': parts.length > 4 ? double.tryParse(parts[4]) ?? 0.0 : 0.0, 'public_key': parts.length > 5 ? parts[5] : '', - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': timestamp.millisecondsSinceEpoch ~/ + 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; @@ -261,7 +265,8 @@ class ApiQueueItem extends HiveObject { 'lon': longitude, 'noisefloor': noiseFloor, 'heard_repeats': heardRepeats, - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': + timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; @@ -281,7 +286,8 @@ class ApiQueueItem extends HiveObject { } /// Check if item is eligible for upload based on canUploadAfter - bool get isUploadEligible => DateTime.now().millisecondsSinceEpoch >= canUploadAfter; + bool get isUploadEligible => + DateTime.now().millisecondsSinceEpoch >= canUploadAfter; /// Mark as retried void markRetried() { @@ -291,5 +297,6 @@ class ApiQueueItem extends HiveObject { } @override - String toString() => 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; + String toString() => + 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; } diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index d8045983..e8072954 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -2,16 +2,16 @@ enum ConnectionStatus { /// Not connected to any device disconnected, - + /// Currently scanning for devices scanning, - + /// Connecting to device connecting, - + /// Connected and ready connected, - + /// Connection error occurred error, } @@ -27,31 +27,31 @@ enum ConnectionStep { /// Step 1: BLE GATT connect bleConnecting, - + /// Step 2: Protocol handshake protocolHandshake, - + /// Step 3: Device info query deviceQuery, - + /// Step 4: Device identification (match device model for display/reporting) powerConfiguration, - + /// Step 5: Time synchronization timeSync, - + /// Step 6: API slot acquisition slotAcquisition, - + /// Step 7: Channel setup (#wardriving) channelSetup, - + /// Step 8: GPS initialization gpsInit, - + /// Step 9: Fully connected and ready connected, - + /// Error state error, } @@ -60,13 +60,13 @@ enum ConnectionStep { enum GpsStatus { /// GPS permissions not granted permissionDenied, - + /// GPS is disabled on device disabled, - + /// Searching for GPS signal searching, - + /// GPS lock acquired locked, diff --git a/lib/models/device_model.dart b/lib/models/device_model.dart index 41ff907e..61505f98 100644 --- a/lib/models/device_model.dart +++ b/lib/models/device_model.dart @@ -1,24 +1,24 @@ /// Represents a MeshCore device model with its power configuration. -/// +/// /// This maps to the device-models.json database from the WebClient repo. /// Power configuration is critical for PA amplifier models to prevent hardware damage. class DeviceModel { /// Full manufacturer string reported by device (e.g., "Ikoka Stick-E22-30dBm (Xiao_nrf52)") final String manufacturer; - + /// Short display name (e.g., "Ikoka Stick") final String shortName; - + /// Power setting for wardrive.js (0.3, 0.6, 1.0, 2.0) /// CRITICAL: PA amplifier models require exact values final double power; - + /// Hardware platform (nrf52, esp32, esp32-s3, etc.) final String platform; - + /// Firmware TX power setting in dBm final int txPower; - + /// Additional notes about the device final String notes; @@ -55,7 +55,8 @@ class DeviceModel { } @override - String toString() => 'DeviceModel($shortName, power=$power, txPower=$txPower)'; + String toString() => + 'DeviceModel($shortName, power=$power, txPower=$txPower)'; } /// Container for the full device models database diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 26429beb..2fe84af8 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,11 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); + : events + .map((e) => e.snr != null + ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' + : '${e.repeaterId}(null)') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,7 +43,8 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ @@ -68,8 +73,10 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) - final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? + rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -190,7 +197,8 @@ class UnifiedPingLogEntry implements Comparable { final DateTime timestamp; final dynamic entry; - UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + UnifiedPingLogEntry( + {required this.type, required this.timestamp, required this.entry}); TxLogEntry get asTx => entry as TxLogEntry; RxLogEntry get asRx => entry as RxLogEntry; @@ -198,28 +206,29 @@ class UnifiedPingLogEntry implements Comparable { TraceLogEntry get asTrace => entry as TraceLogEntry; @override - int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + int compareTo(UnifiedPingLogEntry other) => + other.timestamp.compareTo(timestamp); String get timeString => switch (type) { - PingLogType.tx => asTx.timeString, - PingLogType.rx => asRx.timeString, - PingLogType.disc => asDisc.timeString, - PingLogType.trace => asTrace.timeString, - }; + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; String get locationString => switch (type) { - PingLogType.tx => asTx.locationString, - PingLogType.rx => asRx.locationString, - PingLogType.disc => asDisc.locationString, - PingLogType.trace => asTrace.locationString, - }; + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; String toCsv() => switch (type) { - PingLogType.tx => 'TX,${asTx.toCsv()}', - PingLogType.rx => 'RX,${asRx.toCsv()}', - PingLogType.disc => 'DISC,${asDisc.toCsv()}', - PingLogType.trace => 'TRC,${asTrace.toCsv()}', - }; + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; } /// User Error Entry for error log @@ -249,9 +258,9 @@ class UserErrorEntry { /// Error severity levels enum ErrorSeverity { - info, // Blue: informational messages + info, // Blue: informational messages warning, // Orange: warnings - error, // Red: errors + error, // Red: errors } /// Discovery Log Entry (discovery protocol observation) @@ -290,19 +299,24 @@ class DiscLogEntry { String toCsv() { final nodesStr = discoveredNodes.isEmpty ? 'None' - : discoveredNodes.map((n) => '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})').join(','); + : discoveredNodes + .map((n) => + '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,${noiseFloor ?? ''},${discoveredNodes.length},$nodesStr'; } } /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") - final String nodeType; // "REPEATER" or "ROOM" - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String? pubkeyHex; // Full public key hex (64 chars) for exact repeater matching + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final String nodeType; // "REPEATER" or "ROOM" + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String? + pubkeyHex; // Full public key hex (64 chars) for exact repeater matching DiscoveredNodeEntry({ required this.repeaterId, diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 6bd9fbf3..c2af3531 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -163,11 +163,11 @@ class NoiseFloorSession extends HiveObject { /// Display name for the mode String get modeDisplay => switch (mode) { - 'active' => 'Active Mode', - 'hybrid' => 'Hybrid Mode', - 'targeted' => 'Trace Mode', - _ => 'Passive Mode', - }; + 'active' => 'Active Mode', + 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', + _ => 'Passive Mode', + }; /// Formatted duration string (M:SS or H:MM:SS for long sessions) String get durationDisplay { diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 0e42d087..9d7e1051 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -7,7 +7,7 @@ part 'ping_data.g.dart'; enum PingType { @HiveField(0) tx, - + @HiveField(1) rx, } @@ -48,7 +48,8 @@ class TxPing { /// Note: power is stored in dBm but the message format uses watts /// The actual message is built in PingService with the correct watts value String toMessageFormat({double? powerWatts}) { - final coordsStr = '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + final coordsStr = + '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; final pw = powerWatts ?? 0.3; // Default to 0.3w if not provided return '@[MapperBot] $coordsStr [${pw.toStringAsFixed(1)}w]'; } @@ -70,19 +71,19 @@ class TxPing { class RxPing { @HiveField(0) final double latitude; - + @HiveField(1) final double longitude; - + @HiveField(2) final String repeaterId; - + @HiveField(3) final DateTime timestamp; - + @HiveField(4) final double snr; - + @HiveField(5) final int rssi; diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index dc16045c..5849d7aa 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -178,8 +178,14 @@ class UserPreferences { iataCode: json['iataCode'] as String?, backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, +<<<<<<< HEAD mapStyle: (json['mapStyle'] as String?) ?? 'liberty', closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, +======= + mapStyle: (json['mapStyle'] as String?) ?? 'dark', + closeAppAfterDisconnect: + (json['closeAppAfterDisconnect'] as bool?) ?? false, +>>>>>>> a431a6a (format with dart) themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, @@ -189,7 +195,8 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, - deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + deleteChannelOnDisconnect: + (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, @@ -197,13 +204,20 @@ class UserPreferences { gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, +<<<<<<< HEAD coverageOverlayOpacity: (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, +======= + disconnectAlertEnabled: + (json['disconnectAlertEnabled'] as bool?) ?? false, +>>>>>>> a431a6a (format with dart) customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, customApiUrl: json['customApiUrl'] as String?, customApiKey: json['customApiKey'] as String?, - customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, - customApiIncludeContact: (json['customApiIncludeContact'] as bool?) ?? true, + customApiDisclaimerAccepted: + (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: + (json['customApiIncludeContact'] as bool?) ?? true, ); } @@ -313,10 +327,12 @@ class UserPreferences { powerLevelSet: powerLevelSet ?? this.powerLevelSet, offlineMode: offlineMode ?? this.offlineMode, iataCode: iataCode ?? this.iataCode, - backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + backgroundModeEnabled: + backgroundModeEnabled ?? this.backgroundModeEnabled, developerModeEnabled: developerModeEnabled ?? this.developerModeEnabled, mapStyle: mapStyle ?? this.mapStyle, - closeAppAfterDisconnect: closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, + closeAppAfterDisconnect: + closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, themeMode: themeMode ?? this.themeMode, unitSystem: unitSystem ?? this.unitSystem, hybridModeEnabled: hybridModeEnabled ?? this.hybridModeEnabled, @@ -326,21 +342,30 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, - deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, - minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + deleteChannelOnDisconnect: + deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: + minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, +<<<<<<< HEAD coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, +======= + disconnectAlertEnabled: + disconnectAlertEnabled ?? this.disconnectAlertEnabled, +>>>>>>> a431a6a (format with dart) customApiEnabled: customApiEnabled ?? this.customApiEnabled, customApiUrl: customApiUrl ?? this.customApiUrl, customApiKey: customApiKey ?? this.customApiKey, - customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, - customApiIncludeContact: customApiIncludeContact ?? this.customApiIncludeContact, + customApiDisclaimerAccepted: + customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: + customApiIncludeContact ?? this.customApiIncludeContact, ); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7cb837c2..efcb20fc 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -30,7 +31,8 @@ import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; -import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; +import '../services/meshcore/packet_validator.dart' + show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; @@ -46,10 +48,13 @@ import '../utils/debug_logger_io.dart'; enum AutoMode { /// Active Mode: Sends pings on movement, listens for RX responses active, + /// Passive Mode: Listening only (no transmit) passive, + /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater targeted, } @@ -61,16 +66,22 @@ enum OverlayPingType { tx, disc, trace, rx } enum OfflineUploadResult { /// Upload completed successfully success, + /// Session file not found notFound, + /// Session data is invalid or empty invalidSession, + /// API authentication failed authFailed, + /// Some pings failed to upload partialFailure, + /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available gpsRequired, } @@ -90,11 +101,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DeviceModelService _deviceModelService; late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); - late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + late final CooldownTimer + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + late final ManualPingCooldownTimer + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) late final AutoPingTimer _autoPingTimer; late final RxWindowTimer _rxWindowTimer; - late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final DiscoveryWindowTimer + _discoveryWindowTimer; // Discovery listening window (Passive Mode) MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -111,8 +125,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; - bool _isAuthError = false; // Track if connection failed due to auth - bool _isNetworkError = false; // Track if connection failed due to network + bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; @@ -125,8 +139,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { GpsStatus _gpsStatus = GpsStatus.permissionDenied; Position? _currentPosition; ({double lat, double lon})? _lastKnownPosition; - DateTime? _lastPositionSaveTime; // Throttle position saves to every 30 seconds - bool _firstGpsLockLogged = false; // Track if we've logged first GPS lock message + DateTime? + _lastPositionSaveTime; // Throttle position saves to every 30 seconds + bool _firstGpsLockLogged = + false; // Track if we've logged first GPS lock message // Device info DeviceModel? _deviceModel; @@ -144,7 +160,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The device name to display (prefers SelfInfo name over BLE advertisement name) /// SelfInfo name reflects user's chosen name in MeshCore; BLE name may be cached/stale - String? get displayDeviceName => _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + String? get displayDeviceName => + _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); // Ping state PingStats _pingStats = const PingStats(); @@ -177,7 +194,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _traceLogEntries = []; // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _topRepeatersOverlay = []; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -191,8 +209,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { UserPreferences _preferences = const UserPreferences(); // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -228,11 +246,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isCheckingZone = false; // Zone check retry state - String? _zoneCheckError; // Error message from last failed check (null = no error) - String? _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' - int _zoneCheckRetryCountdown = 0; // Seconds until next retry (0 = not counting) - Timer? _zoneCheckRetryTimer; // Fires to trigger the retry - Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown + String? + _zoneCheckError; // Error message from last failed check (null = no error) + String? + _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' + int _zoneCheckRetryCountdown = + 0; // Seconds until next retry (0 = not counting) + Timer? _zoneCheckRetryTimer; // Fires to trigger the retry + Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown // Maintenance mode state bool _maintenanceMode = false; @@ -274,9 +295,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry bool _isInZoneGracePeriod = false; - Timer? _zoneGraceTimer; // 5-minute overall timeout - Timer? _zoneGracePollingTimer; // 5-second zone polling - Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick int _zoneGraceSecondsRemaining = 0; bool _autoPingWasEnabledBeforeGrace = false; AutoMode _autoModeBeforeGrace = AutoMode.active; @@ -311,10 +332,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _scope; // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) - int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + int? + _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = + false; // True if user manually changed hopBytes while connected + int _hopBytes = + 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = + 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; @@ -359,7 +384,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; - bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; + bool get isBluetoothOff => + _bluetoothAdapterState == BluetoothAdapterState.off; GpsStatus get gpsStatus => _gpsStatus; Position? get currentPosition => _currentPosition; ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; @@ -371,14 +397,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; - bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) - bool get isDiscoveryListening => _pingService?.isDiscoveryListening ?? false; // True during discovery listening window (for Passive Mode) + bool get isPingInProgress => + _pingService?.pingInProgress ?? + false; // True during entire ping + RX window (for auto pings) + bool get isDiscoveryListening => + _pingService?.isDiscoveryListening ?? + false; // True during discovery listening window (for Passive Mode) /// Check if auto-ping disable is pending (waiting for RX window) bool get isPendingDisable => _pingService?.pendingDisable ?? false; + /// True when running any mode that does TX (Active or Hybrid) - bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + bool get isTxModeRunning => + _autoPingEnabled && + (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) - bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + bool get isTargetedModeRunning => + _autoPingEnabled && _autoMode == AutoMode.targeted; String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; @@ -389,13 +424,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get rxPings => List.unmodifiable(_rxPings); /// Top 3 repeaters by best SNR from TX/DISC/Trace pings - List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + List<({String repeaterId, double snr, OverlayPingType type})> + get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. /// Replaces all 3 slots entirely (no carryover from previous pings). - void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + void _updateTopRepeaters( + List<({String repeaterId, double snr})> current, OverlayPingType type) { final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -419,7 +457,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { + _rxOverlayWindowTimer = + Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared }); } @@ -432,21 +471,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; } + List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get traceLogEntries => List.unmodifiable(_traceLogEntries); - List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get traceLogEntries => + List.unmodifiable(_traceLogEntries); + List get errorLogEntries => + List.unmodifiable(_errorLogEntries); List get unifiedPingLogEntries { final merged = [ - ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), - ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), - ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), - ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ..._txLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.trace, timestamp: e.timestamp, entry: e)), ]; merged.sort(); return merged; } + ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; @@ -476,7 +523,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; String? get nearestZoneCode => _nearestZone?['code'] as String?; - double? get nearestZoneDistanceKm => (_nearestZone?['distance_km'] as num?)?.toDouble(); + double? get nearestZoneDistanceKm => + (_nearestZone?['distance_km'] as num?)?.toDouble(); // Zone check retry getters String? get zoneCheckError => _zoneCheckError; @@ -546,11 +594,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; bool get enforceHybrid => _apiService.enforceHybrid; bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + bool get discDropEnabled => + _preferences.discDropEnabled || _apiService.enforceDiscDrop; int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get effectiveHopBytes => + enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; @@ -573,11 +623,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Countdown timers - CooldownTimer get cooldownTimer => _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + CooldownTimer get cooldownTimer => + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) AutoPingTimer get autoPingTimer => _autoPingTimer; RxWindowTimer get rxWindowTimer => _rxWindowTimer; - DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + DiscoveryWindowTimer get discoveryWindowTimer => + _discoveryWindowTimer; // Discovery listening window (Passive Mode) // ============================================ // Initialization @@ -596,11 +649,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize custom API forwarding service _customApiService = CustomApiService(prefsGetter: () => _preferences); _customApiService.onError = (message) { - logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Custom API: $message', + severity: ErrorSeverity.warning, autoSwitch: false); }; _customApiService.contactGetter = () { final pk = _devicePublicKey; - return (pk != null && pk.length >= 8) ? pk.substring(0, 8).toUpperCase() : null; + return (pk != null && pk.length >= 8) + ? pk.substring(0, 8).toUpperCase() + : null; }; _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; @@ -622,7 +678,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize countdown timers with notifyListeners callback for smooth UI updates _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = ManualPingCooldownTimer(onUpdate: notifyListeners); + _manualPingCooldownTimer = + ManualPingCooldownTimer(onUpdate: notifyListeners); _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); @@ -650,9 +707,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -666,7 +727,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); - debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); + debugLog( + '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); // Schedule overlay tile refresh after server has time to regenerate tiles. @@ -709,7 +771,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth adapter state changes (on/off) debugLog('[INIT] Setting up Bluetooth adapter state listener...'); - _adapterStateSubscription = _bluetoothService.adapterStateStream.listen((state) { + _adapterStateSubscription = + _bluetoothService.adapterStateStream.listen((state) { final previousState = _bluetoothAdapterState; _bluetoothAdapterState = state; @@ -725,7 +788,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); await _connectionSubscription?.cancel(); - _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { + _connectionSubscription = + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -735,7 +799,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isInZoneGracePeriod) { // BLE disconnected during zone grace period — abandon grace, full cleanup - debugLog('[CONN] BLE disconnect during zone grace period — full cleanup'); + debugLog( + '[CONN] BLE disconnect during zone grace period — full cleanup'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; @@ -743,14 +808,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingWasEnabledBeforeGrace = false; await _fullDisconnectCleanup(); } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { - debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); + debugLog( + '[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { // Normal disconnect (user-requested or no remembered device) await _fullDisconnectCleanup(); } else { // Disconnected during a reconnect attempt - _attemptReconnect handles retry - debugLog('[CONN] BLE disconnect during reconnect attempt - will retry'); + debugLog( + '[CONN] BLE disconnect during reconnect attempt - will retry'); } } notifyListeners(); @@ -769,23 +836,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Log when we transition to locked state (permission granted + GPS available) if (status == GpsStatus.locked) { - debugLog('[GPS] GPS lock acquired - zone check should trigger on first position'); + debugLog( + '[GPS] GPS lock acquired - zone check should trigger on first position'); } // Log when permission is denied or GPS disabled if (status == GpsStatus.permissionDenied) { - debugLog('[GPS] Location permission denied - zone checks will be blocked'); + debugLog( + '[GPS] Location permission denied - zone checks will be blocked'); } else if (status == GpsStatus.disabled) { - debugLog('[GPS] Location services disabled - zone checks will be blocked'); + debugLog( + '[GPS] Location services disabled - zone checks will be blocked'); } } notifyListeners(); }); - _gpsStatus = _gpsService.status; // Sync initial status + _gpsStatus = _gpsService.status; // Sync initial status debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); await _gpsPositionSubscription?.cancel(); - _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { + _gpsPositionSubscription = + _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -798,7 +869,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] First GPS lock, triggering zone check'); await checkZoneStatus(); _firstGpsLockLogged = true; - } else if (_inZone == null && _preferences.offlineMode && !_firstGpsLockLogged) { + } else if (_inZone == null && + _preferences.offlineMode && + !_firstGpsLockLogged) { debugLog('[GEOFENCE] First GPS lock skipped: offline mode enabled'); _firstGpsLockLogged = true; } @@ -806,14 +879,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check zone every 100m movement (while disconnected) // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled - if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { + if (!isConnected && + !_preferences.offlineMode && + _shouldRecheckZone(position)) { // Throttle log to once per 30s to avoid spam while driving final now = DateTime.now(); - if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_lastZoneCheckLogTime == null || + now.difference(_lastZoneCheckLogTime!) >= + const Duration(seconds: 30)) { if (_zoneCheckSuppressedCount > 0) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); } else { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); } _lastZoneCheckLogTime = now; _zoneCheckSuppressedCount = 0; @@ -857,15 +936,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'isCheckingZone=$_isCheckingZone, hasPosition=${_currentPosition != null}'); await _gpsService.startWatching(); - _gpsStatus = _gpsService.status; // Sync after restart + _gpsStatus = _gpsService.status; // Sync after restart debugLog('[GPS] GPS restarted, new status: $_gpsStatus'); - debugLog('[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}'); // If we now have a position and zone hasn't been checked, trigger check - if (_currentPosition != null && _inZone == null && !_preferences.offlineMode) { - debugLog('[GPS] Permission granted with existing position - triggering zone check'); + if (_currentPosition != null && + _inZone == null && + !_preferences.offlineMode) { + debugLog( + '[GPS] Permission granted with existing position - triggering zone check'); await checkZoneStatus(); } notifyListeners(); @@ -923,7 +1006,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!isEnabled) { debugLog('[SCAN] Bluetooth still disabled after retries'); - _connectionError = 'Bluetooth is disabled. Please enable Bluetooth and try again.'; + _connectionError = + 'Bluetooth is disabled. Please enable Bluetooth and try again.'; notifyListeners(); return; } @@ -938,21 +1022,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen for discovered devices using subscription so stopScan() can cancel DiscoveredDevice? selectedDevice; final completer = Completer(); - _activeScanSubscription = _bluetoothService.scanForDevices( + _activeScanSubscription = _bluetoothService + .scanForDevices( timeout: const Duration(seconds: 15), - ).listen( + ) + .listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { // Prefer remembered device name (from SelfInfo) over BLE cache var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + if (_rememberedDevice != null && + device.id == _rememberedDevice!.id && device.name != _rememberedDevice!.name) { enrichedDevice = DiscoveredDevice( id: device.id, name: _rememberedDevice!.name, rssi: device.rssi, ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + debugLog( + '[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); } _discoveredDevices.add(enrichedDevice); selectedDevice = enrichedDevice; @@ -1024,7 +1112,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final publicKey = _meshCoreConnection!.devicePublicKey; if (publicKey == null) { debugError('[APP] Cannot request auth: no public key'); - return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; } // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" @@ -1036,7 +1128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + debugLog( + '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1049,16 +1142,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name final deviceName = _isAnonymousRenamed ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection!.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot request auth: could not retrieve device name'); - return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); final result = await _apiService.requestAuth( reason: 'connect', @@ -1067,7 +1167,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1078,7 +1180,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); return { @@ -1115,12 +1218,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); return { 'success': false, 'reason': stage1Reason, @@ -1137,13 +1242,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); return { 'success': false, 'reason': 'registration_failed', - 'message': 'Companion not found in backend and failed to register via API' + 'message': + 'Companion not found in backend and failed to register via API' }; } @@ -1155,7 +1262,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1171,9 +1280,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); return { 'success': false, 'reason': serverReason, @@ -1208,10 +1319,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification @@ -1222,7 +1335,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { + if (deviceName != null && + deviceName.isNotEmpty && + _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } @@ -1240,7 +1355,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for noise floor updates - _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _currentNoiseFloor = noiseFloor; // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); @@ -1248,7 +1364,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for battery updates - _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { _currentBatteryPercent = batteryPercent; notifyListeners(); }); @@ -1261,16 +1378,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update preferences if device model was recognized (for display/API reporting) // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { final device = connectionResult.deviceModel!; _preferences = _preferences.copyWith( powerLevel: device.power, txPower: device.txPower, - autoPowerSet: true, // Indicates power was auto-detected from device model + autoPowerSet: + true, // Indicates power was auto-detected from device model powerLevelSet: false, // Clear stale manual flag from previous session ); notifyListeners(); - debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + debugLog( + '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } // Note: API session acquisition is now handled by the auth callback @@ -1287,7 +1407,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update unified RX handler's validator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -1301,7 +1422,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } @@ -1310,7 +1432,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Any other value (e.g., "ottawa") → derive TransportKey and set scope final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -1337,8 +1460,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); } // Configure multi-byte path hash mode on radio @@ -1363,7 +1488,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { shouldIgnoreRepeater: (String repeaterId) { final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); } return false; }, @@ -1377,13 +1503,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // External antenna must be explicitly set (yes or no) before pinging return _preferences.externalAntennaSet; }; - + _pingService!.checkPowerLevelConfigured = () { // Power is configured if: // - Auto-detected from device model, OR // - Manually selected by user, OR // - Device model is known (has default power) - return _preferences.autoPowerSet || _preferences.powerLevelSet || _deviceModel != null; + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; }; // Get external antenna value for API payloads @@ -1450,9 +1578,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1465,14 +1597,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events if (_txLogEntries.isNotEmpty) { final lastEntry = _txLogEntries.last; // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); if (timeDiff <= 10) { // Build updated events list final existingEvents = List.from(lastEntry.events); @@ -1489,7 +1623,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _audioService.playReceiveSound(); } else { // Update existing event's SNR - final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); if (idx >= 0) { existingEvents[idx] = newEvent; } @@ -1504,19 +1639,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: existingEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); // Update top repeaters overlay with current TX echoes - _updateTopRepeaters(existingEvents - .where((e) => e.snr != null) - .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), OverlayPingType.tx); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); } else { - debugLog('[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } } else { debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); @@ -1533,7 +1673,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Track idle time for auto-stop if (skipReason != null) { // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { final elapsed = DateTime.now().difference(_idleAutoStopReference!); if (elapsed >= _autoStopIdleTimeout) { _triggerIdleAutoStop(); @@ -1552,15 +1693,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up real-time disc node discovery callback (like onEchoReceived) _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog('[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); if (isNew) { _audioService.playReceiveSound(); } // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters(discPing.discoveredNodes - .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), OverlayPingType.disc); + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); notifyListeners(); }; @@ -1577,11 +1722,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTx.latitude; lon = lastTx.longitude; if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )).toList(); + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } } @@ -1606,12 +1753,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastDisc.latitude; lon = lastDisc.longitude; if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes.map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )).toList(); + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); } } @@ -1648,11 +1797,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTrace.latitude; lon = lastTrace.longitude; if (result != null && result.success) { - repeaters = [MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - )]; + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; // Update the log entry with success data _traceLogEntries[0] = TraceLogEntry( timestamp: lastTrace.timestamp, @@ -1681,7 +1832,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }; @@ -1735,20 +1887,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + final realName = _isAnonymousRenamed + ? (_originalDeviceName ?? selfInfoName) + : selfInfoName; if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName)); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } } } // Restore per-device antenna preference if previously saved // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( externalAntenna: savedAntenna, @@ -1756,12 +1914,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _antennaRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); notifyListeners(); } // Restore per-device power override if previously saved - if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { final saved = _devicePowerOverrides[resolvedName]!; _preferences = _preferences.copyWith( powerLevel: (saved['powerLevel'] as num).toDouble(), @@ -1771,7 +1931,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _powerRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); notifyListeners(); } @@ -1780,7 +1941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (txAllowed && rxAllowed) { debugLog('[CONN] Connected with full access (TX + RX allowed)'); } else if (rxAllowed) { - debugLog('[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { debugLog('[CONN] Connected with limited access'); } @@ -1818,7 +1980,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (validation != PingValidation.valid) { debugLog('[CONN] Ping validation after connect: $validation'); } - } catch (e) { debugError('[APP] Connection failed: $e'); @@ -1849,7 +2010,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (parts.length > 1) { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { @@ -1859,7 +2021,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAuthError = false; _isNetworkError = false; // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; } else { _connectionError = errorStr.replaceFirst('Exception: ', ''); @@ -1879,8 +2043,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); + _txTracker!.carpeaterPrefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog( + '[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1893,16 +2059,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + carpeaterPrefix: + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { - debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' + debugLog( + '[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging - debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); + debugLog( + '[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); // Check if repeater already has a pin in CURRENT BATCH (not all-time) // This allows new pins after batch flushes (25m movement) @@ -1924,7 +2093,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Increment RX count immediately when pin is created (not on batch flush) _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); - debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' + debugLog( + '[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); // Update RX overlay slot immediately @@ -1948,7 +2118,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); notifyListeners(); } else { - debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); + debugLog( + '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); } } catch (e, stackTrace) { debugError('[APP] Error in immediate observation callback: $e'); @@ -1961,7 +2132,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onRxEntry: (entry) async { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); - debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' + debugLog( + '[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1980,20 +2152,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + final shouldUpdateSnr = + entry.snr != null && entry.snr! > existingPin.snr; if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( - latitude: existingPin.latitude, // KEEP batch start location + latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + snr: entry.snr ?? + existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, ); - debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { - debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { @@ -2008,7 +2184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' + debugLog( + '[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -2080,7 +2257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { severity: ErrorSeverity.warning, autoSwitch: false); }, ); - + // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; @@ -2091,7 +2268,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); final validator = PacketValidator( allowedChannels: allowedChannels, @@ -2104,15 +2282,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxLogger: _rxLogger!, validator: validator, ); - + // Subscribe to LogRxData stream - _logRxDataSubscription = _meshCoreConnection!.logRxDataStream.listen((data) { + _logRxDataSubscription = + _meshCoreConnection!.logRxDataStream.listen((data) { _unifiedRxHandler!.handlePacket(data.raw, data.snr, data.rssi); }); - + // Start listening _unifiedRxHandler!.startListening(); - + debugLog('[APP] Unified RX handler created and listening'); } @@ -2134,14 +2313,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; _traceHopBytes = 1; } final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) final deviceHopBytes = deviceMode + 1; if (effective != deviceHopBytes && _originalPathHashMode != null) { @@ -2151,7 +2332,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = effective; // Update runtime state to reflect new mode _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2166,13 +2348,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else if (_originalPathHashMode == null && effective > 1) { // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + debugWarn( + '[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: effective, reason: 'firmware_unsupported'); notifyListeners(); } } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + debugLog( + '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); } } @@ -2182,7 +2367,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) return; if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + debugLog( + '[PATH] User manually changed path mode, not restoring on disconnect'); _originalPathHashMode = null; _userChangedPathMode = false; return; @@ -2195,12 +2381,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_hopBytes != originalHopBytes) { try { await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + debugLog( + '[PATH] Restored path hash mode to original: $originalHopBytes-byte'); } catch (e) { debugError('[PATH] Failed to restore path hash mode: $e'); } } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + debugLog( + '[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); } _originalPathHashMode = null; _userChangedPathMode = false; @@ -2211,7 +2399,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) { // Old firmware — can't send command, show warning debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: newHopBytes, reason: 'firmware_unsupported'); _hopBytes = 1; // Force back to 1 notifyListeners(); return; @@ -2230,7 +2419,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + debugLog( + '[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -2261,7 +2451,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => + _pendingPathHashWarning; /// Clear the pending warning after UI has shown it void clearPathHashWarning() { @@ -2406,7 +2597,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService = null; // Do NOT release API session or clear API queue - debugLog('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); + debugLog( + '[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); notifyListeners(); @@ -2423,40 +2615,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Attempt a single reconnection void _attemptReconnect() { if (_reconnectAttempt >= _maxReconnectAttempts) { - debugLog('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); + debugLog( + '[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); _abandonAutoReconnect(); return; } _reconnectAttempt++; - debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); + debugLog( + '[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); // Use longer delay after bond errors to give iOS time to clear stale keys - final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + final delay = _lastReconnectWasBondError + ? _reconnectDelayAfterBondError + : _reconnectDelay; // Delay before attempting reconnection _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { - debugLog('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); + debugLog( + '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { - debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + debugLog( + '[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again - debugLog('[CONN] Auto-reconnect: connection did not complete, retrying...'); + debugLog( + '[CONN] Auto-reconnect: connection did not complete, retrying...'); _connectionStep = ConnectionStep.reconnecting; notifyListeners(); _attemptReconnect(); } } catch (e) { - debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); + debugError( + '[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { // Check for iOS apple-code 14 (Peer removed pairing information) // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying @@ -2479,10 +2679,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { if (!isConnected || _autoPingEnabled) return; debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); - logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + logError('Disconnected: 15 minutes of inactivity', + severity: ErrorSeverity.warning); disconnect(); }); - debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + debugLog( + '[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); } /// Cancel the idle disconnect timer @@ -2497,11 +2699,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry Future _handleBondErrorIfNeeded(Object error) async { final errorStr = error.toString(); - if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + if (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')) { _lastReconnectWasBondError = true; final deviceId = _rememberedDevice?.id; if (deviceId != null) { - debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + debugLog( + '[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); await _bluetoothService.removeBond(deviceId); } } @@ -2523,7 +2728,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - debugLog('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); + debugLog( + '[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); // Restore auto-ping if it was active if (wasAutoPing) { @@ -2537,13 +2743,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + debugLog( + '[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { toggleAutoPing(previousMode); - debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); + debugLog( + '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); } else { @@ -2582,7 +2790,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); // Reset anonymous mode state (BLE already gone, can't restore name) @@ -2671,11 +2880,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed && _originalDeviceName != null) { try { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + debugLog( + '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); + logError( + 'Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, + autoSwitch: false); } _isAnonymousRenamed = false; _originalDeviceName = null; @@ -2709,7 +2921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); - + // Cancel stream subscriptions await _noiseFloorSubscription?.cancel(); _noiseFloorSubscription = null; @@ -2730,7 +2942,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _displayDeviceName = null; _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; @@ -2830,7 +3043,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { - debugWarn('[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); + debugWarn( + '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors return false; } @@ -2851,7 +3065,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); - logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Auto-ping stopped: no movement for 30 minutes', + severity: ErrorSeverity.warning, autoSwitch: false); _idleAutoStopReference = null; toggleAutoPing(_autoMode); } @@ -2919,7 +3134,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Passive Mode is listening only, no cooldown needed if (isTxMode) { _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2936,7 +3152,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Block starting if shared cooldown is active (TX modes only) // Passive Mode is listening only and can start during cooldown if (isTxMode && _cooldownTimer.isRunning) { - debugLog('[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); return false; } @@ -2967,7 +3184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Set interval from user preferences before starting final intervalMs = _preferences.autoPingInterval * 1000; _pingService!.setAutoPingInterval(intervalMs); - debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); final started = await _pingService!.enableAutoPing( passiveMode: isPassive, @@ -2978,7 +3196,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { - debugLog('[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); } else { debugLog('[PING] Auto mode start blocked'); } @@ -2991,7 +3210,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -3011,7 +3236,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -3050,7 +3281,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } - debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); + debugLog( + '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); notifyListeners(); } @@ -3060,14 +3292,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_traceLogEntries.length > _maxLogEntries) { _traceLogEntries.removeLast(); } - debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + debugLog( + '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); // Update top repeaters overlay with successful trace result if (entry.success && entry.localSnr != null) { // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace); } notifyListeners(); @@ -3075,13 +3309,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab - void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { + void logError(String message, + {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { _errorLogEntries.add(UserErrorEntry( timestamp: DateTime.now(), message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) _errorLogEntries.removeAt(0); + if (_errorLogEntries.length > _maxErrorEntries) { + _errorLogEntries.removeAt(0); + } if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3129,9 +3366,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Hot-switch while connected - return enabled - ? await _switchToOfflineMode() - : await _switchToOnlineMode(); + return enabled ? await _switchToOfflineMode() : await _switchToOnlineMode(); } /// Simple offline mode change (when not connected) @@ -3158,7 +3393,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); + debugLog( + '[GEOFENCE] Re-checking zone status after offline mode disabled'); checkZoneStatus(); } } @@ -3257,13 +3493,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot switch to online mode: no device name available'); + debugError( + '[APP] Cannot switch to online mode: no device name available'); _modeSwitchError = 'Device name not available'; return (success: false, error: _modeSwitchError); } if (_devicePublicKey == null) { - debugError('[APP] Cannot switch to online mode: no public key available'); + debugError( + '[APP] Cannot switch to online mode: no public key available'); _modeSwitchError = 'Device public key not available'; return (success: false, error: _modeSwitchError); } @@ -3280,17 +3518,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (zoneCode == null) { debugError('[APP] Cannot switch to online mode: not in a zone'); - _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + _modeSwitchError = + 'Could not determine your zone. Check GPS and internet connection.'; return (success: false, error: _modeSwitchError); } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; var result = await _apiService.requestAuth( reason: 'connect', @@ -3310,10 +3551,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); - _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; + _modeSwitchError = + _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } @@ -3333,11 +3576,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } else { // Stage 1 failed — check if Stage 2 is worth attempting - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); _modeSwitchError = result['message'] as String? ?? 'GPS error'; return (success: false, error: _modeSwitchError); } @@ -3351,10 +3596,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); - _modeSwitchError = 'Companion not found in backend and failed to register via API'; + _modeSwitchError = + 'Companion not found in backend and failed to register via API'; return (success: false, error: _modeSwitchError); } @@ -3378,9 +3625,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); _modeSwitchError = serverMessage ?? 'Registration rejected by server'; return (success: false, error: _modeSwitchError); } @@ -3509,7 +3758,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Note: Connection already validates device name exists, so this should never be null final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, @@ -3525,14 +3775,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) { + return; + } final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); _offlineSessionService.updateCurrentSession( pings, @@ -3582,7 +3835,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (success) { // Delete the session file on successful upload await _offlineSessionService.deleteSession(filename); - debugLog('[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); + debugLog( + '[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); } else { debugError('[API] Failed to upload offline session: $filename'); } @@ -3607,7 +3861,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }) async { // Concurrency guard — only one offline upload at a time if (_isUploadingOfflineSession) { - debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + debugWarn( + '[OFFLINE] Upload already in progress, rejecting concurrent request'); return OfflineUploadResult.uploadInProgress; } @@ -3615,7 +3870,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); try { - return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + return await _uploadOfflineSessionIsolated(filename, + onProgress: onProgress); } finally { _isUploadingOfflineSession = false; notifyListeners(); @@ -3662,13 +3918,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 3. Check GPS before auth — the server requires current coordinates for geo-auth if (_currentPosition == null) { - debugError('[OFFLINE] Upload requires GPS - location services not available'); + debugError( + '[OFFLINE] Upload requires GPS - location services not available'); return OfflineUploadResult.gpsRequired; } // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. - debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); + debugLog( + '[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3697,7 +3955,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stage 2: If unknown_device and we have a stored contactUri, attempt registration if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + debugLog( + '[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); final registerResult = await _apiService.requestAuth( reason: 'register', contactUri: session.contactUri, @@ -3719,7 +3978,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + debugLog( + '[OFFLINE] Stage 2 succeeded: device registered for offline upload'); effectiveAuth = registerResult; } else { debugError('[OFFLINE] Auth failed: $reason'); @@ -3734,7 +3994,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); + debugLog( + '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); @@ -3750,7 +4011,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + final result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); @@ -3779,7 +4041,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn( + '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -3803,7 +4066,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { - debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' + debugLog( + '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); _preferences = preferences; @@ -3813,26 +4077,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final deviceName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); - debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + debugLog( + '[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } // Persist power override per device name - if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + if (deviceName != null && + preferences.powerLevelSet && + !preferences.autoPowerSet) { _devicePowerOverrides[deviceName] = { 'powerLevel': preferences.powerLevel, 'txPower': preferences.txPower, }; _saveDevicePowerOverrides(); - debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + debugLog( + '[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); } else if (deviceName != null && preferences.autoPowerSet) { // User re-selected the auto-detected value — clear any saved override if (_devicePowerOverrides.remove(deviceName) != null) { _saveDevicePowerOverrides(); - debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + debugLog( + '[APP] Cleared power override for "$deviceName" (auto-detected selected)'); } } @@ -3843,7 +4113,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _syncCarpeaterPrefix(); // Propagate min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; notifyListeners(); @@ -3859,7 +4130,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + if (_connectionStatus == ConnectionStatus.connected && + _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { _requestConnectionTabSwitch = true; @@ -3874,7 +4146,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Propagate carpeaterPrefix to live TxTracker and RxLogger void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + final prefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; if (_txTracker != null) { _txTracker!.carpeaterPrefix = prefix; debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); @@ -3944,7 +4217,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setColorVisionType(String type) { _preferences = _preferences.copyWith(colorVisionType: type); PingColors.setColorVisionType( - ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ColorVisionType.values.firstWhere((e) => e.name == type, + orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); notifyListeners(); @@ -4025,7 +4299,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { + return; + } debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } @@ -4109,13 +4385,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { - debugWarn('[API] Rate limited - continuing without disconnect: $userMessage'); + debugWarn( + '[API] Rate limited - continuing without disconnect: $userMessage'); return; } // Zone grace period: intercept outside_zone during active session if (reason == 'outside_zone' && _isInZoneGracePeriod) { - debugLog('[ZONE GRACE] outside_zone during grace period — already handling'); + debugLog( + '[ZONE GRACE] outside_zone during grace period — already handling'); return; } if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { @@ -4171,7 +4449,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, ); - debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + debugLog( + '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); } } catch (e) { debugError('[APP] Failed to preserve queue to offline storage: $e'); @@ -4185,7 +4464,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Handle maintenance mode while connected - end session and log error - Future _handleMaintenanceModeConnected(String message, String? url) async { + Future _handleMaintenanceModeConnected( + String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); // Alert if auto-ping was running (maintenance is not user-initiated) @@ -4194,7 +4474,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Log to error log (this sets _requestErrorLogSwitch = true) - logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); + logError('Maintenance Mode Enabled: $message', + severity: ErrorSeverity.warning); // Disconnect (ends session, cleans up) await disconnect(); @@ -4225,7 +4506,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start periodic polling to check if maintenance mode has ended void _startMaintenancePolling() { _maintenanceCheckTimer?.cancel(); - _maintenanceCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + _maintenanceCheckTimer = + Timer.periodic(const Duration(seconds: 30), (_) async { if (!_maintenanceMode) { _maintenanceCheckTimer?.cancel(); _maintenanceCheckTimer = null; @@ -4255,7 +4537,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Validate GPS position for API calls /// Returns (isValid, errorMessage, errorCode) tuple - ({bool isValid, String? errorMessage, String? errorCode}) _validateGps(Position? position) { + ({bool isValid, String? errorMessage, String? errorCode}) _validateGps( + Position? position) { if (position == null) { return ( isValid: false, @@ -4269,7 +4552,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ageSeconds > _maxGpsAgeSeconds) { return ( isValid: false, - errorMessage: 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', + errorMessage: + 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', errorCode: 'gps_stale', ); } @@ -4278,7 +4562,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (position.accuracy > _maxGpsAccuracyMeters) { return ( isValid: false, - errorMessage: 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', + errorMessage: + 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', errorCode: 'gps_inaccurate', ); } @@ -4317,7 +4602,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Schedule a zone check retry with countdown timer for UI feedback - void _scheduleZoneCheckRetry({required int seconds, required String error, required String reason}) { + void _scheduleZoneCheckRetry( + {required int seconds, required String error, required String reason}) { // Cancel any existing timers _zoneCheckRetryTimer?.cancel(); _zoneCheckCountdownTimer?.cancel(); @@ -4358,11 +4644,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Should be called on app launch and every 100m of GPS movement while disconnected Future checkZoneStatus() async { debugLog('[GEOFENCE] checkZoneStatus() called'); - debugLog('[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); if (_currentPosition == null) { - debugLog('[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); + debugLog( + '[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); return; } @@ -4372,18 +4660,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (_isCheckingZone) { - debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); + debugLog( + '[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; } - debugLog('[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); + debugLog( + '[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); _isCheckingZone = true; // Don't clear error or notify here — keep current error view visible during retry // to avoid a full-screen flash. Error is cleared in finally block on success, // or overwritten by _scheduleZoneCheckRetry on failure. try { - debugLog('[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); final result = await _apiService.checkZoneStatus( @@ -4393,7 +4684,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, ); - debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); + debugLog( + '[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { // Update position even on failure to prevent zone check flooding @@ -4416,7 +4708,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); // Start polling to detect when maintenance ends _startMaintenancePolling(); @@ -4433,8 +4726,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final success = result['success'] == true; if (!success) { final reason = result['reason'] as String?; - final message = result['message'] as String? ?? 'Zone status check failed'; - debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + final message = + result['message'] as String? ?? 'Zone status check failed'; + debugError( + '[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); @@ -4448,14 +4743,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 30, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 30, error: errorMsg, reason: reason!); } else if (reason == 'bad_key' || reason == 'invalid_request') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 60, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 60, error: errorMsg, reason: reason!); } else { // Unknown server errors — use server message - _scheduleZoneCheckRetry(seconds: 15, error: message, reason: 'server_error'); + _scheduleZoneCheckRetry( + seconds: 15, error: message, reason: 'server_error'); } return; @@ -4487,14 +4785,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check + _fetchRepeatersForZone( + newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; - final distanceKm = (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; - debugWarn('[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + final distanceKm = + (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; + debugWarn( + '[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); // Clear repeaters when exiting zone _repeaters = []; @@ -4505,7 +4806,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check error: $e'); } finally { _isCheckingZone = false; - debugLog('[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); notifyListeners(); } @@ -4521,11 +4823,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth response includes slot data, use it directly (forward-compatible) if (authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = authResult['slots_available']; - debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + debugLog( + '[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); } if (authResult.containsKey('slots_max')) { _currentZone!['slots_max'] = authResult['slots_max']; - debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + debugLog( + '[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); } // Sync at_capacity with tx_allowed @@ -4535,7 +4839,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth says TX not allowed and server didn't provide slot data, set slots to 0 if (!authTxAllowed && !authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = 0; - debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + debugLog( + '[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); } // If auth says TX allowed and we have slot data but server didn't provide updated count, @@ -4593,8 +4898,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _startZoneGracePeriod() async { if (_isInZoneGracePeriod) return; _isInZoneGracePeriod = true; - debugLog('[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); - logError('Left wardriving zone. Searching for nearby zone...', severity: ErrorSeverity.warning, autoSwitch: false); + debugLog( + '[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', + severity: ErrorSeverity.warning, autoSwitch: false); // Save auto-ping state for restoration on zone re-entry _autoPingWasEnabledBeforeGrace = _autoPingEnabled; @@ -4676,18 +4983,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + debugLog( + '[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume if (_sessionZoneCode != null && reEnteredZoneCode.isNotEmpty && reEnteredZoneCode != _sessionZoneCode) { - debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + debugLog( + '[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + await _handleZoneTransfer( + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); return; } @@ -4708,8 +5018,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - debugLog('[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); - logError('Re-entered wardriving zone. Resuming...', severity: ErrorSeverity.info, autoSwitch: false); + debugLog( + '[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', + severity: ErrorSeverity.info, autoSwitch: false); // Re-enable heartbeat _apiService.enableHeartbeat( @@ -4735,7 +5047,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -4782,7 +5095,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle zone-to-zone transfer during active wardriving session. /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. - Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + Future _handleZoneTransfer( + String newZoneCode, String newZoneName) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -4845,7 +5159,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + if (_devicePublicKey == null || + deviceName == null || + _currentPosition == null) { debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); await disconnect(); return; @@ -4860,7 +5176,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4869,7 +5186,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 10. Check auth result if (result == null) { debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); - logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + logError('Zone transfer failed: unable to reach server', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4887,8 +5205,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (result['success'] != true) { final reason = result['reason'] as String? ?? 'unknown'; final message = result['message'] as String? ?? 'Auth failed'; - debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + debugError( + '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4911,7 +5231,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 13. Update PacketValidator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -4925,13 +5246,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + debugLog( + '[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); } // 14. Update flood scope from new auth response final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -4960,8 +5283,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); } // 16. Reconfigure path hash mode if new zone requires different hop bytes @@ -5000,7 +5325,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -5053,7 +5379,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); notifyListeners(); } else { - debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + debugWarn( + '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); @@ -5304,7 +5631,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load a route file (KML or GPX) bool loadSimulatorRoute(String content, {String? filename}) { - final success = _gpsService.simulator.loadRoute(content, filename: filename); + final success = + _gpsService.simulator.loadRoute(content, filename: filename); if (success) { _gpsSimulatorPattern = SimulatorPattern.route; // If simulator is running, it will automatically use the new route @@ -5363,7 +5691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Attempt to recover from Hive corruption - Future?> _attemptHiveRecovery(String boxName, Duration timeout) async { + Future?> _attemptHiveRecovery( + String boxName, Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -5377,7 +5706,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return box; } catch (e) { debugError('[HIVE] Recovery failed for "$boxName": $e'); - logError('Storage for "$boxName" unavailable - some settings may not persist'); + logError( + 'Storage for "$boxName" unavailable - some settings may not persist'); return null; } } @@ -5393,7 +5723,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('device'); if (json != null) { - _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); + _rememberedDevice = + RememberedDevice.fromJson(Map.from(json)); debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); notifyListeners(); } @@ -5478,13 +5809,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('preferences'); if (json != null) { - _preferences = UserPreferences.fromJson(Map.from(json)); - debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' + _preferences = + UserPreferences.fromJson(Map.from(json)); + debugLog( + '[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); // Apply saved min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; // Apply saved color vision type @@ -5528,7 +5862,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_antenna_preferences'); if (raw != null) { _deviceAntennaPreferences = Map.from(raw as Map); - debugLog('[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); + debugLog( + '[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device antenna preferences: $e'); @@ -5560,9 +5895,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_power_overrides'); if (raw != null) { _devicePowerOverrides = (raw as Map).map( - (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + (key, value) => + MapEntry(key.toString(), Map.from(value as Map)), ); - debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + debugLog( + '[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device power overrides: $e'); @@ -5591,10 +5928,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (box == null) return; try { - _lastConnectedDeviceName = box.get('last_connected_device_name') as String?; + _lastConnectedDeviceName = + box.get('last_connected_device_name') as String?; _lastConnectedPublicKey = box.get('last_connected_public_key') as String?; if (_lastConnectedDeviceName != null) { - debugLog('[APP] Loaded last connected device: $_lastConnectedDeviceName'); + debugLog( + '[APP] Loaded last connected device: $_lastConnectedDeviceName'); } } catch (e) { debugLog('[APP] Failed to load last connected device: $e'); @@ -5602,7 +5941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save last connected device info to Hive storage - Future _saveLastConnectedDevice(String deviceName, String publicKey) async { + Future _saveLastConnectedDevice( + String deviceName, String publicKey) async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; @@ -5693,34 +6033,45 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[HIVE] Opening typed box "$_noiseFloorSessionBoxName"...'); try { - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); return box; } on TimeoutException { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } catch (e) { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } } /// Attempt to recover from Hive corruption for noise floor box - Future?> _attemptNoiseFloorBoxRecovery(Duration timeout) async { + Future?> _attemptNoiseFloorBoxRecovery( + Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$_noiseFloorSessionBoxName"...'); await Hive.deleteBoxFromDisk(_noiseFloorSessionBoxName); debugLog('[HIVE] Retrying open...'); // Notify user that cleanup happened - logError('Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); - - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); + + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); return box; } catch (e) { debugError('[HIVE] Recovery failed for "$_noiseFloorSessionBoxName": $e'); - logError('Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); return null; } } @@ -5736,7 +6087,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _storedNoiseFloorSessions = _noiseFloorSessionBox!.values.toList() ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Newest first - debugLog('[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); + debugLog( + '[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); } catch (e) { debugError('[GRAPH] Failed to load noise floor sessions: $e'); _storedNoiseFloorSessions = []; @@ -5799,7 +6151,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_currentNoiseFloorSession == null) return; _currentNoiseFloorSession!.endTime = DateTime.now(); - debugLog('[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' + debugLog( + '[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' '${_currentNoiseFloorSession!.samples.length} samples, ' '${_currentNoiseFloorSession!.markers.length} markers'); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 2ebb0da3..5e6b9a79 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -24,7 +24,8 @@ class ConnectionScreen extends StatefulWidget { State createState() => _ConnectionScreenState(); } -class _ConnectionScreenState extends State with WidgetsBindingObserver { +class _ConnectionScreenState extends State + with WidgetsBindingObserver { @override void initState() { super.initState(); @@ -125,7 +126,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (pathWarning != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + _showPathHashWarning( + context, pathWarning.hopBytes, pathWarning.reason); appState.clearPathHashWarning(); }); } @@ -234,10 +236,12 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { + Widget _buildConnectionProgress( + BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -272,7 +276,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildZoneGraceView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final nearestName = appState.nearestZoneName; final nearestDistance = appState.nearestZoneDistanceKm; final hasNearestInfo = nearestName != null && nearestDistance != null; @@ -299,7 +304,10 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -322,14 +330,20 @@ class _ConnectionScreenState extends State with WidgetsBinding height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), const SizedBox(width: 8), Text( 'Searching for zone...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), ], @@ -346,8 +360,10 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildZoneTransferView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildZoneTransferView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final from = appState.zoneTransferFrom ?? '?'; final to = appState.zoneTransferTo ?? '?'; @@ -368,7 +384,10 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( '$from → $to', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), SizedBox(height: isLandscape ? 8 : 12), @@ -380,14 +399,20 @@ class _ConnectionScreenState extends State with WidgetsBinding height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), const SizedBox(width: 8), Text( 'Re-authenticating...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), ], @@ -404,8 +429,10 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildReconnectingView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; return SafeArea( @@ -425,14 +452,20 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Attempt ${appState.reconnectAttempt} of 3', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), const SizedBox(height: 4), Text( deviceName, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), SizedBox(height: isLandscape ? 16 : 24), @@ -459,7 +492,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (semverMatch != null) { version = semverMatch.group(1); } else { - final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + final nightlyMatch = + RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); if (nightlyMatch != null) { version = nightlyMatch.group(1); } @@ -468,7 +502,8 @@ class _ConnectionScreenState extends State with WidgetsBinding if (version == null) { final manufacturerString = appState.manufacturerString; if (manufacturerString != null) { - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final versionRegex = + RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); final match = versionRegex.firstMatch(manufacturerString); if (match != null) { version = match.group(1); @@ -476,12 +511,17 @@ class _ConnectionScreenState extends State with WidgetsBinding } } - final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; + final hardware = appState.deviceModel?.shortName ?? + appState.manufacturerString ?? + 'Unknown'; final platform = appState.deviceModel?.platform; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final prefs = appState.preferences; final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Compact device summary card final deviceSummaryCard = Card( @@ -494,7 +534,8 @@ class _ConnectionScreenState extends State with WidgetsBinding // Header: BT icon + name/status Row( children: [ - const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const Icon(Icons.bluetooth_connected, + color: Colors.green, size: 20), const SizedBox(width: 8), Expanded( child: Column( @@ -503,15 +544,15 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( deviceName, style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), overflow: TextOverflow.ellipsis, ), Text( 'Connected', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.green, - ), + color: Colors.green, + ), ), ], ), @@ -526,8 +567,10 @@ class _ConnectionScreenState extends State with WidgetsBinding runSpacing: 4, children: [ _buildDetailChip(context, Icons.memory, hardware), - if (version != null) _buildDetailChip(context, Icons.code, version), - if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + if (version != null) + _buildDetailChip(context, Icons.code, version), + if (platform != null) + _buildDetailChip(context, Icons.developer_board, platform), ], ), @@ -606,7 +649,9 @@ class _ConnectionScreenState extends State with WidgetsBinding return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: InkWell( - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showPowerLevelSelector(context, appState), borderRadius: BorderRadius.circular(4), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -622,10 +667,15 @@ class _ConnectionScreenState extends State with WidgetsBinding Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + Icon(Icons.bolt, + size: 16, + color: + isPowerSet ? Colors.amber.shade700 : Colors.orange), const SizedBox(width: 4), Text( - isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + isPowerSet + ? prefs.powerLevelDisplay + : 'Unknown - tap to set', style: TextStyle( fontWeight: FontWeight.w500, color: isPowerSet ? null : Colors.orange, @@ -633,7 +683,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ), if (prefs.autoPowerSet) ...[ const SizedBox(width: 4), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const Icon(Icons.auto_awesome, + size: 14, color: Colors.green), const SizedBox(width: 2), const Text( 'Auto', @@ -643,7 +694,9 @@ class _ConnectionScreenState extends State with WidgetsBinding fontWeight: FontWeight.bold, ), ), - ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + ] else if (prefs.powerLevelSet && + !prefs.autoPowerSet && + appState.deviceModel != null) ...[ const SizedBox(width: 4), const Icon(Icons.edit, size: 14, color: Colors.orange), const SizedBox(width: 2), @@ -658,7 +711,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ], if (!isAutoMode) ...[ const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + const Icon(Icons.chevron_right, + size: 16, color: Colors.grey), ], ], ), @@ -695,8 +749,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - - Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -841,17 +893,19 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.verified_user, color: Colors.blue, size: 20), + child: const Icon(Icons.verified_user, + color: Colors.blue, size: 20), ), const SizedBox(width: 12), Expanded( child: Text( 'Registration Methods', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -875,7 +929,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.green, title: 'Mesh', trustLevel: 'Most trusted', - description: 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', + description: + 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', isCurrentType: currentType == 'Mesh', ), const SizedBox(height: 12), @@ -885,7 +940,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.blue, title: 'API', trustLevel: 'Trusted', - description: 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', + description: + 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', isCurrentType: currentType == 'API', ), const SizedBox(height: 12), @@ -895,7 +951,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.orange, title: 'Manual', trustLevel: 'Basic', - description: 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', + description: + 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', isCurrentType: currentType == 'Manual', ), ], @@ -924,7 +981,9 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: isCurrentType ? color.withValues(alpha: 0.1) : null, borderRadius: BorderRadius.circular(8), - border: isCurrentType ? Border.all(color: color.withValues(alpha: 0.4)) : null, + border: isCurrentType + ? Border.all(color: color.withValues(alpha: 0.4)) + : null, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -949,13 +1008,15 @@ class _ConnectionScreenState extends State with WidgetsBinding trustLevel, style: TextStyle( fontSize: 11, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: + theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), if (isCurrentType) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(4), @@ -988,11 +1049,13 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { + void _showPowerLevelSelector( + BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; // Only show selection if power has been set (auto or manual) - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; + final isPowerSet = + prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; final currentPower = isPowerSet ? prefs.powerLevel : null; // Helper to handle power selection with confirmation for overrides @@ -1040,7 +1103,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, - powerLevelSet: true, // Mark as manually set + powerLevelSet: true, // Mark as manually set ), ); Navigator.pop(context); @@ -1061,8 +1124,10 @@ class _ConnectionScreenState extends State with WidgetsBinding padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), - border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange) + .withValues(alpha: 0.1), + border: Border.all( + color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -1097,7 +1162,8 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = deviceModel != null && power == deviceModel.power; + final isRecommended = + deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -1105,10 +1171,12 @@ class _ConnectionScreenState extends State with WidgetsBinding return RadioListTile( title: Row( children: [ - Flexible(child: Text(tempPrefs.powerLevelDisplayWithDbm)), + Flexible( + child: Text(tempPrefs.powerLevelDisplayWithDbm)), if (isRecommended) ...[ const SizedBox(width: 8), - const Icon(Icons.check_circle, size: 16, color: Colors.green), + const Icon(Icons.check_circle, + size: 16, color: Colors.green), ], ], ), @@ -1157,7 +1225,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); Navigator.pop(context); }, - child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + child: const Text('Reset to Auto', + style: TextStyle(color: Colors.green)), ), TextButton( onPressed: () => Navigator.pop(context), @@ -1169,7 +1238,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildError(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -1187,7 +1257,9 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( appState.isNetworkError ? 'Server Unreachable' - : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + : appState.isAuthError + ? 'Authentication Failed' + : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -1226,24 +1298,24 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.gps_off; locationText = '-'; locationColor = Colors.grey; - // Check maintenance mode + // Check maintenance mode } else if (appState.maintenanceMode) { locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; - // Network error: show wifi off indicator + // Network error: show wifi off indicator } else if (appState.zoneCheckErrorReason == 'network') { locationIcon = Icons.wifi_off; locationText = 'No Internet'; locationColor = Colors.red; - // GPS error: show GPS issue indicator + // GPS error: show GPS issue indicator } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || - appState.zoneCheckErrorReason == 'gps_stale') { + appState.zoneCheckErrorReason == 'gps_stale') { locationIcon = Icons.gps_off; locationText = 'GPS Unavailable'; locationColor = Colors.orange; - // Show "Checking Zone..." whenever a zone check is in progress - // This provides consistent UI feedback during both initial and re-checks + // Show "Checking Zone..." whenever a zone check is in progress + // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { locationIcon = Icons.location_searching; locationText = 'Checking Zone...'; @@ -1367,7 +1439,8 @@ class _ConnectionScreenState extends State with WidgetsBinding required String message, Widget? action, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Use Center with CustomScrollView for both vertical centering and scroll capability return Center( @@ -1392,7 +1465,10 @@ class _ConnectionScreenState extends State with WidgetsBinding message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), if (action != null) ...[ @@ -1408,7 +1484,8 @@ class _ConnectionScreenState extends State with WidgetsBinding Widget _buildDeviceList(BuildContext context, AppStateProvider appState) { // Offline mode bypasses both zone and maintenance checks - final canConnect = appState.offlineMode || (appState.inZone == true && !appState.maintenanceMode); + final canConnect = appState.offlineMode || + (appState.inZone == true && !appState.maintenanceMode); // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { @@ -1433,7 +1510,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ), const SizedBox(height: 8), Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + appState.maintenanceMessage ?? + 'Service is temporarily unavailable.', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, @@ -1447,13 +1525,15 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: const Icon(Icons.cloud_off), label: const Text('Enable Offline Mode'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), if (appState.maintenanceUrl != null) ...[ const SizedBox(height: 12), OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + onPressed: () => + _launchMaintenanceUrl(appState.maintenanceUrl!), icon: const Icon(Icons.open_in_new, size: 18), label: const Text('More Info'), ), @@ -1470,12 +1550,14 @@ class _ConnectionScreenState extends State with WidgetsBinding child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), const SizedBox(width: 8), Flexible( child: Text( 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], @@ -1507,8 +1589,10 @@ class _ConnectionScreenState extends State with WidgetsBinding String message = 'Your geo zone is not on-boarded into MeshMapper.'; if (nearestName != null && distKmValue != null) { - final zoneDisplay = nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; - final dist = formatKilometers(distKmValue, isImperial: appState.preferences.isImperial); + final zoneDisplay = + nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; + final dist = formatKilometers(distKmValue, + isImperial: appState.preferences.isImperial); message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } @@ -1578,7 +1662,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: const Icon(Icons.cloud_off), label: const Text('Enable Offline Mode'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), ), ), const SizedBox(height: 32), @@ -1587,17 +1672,20 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), const SizedBox(width: 8), Flexible( child: Text( 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], @@ -1619,13 +1707,15 @@ class _ConnectionScreenState extends State with WidgetsBinding title: appState.zoneCheckErrorReason == 'gps_inaccurate' ? 'GPS Accuracy Error' : 'GPS Stale Error', - message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + message: + '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', action: FilledButton.icon( onPressed: () => appState.checkZoneStatus(), icon: const Icon(Icons.refresh), label: const Text('Retry Zone Check'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ); @@ -1657,7 +1747,9 @@ class _ConnectionScreenState extends State with WidgetsBinding return Column( children: [ const LinearProgressIndicator(), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), + Expanded( + child: _buildDeviceListView(context, appState, + canConnect: canConnect)), ], ); } @@ -1666,7 +1758,8 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); + return _buildRememberedDeviceView(context, appState, remembered, + canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1679,7 +1772,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: Icons.gps_off, iconColor: Colors.red.withValues(alpha: 0.7), title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', + message: + 'Please enable Location Services to verify you\'re in an allowed zone.', action: isIOS ? null : ElevatedButton.icon( @@ -1697,7 +1791,8 @@ class _ConnectionScreenState extends State with WidgetsBinding icon: Icons.location_off, iconColor: Colors.orange.withValues(alpha: 0.7), title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', + message: + 'Location access is needed to verify you\'re in an allowed zone.', action: ElevatedButton.icon( onPressed: () => _requestLocationPermission(appState), icon: const Icon(Icons.location_on), @@ -1742,7 +1837,8 @@ class _ConnectionScreenState extends State with WidgetsBinding RememberedDevice remembered, { bool canConnect = true, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SingleChildScrollView( child: Center( @@ -1772,7 +1868,9 @@ class _ConnectionScreenState extends State with WidgetsBinding ), SizedBox(height: isLandscape ? 12 : 24), ElevatedButton.icon( - onPressed: canConnect ? () => appState.reconnectToRememberedDevice() : null, + onPressed: canConnect + ? () => appState.reconnectToRememberedDevice() + : null, icon: const Icon(Icons.bluetooth_connected), label: Text(canConnect ? 'Reconnect' @@ -1819,7 +1917,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, {bool canConnect = true}) { + Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, + {bool canConnect = true}) { return ListView.builder( itemCount: appState.discoveredDevices.length, itemBuilder: (context, index) { @@ -1947,9 +2046,8 @@ class _DeviceListTile extends StatelessWidget { device.id, style: TextStyle(color: enabled ? null : Colors.grey), ), - trailing: device.rssi != null - ? _buildRssiChip(device.rssi!, enabled) - : null, + trailing: + device.rssi != null ? _buildRssiChip(device.rssi!, enabled) : null, enabled: enabled, onTap: onTap, ); diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 977ce8cf..623520de 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -20,7 +20,8 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: + const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) @@ -79,7 +80,8 @@ class GraphScreen extends StatelessWidget { _SessionListTile( session: currentSession, isActive: true, - onTap: () => _openFullScreenGraph(context, currentSession, isLive: true), + onTap: () => + _openFullScreenGraph(context, currentSession, isLive: true), ), if (sessions.isNotEmpty) const Divider(), ], @@ -94,10 +96,12 @@ class GraphScreen extends StatelessWidget { ); } - void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, {bool isLive = false}) { + void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, + {bool isLive = false}) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => _FullScreenGraphPage(session: session, isLive: isLive), + builder: (context) => + _FullScreenGraphPage(session: session, isLive: isLive), ), ); } @@ -107,7 +111,8 @@ class GraphScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Clear All Sessions?'), - content: const Text('This will delete all saved noise floor session graphs. The current active session will not be affected.'), + content: const Text( + 'This will delete all saved noise floor session graphs. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -149,7 +154,8 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = widget.session; if (widget.isLive) { _liveTimer = Timer.periodic(const Duration(seconds: 2), (_) { - final current = context.read().currentNoiseFloorSession; + final current = + context.read().currentNoiseFloorSession; if (current != null) { setState(() { _session = current; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e835e9d9..90c94030 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -68,11 +68,11 @@ class _HomeScreenState extends State { return _isControlsMinimized ? 60 : 320; } - @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // In landscape: no AppBar, everything on map overlays if (isLandscape) { @@ -148,7 +148,8 @@ class _HomeScreenState extends State { } /// Stats row for AppBar/floating status bar (matches StatusBar exactly) - Widget _buildAppBarStats(AppStateProvider appState, {bool withTapHandlers = false}) { + Widget _buildAppBarStats(AppStateProvider appState, + {bool withTapHandlers = false}) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -173,7 +174,8 @@ class _HomeScreenState extends State { Icons.radar, appState.pingStats.discCount, PingColors.discSuccess, - onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), // Trace count @@ -181,7 +183,8 @@ class _HomeScreenState extends State { Icons.route, appState.pingStats.traceCount, PingColors.traceSuccess, - onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), // Upload count @@ -189,14 +192,16 @@ class _HomeScreenState extends State { Icons.cloud_done, appState.pingStats.successfulUploads, Colors.teal.shade400, - onTap: withTapHandlers ? () => _showInfoPopup('upload', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('upload', appState) : null, ), ], ); } /// Stat chip for AppBar (same style as StatusBar) - Widget _buildAppBarStatChip(IconData icon, int value, Color color, {VoidCallback? onTap}) { + Widget _buildAppBarStatChip(IconData icon, int value, Color color, + {VoidCallback? onTap}) { final chip = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -239,7 +244,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -300,51 +306,102 @@ class _HomeScreenState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); + return ( + 'Discovery Requests', + 'Discovery request packets we have sent out.', + Icons.radar, + PingColors.discSuccess + ); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -576,7 +633,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.help_outline, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.help_outline, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -588,7 +646,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.close, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.close, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -676,13 +735,14 @@ class _HomeScreenState extends State { children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), - Text(text, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)), + Text(text, + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: color)), ], ), ); } - /// Reconnecting overlay shown centered over the map during auto-reconnect Widget _buildReconnectingOverlay(AppStateProvider appState) { final deviceName = appState.rememberedDevice?.displayName ?? 'device'; @@ -932,7 +992,8 @@ class _HomeScreenState extends State { children: [ // Header with help and minimize buttons ListTile( - title: const Text('Controls', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text('Controls', + style: TextStyle(fontWeight: FontWeight.bold)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1007,7 +1068,8 @@ class _HomeScreenState extends State { /// Show help bottom sheet explaining each control void _showControlsHelp(BuildContext context) { - final prefs = Provider.of(context, listen: false).preferences; + final prefs = + Provider.of(context, listen: false).preferences; showModalBottomSheet( context: context, useSafeArea: true, @@ -1061,7 +1123,8 @@ class _HomeScreenState extends State { icon: Icons.settings_input_antenna, color: Colors.orange, title: 'External Antenna', - description: 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', + description: + 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', ), // Send Ping button @@ -1069,12 +1132,15 @@ class _HomeScreenState extends State { icon: Icons.cell_tower, color: const Color(0xFF0EA5E9), title: 'Send Ping', - description: 'Send a single ping to #wardriving and track which repeaters heard it.', + description: + 'Send a single ping to #wardriving and track which repeaters heard it.', ), // Active Mode / Hybrid Mode button _buildHelpItem( - icon: prefs.hybridModeEnabled ? Icons.compare_arrows : Icons.sensors, + icon: prefs.hybridModeEnabled + ? Icons.compare_arrows + : Icons.sensors, color: const Color(0xFF6366F1), title: prefs.hybridModeEnabled ? 'Hybrid Mode' : 'Active Mode', description: prefs.hybridModeEnabled @@ -1087,7 +1153,8 @@ class _HomeScreenState extends State { icon: Icons.hearing, color: const Color(0xFF6366F1), title: 'Passive Mode', - description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', + description: + 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), // Trace Mode @@ -1095,7 +1162,8 @@ class _HomeScreenState extends State { icon: Icons.gps_fixed, color: Colors.cyan, title: 'Trace Mode', - description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', + description: + 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), @@ -1217,4 +1285,3 @@ class _HomeScreenState extends State { return Colors.red; } } - diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 565f3920..e7bd7f70 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -16,7 +16,8 @@ class LogScreen extends StatefulWidget { State createState() => _LogScreenState(); } -class _LogScreenState extends State with SingleTickerProviderStateMixin { +class _LogScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; final _allPingsKey = GlobalKey<_AllPingsTabState>(); @@ -68,7 +69,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix }, itemBuilder: (context) => [ const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), - const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + const PopupMenuItem( + value: 'clear', child: Text('Clear all logs')), ], ), ], @@ -80,8 +82,12 @@ class _LogScreenState extends State with SingleTickerProviderStateMix dividerHeight: 1, labelPadding: EdgeInsets.zero, tabs: [ - Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), - Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + Tab( + height: 32, + text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab( + height: 32, + text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), ], ), ), @@ -120,7 +126,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix final filtered = tabState._filteredEntries; if (filtered.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No matching entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -131,7 +139,10 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), + SnackBar( + content: + Text('${filtered.length} filtered entries copied to clipboard'), + duration: const Duration(seconds: 2)), ); return; } @@ -143,7 +154,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No ping log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -161,7 +174,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + buffer.writeln( + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -170,7 +184,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (disc.isNotEmpty) { buffer.writeln('--- DISC Log ---'); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + buffer + .writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); for (final entry in disc) { buffer.writeln(entry.toCsv()); } @@ -179,7 +194,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (trace.isNotEmpty) { buffer.writeln('--- TRC Log ---'); - buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + buffer.writeln( + 'timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); for (final entry in trace) { buffer.writeln(entry.toCsv()); } @@ -187,14 +203,18 @@ class _LogScreenState extends State with SingleTickerProviderStateMix Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('All ping logs copied to clipboard'), + duration: Duration(seconds: 2)), ); } void _copyErrorLogToCsv(BuildContext context, List entries) { if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No error log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No error log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -206,7 +226,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Error log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Error log copied to clipboard'), + duration: Duration(seconds: 2)), ); } @@ -215,7 +237,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), + content: + const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -299,7 +322,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Resolve a short repeater ID to known repeater names via prefix matching. static ({List names, bool ambiguous}) _resolveRepeaterNames( - String repeaterId, List repeaters, + String repeaterId, + List repeaters, ) { final idLower = repeaterId.toLowerCase(); final matches = repeaters @@ -330,7 +354,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final event in tx.events) { if (event.repeaterId.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } } return false; case PingLogType.rx: @@ -341,32 +367,43 @@ class _AllPingsTabState extends State<_AllPingsTab> { case PingLogType.disc: final disc = entry.asDisc; for (final node in disc.discoveredNodes) { - if (node.repeaterId.toLowerCase().startsWith(query)) return true; - if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + if (node.repeaterId.toLowerCase().startsWith(query)) { + return true; + } + if (node.pubkeyHex != null && + node.pubkeyHex!.toLowerCase().startsWith(query)) { + return true; + } final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } } return false; case PingLogType.trace: final trace = entry.asTrace; if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + final resolved = + _resolveRepeaterNames(trace.targetRepeaterId, repeaters); return resolved.names.any((n) => n.toLowerCase().contains(query)); } } /// Whether an entry should show the ambiguity indicator. /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. - bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + bool _shouldShowAmbiguity( + UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; switch (entry.type) { case PingLogType.tx: - return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + return entry.asTx.events + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); case PingLogType.rx: return _isAmbiguousId(entry.asRx.repeaterId, repeaters); case PingLogType.disc: - return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + return entry.asDisc.discoveredNodes + .any((n) => _isAmbiguousId(n.repeaterId, repeaters)); case PingLogType.trace: return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); } @@ -412,11 +449,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { contentPadding: const EdgeInsets.symmetric(vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), ), onChanged: (value) => setState(() => _searchQuery = value.trim()), @@ -429,19 +474,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), clipBehavior: Clip.antiAlias, child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, + PingColors.txSuccess, + isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), + _buildFilterSegment( + PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), + _buildFilterSegment(PingLogType.disc, 'DISC', + widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', + widget.traceCount, PingColors.traceSuccess, + isLast: true), ], ), ), @@ -464,7 +519,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { hasEntries && _searchQuery.isNotEmpty ? 'No results for \'$_searchQuery\'' : 'No pings logged yet', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -474,12 +531,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { itemCount: filtered.length, itemBuilder: (context, index) { final unified = filtered[index]; - final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + final showAmbiguity = + _shouldShowAmbiguity(unified, widget.repeaters); return switch (unified.type) { - PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), - PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), - PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), - PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + PingLogType.tx => _buildTxCard(context, unified.asTx, + showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, + showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard( + context, unified.asDisc, + showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard( + context, unified.asTrace, + showAmbiguity: showAmbiguity), }; }, ), @@ -488,7 +552,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + Widget _buildFilterSegment( + PingLogType type, String label, int count, Color color, + {bool isFirst = false, bool isLast = false}) { final active = _activeFilters.contains(type); return Expanded( child: GestureDetector( @@ -504,16 +570,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { style: TextStyle( fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), ), ), if (count > 0) ...[ const SizedBox(width: 4), Container( - constraints: const BoxConstraints(minWidth: 18, minHeight: 16), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + constraints: + const BoxConstraints(minWidth: 18, minHeight: 16), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, @@ -600,19 +678,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- - Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.tx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), @@ -640,7 +722,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ @@ -670,9 +754,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null ? '${event.rssi}' : '-', + rssiColor))), ], ), ), @@ -683,7 +775,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // RX Card // --------------------------------------------------------------------------- - Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildRxCard(BuildContext context, RxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); @@ -692,43 +785,71 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.rx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), const SizedBox(height: 10), // Repeater table (single row) Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'SNR', center: true)), - Expanded(child: _tableHeader(context, 'RSSI', center: true)), + SizedBox( + width: 60, child: _tableHeader(context, 'Node')), + Expanded( + child: + _tableHeader(context, 'SNR', center: true)), + Expanded( + child: + _tableHeader(context, 'RSSI', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.repeaterId), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: entry.repeaterId, + fontSize: 14, + width: 60), + Expanded( + child: Center( + child: _buildChip( + entry.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.rssi != null + ? '${entry.rssi}' + : '-', + rssiColor))), ], ), ), @@ -747,44 +868,63 @@ class _AllPingsTabState extends State<_AllPingsTab> { // DISC Card // --------------------------------------------------------------------------- - Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.disc, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes + .map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -812,7 +952,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, + fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( @@ -821,7 +962,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { width: 70, child: Row( children: [ - Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), + Flexible( + child: RepeaterIdChip( + repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: TextStyle( @@ -833,9 +976,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), - Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), - Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), - Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), + Expanded( + child: Center( + child: _buildChip( + node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded( + child: + Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded( + child: Center( + child: _buildChip( + node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), @@ -846,7 +997,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Trace Card // --------------------------------------------------------------------------- - Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, + {bool showAmbiguity = false}) { final colorScheme = Theme.of(context).colorScheme; final appState = context.read(); @@ -854,13 +1006,16 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.trace, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Results table if (entry.success) ...[ const SizedBox(height: 10), @@ -868,18 +1023,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), @@ -915,10 +1080,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), - Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), - Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), - Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + SizedBox( + width: 70, + child: RepeaterIdChip( + repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded( + child: Center( + child: _buildChip( + entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.localRssi != null ? '${entry.localRssi}' : '-', + rssiColor))), + Expanded( + child: Center( + child: _buildChip( + entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), ], ), ); @@ -928,7 +1106,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Shared helpers // --------------------------------------------------------------------------- - static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + static Widget _buildCardHeader(BuildContext context, PingLogType type, + String timeString, String locationString, + {bool showAmbiguity = false}) { return Row( children: [ _buildTypeBadge(type), @@ -936,7 +1116,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { const SizedBox(width: 2), Tooltip( message: 'Repeater ID matches multiple nodes', - child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + child: Icon(Icons.help_outline, + size: 14, color: Colors.amber.shade700), ), ], const SizedBox(width: 6), @@ -950,7 +1131,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), const Spacer(), - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 2), Text( locationString, @@ -964,7 +1146,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + static Widget _tableHeader(BuildContext context, String text, + {bool center = false}) { return Text( text, textAlign: center ? TextAlign.center : TextAlign.left, @@ -1014,9 +1197,12 @@ class _ErrorLogTab extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle_outline, size: 48, color: Colors.green), + const Icon(Icons.check_circle_outline, + size: 48, color: Colors.green), const SizedBox(height: 16), - Text('No errors logged', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text('No errors logged', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant)), ], ), ); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 87c2c435..a3cdfef1 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -53,7 +53,8 @@ class _MainScaffoldState extends State { if (kIsWeb) { // Web: No disclosure dialog needed, just request permission // This triggers the browser's native location permission prompt - debugLog('[DISCLOSURE] Web platform - requesting GPS permission directly'); + debugLog( + '[DISCLOSURE] Web platform - requesting GPS permission directly'); await _requestWebGpsPermission(); return; } @@ -109,7 +110,7 @@ class _MainScaffoldState extends State { return; } granted = permission == LocationPermission.always || - permission == LocationPermission.whileInUse; + permission == LocationPermission.whileInUse; } else { // Android: only request if needed so previously granted permission just restarts GPS. var status = await Permission.locationWhenInUse.status; @@ -187,7 +188,8 @@ class _MainScaffoldState extends State { }); } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( body: IndexedStack( @@ -233,8 +235,12 @@ class _MainScaffoldState extends State { index: 2, ), _buildCompactNavItem( - icon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, - activeIcon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, + icon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, + activeIcon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, index: 3, color: appState.isConnected ? Colors.green : null, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4cfe78ed..61a0b954 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'dart:io' show File; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -43,13 +44,15 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + Future _showUploadLogsDialog( + BuildContext context, AppStateProvider appState) async { final result = await showUploadLogsDialog(context, appState); if (!context.mounted || result == null) return; if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + String message = + 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; if (result.failedCount > 0) { message += ' (${result.failedCount} failed)'; } @@ -115,11 +118,13 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), child: const Row( children: [ @@ -141,23 +146,25 @@ class _SettingsScreenState extends State { prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, ), title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + subtitle: + Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + if (!kIsWeb) _BackgroundModeToggle(appState: appState), SwitchListTile( - secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + secondary: + Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' : 'Disabled to save mobile data'), value: !prefs.mapTilesEnabled, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), if (prefs.mapTilesEnabled) @@ -186,7 +193,8 @@ class _SettingsScreenState extends State { prefs.isImperial ? Icons.square_foot : Icons.straighten, ), title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); @@ -195,10 +203,12 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.cell_tower), title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), value: prefs.showTopRepeaters, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), ListTile( @@ -216,9 +226,11 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), value: appState.isSoundEnabled, onChanged: (_) => appState.toggleSoundEnabled(), ), @@ -233,14 +245,16 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Response Received'), - subtitle: const Text('Sound when repeater echo or RX is received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), value: appState.isRxSoundEnabled, onChanged: (value) => appState.setRxSoundEnabled(value), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Disconnect Alert'), - subtitle: const Text('Triple beep when pinging stops unexpectedly'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), value: appState.isDisconnectAlertEnabled, onChanged: (value) => appState.setDisconnectAlertEnabled(value), ), @@ -256,17 +270,20 @@ class _SettingsScreenState extends State { ? 'Device broadcasts as "Anonymous"' : 'Device uses its real name'), value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == + ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, ), ListTile( leading: const Icon(Icons.timer), @@ -274,7 +291,9 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showIntervalSelector(context, appState), ), ListTile( leading: const Icon(Icons.straighten), @@ -282,16 +301,22 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.minPingDistanceDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showDistanceSelector(context, appState), ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), + subtitle: + const Text('Stops auto-ping after 30 min without movement'), value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(autoStopAfterIdle: value)); + }, ), ]), @@ -301,7 +326,9 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.compare_arrows), title: Row( children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHybridModeInfo(context), @@ -323,15 +350,20 @@ class _SettingsScreenState extends State { ) : const Text('Combines Active and Passive modes'), value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, + onChanged: (isAutoMode || appState.enforceHybrid) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(hybridModeEnabled: value)); + }, ), SwitchListTile( secondary: const Icon(Icons.signal_wifi_off), title: Row( children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('Discovery Drop', + overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showDiscDropInfo(context), @@ -353,13 +385,16 @@ class _SettingsScreenState extends State { ) : const Text('Count failed discoveries as failed pings'), value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value == true) { - _showDiscDropEnableConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); - } - }, + onChanged: (isAutoMode || appState.enforceDiscDrop) + ? null + : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(discDropEnabled: false)); + } + }, ), ]), @@ -368,17 +403,21 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.filter_alt), title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), + subtitle: Text( + prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(ignoreCarpeater: value)); + } + }, ), if (prefs.ignoreCarpeater) ListTile( @@ -389,7 +428,9 @@ class _SettingsScreenState extends State { : 'Not set'), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showRepeaterIdDialog(context, appState), ), SwitchListTile( secondary: const Icon(Icons.shield_outlined), @@ -398,13 +439,16 @@ class _SettingsScreenState extends State { ? 'Allows all signal strengths' : 'Drops signals stronger than -30 dBm'), value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(disableRssiFilter: false)); + } + }, ), ]), @@ -414,7 +458,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.linear_scale), title: Row( children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHopBytesInfo(context), @@ -446,14 +491,19 @@ class _SettingsScreenState extends State { ) : const Text('Repeater ID size in TX/RX path hops'), trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + value: appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes, underline: const SizedBox(), items: const [ DropdownMenuItem(value: 1, child: Text('1')), DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 3, child: Text('3')), ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + appState.enforceHopBytes || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setHopBytes(value); @@ -464,7 +514,9 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.gps_fixed), title: Row( children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Trace Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showTraceBytesInfo(context), @@ -498,7 +550,9 @@ class _SettingsScreenState extends State { DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 4, child: Text('4')), ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setTraceHopBytes(value); @@ -529,7 +583,8 @@ class _SettingsScreenState extends State { : 'Keeps #wardriving channel on device'), value: prefs.deleteChannelOnDisconnect, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + appState.updatePreferences( + prefs.copyWith(deleteChannelOnDisconnect: value)); }, ), ]), @@ -584,12 +639,15 @@ class _SettingsScreenState extends State { ) else ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession( + context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession( + context, appState, session.filename), + onDownload: () => _downloadOfflineSession( + context, appState, session.filename), + )), ]), // API Endpoints @@ -606,13 +664,16 @@ class _SettingsScreenState extends State { ? (prefs.customApiUrl ?? 'Not configured') : 'Forward pings to a third-party server'), value: prefs.customApiEnabled, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showCustomApiDisclaimer(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(customApiEnabled: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(customApiEnabled: false)); + } + }, ), if (prefs.customApiEnabled) ...[ ListTile( @@ -620,29 +681,41 @@ class _SettingsScreenState extends State { title: const Text('Endpoint URL'), subtitle: Text(prefs.customApiUrl ?? 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiUrlDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiUrlDialog(context, appState), ), ListTile( leading: const SizedBox(width: 24), title: const Text('API Key'), - subtitle: Text(prefs.customApiKey != null ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Not set'), + subtitle: Text(prefs.customApiKey != null + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' + : 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiKeyDialog(context, appState), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Include Contact Key'), - subtitle: const Text('Share device public key prefix with endpoint'), + subtitle: + const Text('Share device public key prefix with endpoint'), value: prefs.customApiIncludeContact, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(customApiIncludeContact: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(customApiIncludeContact: value)); + }, ), ListTile( leading: const Icon(Icons.content_paste), title: const Text('Import from Clipboard'), subtitle: const Text('Paste a meshmapper:// config link'), - onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState), + onTap: isAutoMode + ? null + : () => _importCustomApiFromClipboard(context, appState), ), ], ]), @@ -670,7 +743,8 @@ class _SettingsScreenState extends State { leading: const FaIcon(FontAwesomeIcons.github), title: const Text('GitHub'), subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + onTap: () => _launchUrl( + 'https://github.com/MeshMapper/MeshMapper_Project'), ), ListTile( leading: const FaIcon(FontAwesomeIcons.discord), @@ -681,7 +755,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.groups), title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + subtitle: const Text( + 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), ListTile( @@ -698,12 +773,15 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.exit_to_app), title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), + subtitle: + const Text('Automatically exit the app when disconnecting'), value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + onChanged: (value) => + appState.setCloseAppAfterDisconnect(value), ), ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), + leading: + const Icon(Icons.power_settings_new, color: Colors.red), title: const Text('Close App'), subtitle: const Text('Exit the app completely'), onTap: () => _showCloseAppConfirmation(context, appState), @@ -733,7 +811,8 @@ class _SettingsScreenState extends State { if (appState.isGpsSimulatorEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -771,13 +850,15 @@ class _SettingsScreenState extends State { min: 10, max: 120, divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + label: formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), onChanged: (value) { appState.setGpsSimulatorSpeed(value); }, ), trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -793,15 +874,18 @@ class _SettingsScreenState extends State { items: [ const DropdownMenuItem( value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), + child: Text('Straight Line', + overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + child: + Text('Circle', overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + child: Text('Random Walk', + overflow: TextOverflow.ellipsis), ), if (appState.hasSimulatorRoute) DropdownMenuItem( @@ -882,7 +966,8 @@ class _SettingsScreenState extends State { if (appState.debugLogsEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -913,7 +998,8 @@ class _SettingsScreenState extends State { } }, ), - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + if (appState.debugLogsEnabled || + appState.debugLogFiles.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( @@ -929,12 +1015,14 @@ class _SettingsScreenState extends State { TextButton.icon( icon: const Icon(Icons.cloud_upload, size: 18), label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + onPressed: () => + _showUploadLogsDialog(context, appState), ), TextButton.icon( icon: const Icon(Icons.delete_sweep, size: 18), label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + onPressed: () => + _confirmDeleteAllLogs(context, appState), ), ], ], @@ -945,7 +1033,8 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + style: + TextStyle(color: Colors.grey.shade500, fontSize: 13), ), ) else @@ -955,19 +1044,27 @@ class _SettingsScreenState extends State { final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final timestampMatch = + RegExp(r'meshmapper-debug-(\d+)\.txt') + .firstMatch(filename); final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + int.parse(timestampMatch.group(1)!) * 1000) : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + final dateStr = fileDate != null + ? DateFormat('MMM d, h:mm a').format(fileDate) + : filename; String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } if (isCurrentLog) { sizeDisplay = '$sizeDisplay (current)'; @@ -975,7 +1072,8 @@ class _SettingsScreenState extends State { return ListTile( leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), + title: + Text(dateStr, style: const TextStyle(fontSize: 13)), subtitle: Text( sizeDisplay, style: const TextStyle(fontSize: 11), @@ -985,7 +1083,8 @@ class _SettingsScreenState extends State { children: [ IconButton( icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), + onPressed: () => + _showLogViewer(context, appState, file), tooltip: 'View', ), IconButton( @@ -1006,27 +1105,38 @@ class _SettingsScreenState extends State { String _markerStyleLabel(String style) { switch (style) { - case 'circle': return 'Outlined Dot'; - case 'pin': return 'Pin'; - case 'diamond': return 'Diamond'; + case 'circle': + return 'Outlined Dot'; + case 'pin': + return 'Pin'; + case 'diamond': + return 'Diamond'; case 'dot': - default: return 'Dot'; + default: + return 'Dot'; } } String _gpsMarkerLabel(String style) { switch (style) { - case 'car': return 'Car'; - case 'bike': return 'Bike'; - case 'boat': return 'Boat'; - case 'walk': return 'Walk'; - case 'chomper': return 'Chomper'; + case 'car': + return 'Car'; + case 'bike': + return 'Bike'; + case 'boat': + return 'Boat'; + case 'walk': + return 'Walk'; + case 'chomper': + return 'Chomper'; case 'arrow': - default: return 'Arrow'; + default: + return 'Arrow'; } } - void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + void _showMarkerStyleSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('dot', 'Dot', Icons.circle), ('circle', 'Outlined Dot', Icons.circle_outlined), @@ -1044,13 +1154,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Map Marker Style', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.markerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(markerStyle: v)); } Navigator.pop(context); }, @@ -1093,13 +1205,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('GPS Marker', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.gpsMarkerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(gpsMarkerStyle: v)); } Navigator.pop(context); }, @@ -1132,13 +1246,30 @@ class _SettingsScreenState extends State { }; } - void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + void _showColorVisionSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('none', 'Default', 'Standard color palette'), - ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), - ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), - ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), - ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ( + 'protanopia', + 'Protanopia', + 'Red-blind — difficulty distinguishing red and green' + ), + ( + 'deuteranopia', + 'Deuteranopia', + 'Green-blind — difficulty distinguishing red and green' + ), + ( + 'tritanopia', + 'Tritanopia', + 'Blue-blind — difficulty distinguishing blue and yellow' + ), + ( + 'achromatopsia', + 'Achromatopsia', + 'Total color blindness — sees in greyscale' + ), ]; showModalBottomSheet( context: context, @@ -1151,7 +1282,8 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Color Vision', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.colorVisionType, @@ -1166,7 +1298,8 @@ class _SettingsScreenState extends State { RadioListTile( secondary: const Icon(Icons.visibility), title: Text(label), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), value: value, ), ], @@ -1179,7 +1312,8 @@ class _SettingsScreenState extends State { ); } - Widget _buildSection(BuildContext context, String title, List children) { + Widget _buildSection( + BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Card( @@ -1216,7 +1350,8 @@ class _SettingsScreenState extends State { } } - Future _showBugReportDialog(BuildContext context, AppStateProvider appState) async { + Future _showBugReportDialog( + BuildContext context, AppStateProvider appState) async { final result = await showBugReportDialog(context, appState); if (!context.mounted || result == null) return; @@ -1236,7 +1371,8 @@ class _SettingsScreenState extends State { message, duration: const Duration(seconds: 5), actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + onAction: + result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, ); } else if (result.errorMessage != null) { AppToast.error( @@ -1297,7 +1433,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableRssiFilterConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1330,7 +1467,8 @@ class _SettingsScreenState extends State { ); } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showEnableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, @@ -1364,7 +1502,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1409,21 +1548,24 @@ class _SettingsScreenState extends State { style: TextStyle(fontSize: 14), ), SizedBox(height: 12), - Text('How it works:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('How it works:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'Discovery \u2192 wait \u2192 TX Ping \u2192 wait \u2192 Discovery \u2192 ...', style: TextStyle(fontSize: 13, fontFamily: 'monospace'), ), SizedBox(height: 12), - Text('Interval timing:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('Interval timing:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'At 15s interval, each ping type fires every 30s. Discovery\'s 30s firmware rate limit is naturally respected.', style: TextStyle(fontSize: 13), ), SizedBox(height: 12), - Text('When enabled:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('When enabled:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( '\u2022 Replaces the Active button with Hybrid\n' @@ -1480,7 +1622,8 @@ class _SettingsScreenState extends State { ); } - void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + void _showDiscDropEnableConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1702,7 +1845,8 @@ class _SettingsScreenState extends State { final tile = RadioListTile( title: Text( '$interval seconds', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), ), subtitle: isDisabled ? const Text( @@ -1810,7 +1954,8 @@ class _SettingsScreenState extends State { textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters - final filtered = value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + final filtered = + value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (filtered != value) { controller.value = controller.value.copyWith( text: filtered, @@ -1854,7 +1999,8 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); + AppToast.warning( + context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1864,7 +2010,8 @@ class _SettingsScreenState extends State { ); } - Future _pickRouteFile(BuildContext context, AppStateProvider appState) async { + Future _pickRouteFile( + BuildContext context, AppStateProvider appState) async { try { debugLog('[SETTINGS] Opening file picker...'); @@ -1882,9 +2029,8 @@ class _SettingsScreenState extends State { if (result != null && result.files.isNotEmpty) { debugLog('[SETTINGS] File picked: ${result.files.first.name}'); final file = result.files.first; - final content = file.bytes != null - ? String.fromCharCodes(file.bytes!) - : null; + final content = + file.bytes != null ? String.fromCharCodes(file.bytes!) : null; if (content != null && context.mounted) { debugLog('[SETTINGS] File content loaded, ${content.length} chars'); @@ -1918,7 +2064,8 @@ class _SettingsScreenState extends State { ); } - void _processRouteFile(BuildContext context, AppStateProvider appState, String content, String filename) { + void _processRouteFile(BuildContext context, AppStateProvider appState, + String content, String filename) { debugLog('[SETTINGS] Calling loadSimulatorRoute...'); final success = appState.loadSimulatorRoute( content, @@ -1939,7 +2086,8 @@ class _SettingsScreenState extends State { } } - Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { + Future _uploadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { // Progress text notifier for updating dialog without rebuilding screen final progressNotifier = ValueNotifier('Authenticating...'); @@ -2039,7 +2187,8 @@ class _SettingsScreenState extends State { } } - void _confirmDeleteOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _confirmDeleteOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2065,9 +2214,11 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { try { - final sessionData = appState.offlineSessionService.getSessionData(filename); + final sessionData = + appState.offlineSessionService.getSessionData(filename); if (sessionData == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2079,7 +2230,8 @@ class _SettingsScreenState extends State { } // Convert to pretty JSON - final jsonString = const JsonEncoder.withIndent(' ').convert(sessionData); + final jsonString = + const JsonEncoder.withIndent(' ').convert(sessionData); if (kIsWeb && isWebFileHelpersAvailable) { // Web: Create a blob and trigger download @@ -2146,7 +2298,8 @@ class _SettingsScreenState extends State { } /// Show debug log viewer dialog - void _showLogViewer(BuildContext context, AppStateProvider appState, File file) async { + void _showLogViewer( + BuildContext context, AppStateProvider appState, File file) async { await appState.viewDebugLog(file); if (!context.mounted) return; @@ -2178,7 +2331,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiDisclaimer(BuildContext context, AppStateProvider appState) { + void _showCustomApiDisclaimer( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2236,7 +2390,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiUrlDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiUrlDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiUrl ?? '', ); @@ -2296,7 +2451,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiKeyDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiKeyDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiKey ?? '', ); @@ -2335,7 +2491,8 @@ class _SettingsScreenState extends State { ); } - Future _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async { + Future _importCustomApiFromClipboard( + BuildContext context, AppStateProvider appState) async { final clipData = await Clipboard.getData('text/plain'); final text = clipData?.text?.trim(); @@ -2358,11 +2515,15 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the url parameter'); + if (context.mounted) { + AppToast.error(context, 'Link is missing the url parameter'); + } return; } if (key == null || key.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the key parameter'); + if (context.mounted) { + AppToast.error(context, 'Link is missing the key parameter'); + } return; } @@ -2371,7 +2532,9 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl'); + if (context.mounted) { + AppToast.error(context, 'Invalid URL in link: $rawUrl'); + } return; } @@ -2388,11 +2551,14 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link'); + if (context.mounted) { + AppToast.error(context, 'Invalid meshmapper:// link'); + } } } - void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { + void _showCloseAppConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; showDialog( @@ -2472,7 +2638,9 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Future _requestPermission() async { // Show prominent disclosure before requesting background location - final accepted = await PermissionDisclosureService.showBackgroundLocationDisclosure(context); + final accepted = + await PermissionDisclosureService.showBackgroundLocationDisclosure( + context); if (!accepted) { return; // User declined } @@ -2607,7 +2775,10 @@ class _OfflineSessionTile extends StatelessWidget { if (isUploaded) const Text( 'Uploaded', - style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500), ), if (session.deviceName != null) Text( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 932f06ed..4ed600a4 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -79,7 +79,8 @@ class ApiQueueService { // Pings without a valid session cannot be uploaded, so delete them try { if (_box != null && _box!.isNotEmpty) { - debugLog('[API QUEUE] Clearing ${_box!.length} stale items from previous session'); + debugLog( + '[API QUEUE] Clearing ${_box!.length} stale items from previous session'); await _box!.clear(); } } catch (e) { @@ -108,10 +109,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened successfully'); return box; } on TimeoutException { - debugError('[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); return _attemptRecovery(timeout); } catch (e) { - debugError('[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); return _attemptRecovery(timeout); } } @@ -132,10 +135,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened after recovery'); return box; } catch (e) { - debugError('[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); + debugError( + '[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); // Notify user of persistence failure - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); return null; } @@ -150,7 +155,8 @@ class ApiQueueService { _isRecovering = true; try { - debugLog('[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); + debugLog( + '[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); // Close the corrupt box try { @@ -168,16 +174,19 @@ class ApiQueueService { _box = box; debugLog('[API QUEUE] Box recovered successfully'); } catch (e) { - debugError('[API QUEUE] Runtime recovery failed: $e - operating without persistence'); + debugError( + '[API QUEUE] Runtime recovery failed: $e - operating without persistence'); _box = null; - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); } finally { _isRecovering = false; } } /// Wrap a write operation with corruption recovery and single retry - Future _safeWrite(Future Function(Box box) operation) async { + Future _safeWrite( + Future Function(Box box) operation) async { final box = _box; if (box == null) return false; @@ -249,9 +258,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -344,9 +355,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -393,9 +406,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -434,9 +449,11 @@ class ApiQueueService { final wrote = await _safeWrite((box) => box.add(item)); if (!wrote) { _memoryQueue.add(item); - debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); } else { - debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + debugLog( + '[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); @@ -474,7 +491,8 @@ class ApiQueueService { } } - debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); + debugLog( + '[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); onQueueUpdated?.call(queueSize); } finally { _isFlushing = false; @@ -525,13 +543,15 @@ class ApiQueueService { try { // Collect items from both Hive and memory queue - final hiveItems = _safeRead((box) => box.values - .where((item) => - item.retryCount < _maxRetries && - item.isReadyForRetry && - item.isUploadEligible) - .take(_batchSize) - .toList(), []); + final hiveItems = _safeRead( + (box) => box.values + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize) + .toList(), + []); final memoryItems = _memoryQueue .where((item) => @@ -555,12 +575,14 @@ class ApiQueueService { // Log each item with external_antenna value for (int i = 0; i < items.length; i++) { final item = items[i]; - debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); + debugLog( + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } final memoryCount = memoryItems.length; if (memoryCount > 0) { - debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + debugLog( + '[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); } else { debugLog('[API QUEUE] Uploading ${items.length} items...'); } @@ -572,7 +594,9 @@ class ApiQueueService { final uploadedCount = items.length; // Remove successful Hive items for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} + try { + await item.delete(); + } catch (_) {} } // Remove successful memory items for (final item in memoryItems) { @@ -585,12 +609,15 @@ class ApiQueueService { } else if (result == UploadResult.nonRetryable) { // Data is permanently invalid — discard for (final item in hiveItems) { - try { await item.delete(); } catch (_) {} + try { + await item.delete(); + } catch (_) {} } for (final item in memoryItems) { _memoryQueue.remove(item); } - debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); + debugWarn( + '[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried for (final item in hiveItems) { @@ -601,7 +628,8 @@ class ApiQueueService { item.retryCount++; item.lastRetryAt = DateTime.now(); } - debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); + debugLog( + '[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } onQueueUpdated?.call(queueSize); @@ -648,7 +676,8 @@ class ApiQueueService { final count = queueSize + _rxBuffer.length; if (count > 0) { - debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); + debugLog( + '[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); _memoryQueue.clear(); @@ -679,10 +708,12 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { final hiveItems = _safeRead( - (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), + (box) => + box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); - final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + final memoryItems = + _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); return [...hiveItems, ...memoryItems]; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index eb8a47df..1cdd4325 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -33,7 +33,7 @@ class ApiService { static const Duration heartbeatBuffer = Duration(minutes: 1); final http.Client _client; - bool _heartbeatEnabled = false; // Track if heartbeat mode is active + bool _heartbeatEnabled = false; // Track if heartbeat mode is active String? _sessionId; bool _txAllowed = false; bool _rxAllowed = false; @@ -91,7 +91,8 @@ class ApiService { /// Check if response indicates maintenance mode, trigger callback if so bool _checkMaintenanceMode(Map response) { if (response['maintenance'] == true) { - final message = response['maintenance_message'] as String? ?? 'Service is under maintenance'; + final message = response['maintenance_message'] as String? ?? + 'Service is under maintenance'; final url = response['maintenance_url'] as String?; debugLog('[MAINTENANCE] Maintenance mode detected: $message'); onMaintenanceMode?.call(message, url); @@ -109,7 +110,8 @@ class ApiService { Map? request, dynamic response, }) { - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); String reqSummary; if (request != null) { @@ -136,13 +138,13 @@ class ApiService { /// Check if we have a valid session bool get hasSession => _sessionId != null; - + /// Check if TX is allowed bool get txAllowed => _txAllowed; - + /// Check if RX is allowed bool get rxAllowed => _rxAllowed; - + /// Get session ID String? get sessionId => _sessionId; @@ -174,17 +176,21 @@ class ApiService { 'key': apiKey, }; - final response = await _client.post( - Uri.parse(geoAuthStatusUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthStatusUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); debugError('[API] Response headers: ${response.headers}'); } @@ -193,7 +199,8 @@ class ApiService { data = json.decode(response.body) as Map; } on FormatException { // CDN/proxy can return HTML error pages with HTTP 200 - debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } @@ -226,8 +233,8 @@ class ApiService { /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, - String? publicKey, // Now optional - either publicKey or contactUri required - String? contactUri, // NEW: for registration flow + String? publicKey, // Now optional - either publicKey or contactUri required + String? contactUri, // NEW: for registration flow String? who, String? appVersion, double? power, @@ -269,7 +276,9 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + if (power != null) { + payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; payload['coords'] = { @@ -283,24 +292,29 @@ class ApiService { payload['session_id'] = sessionId ?? _sessionId; } - final response = await _client.post( - Uri.parse(geoAuthUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -316,7 +330,8 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) - if ((reason == 'connect' || reason == 'register') && data['success'] == true) { + if ((reason == 'connect' || reason == 'register') && + data['success'] == true) { if (!skipSessionStore) { _sessionId = data['session_id'] as String?; _txAllowed = data['tx_allowed'] == true; @@ -367,7 +382,8 @@ class ApiService { if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { _apiHopBytes = hopBytes; if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + debugLog( + '[API] Regional admin enforces $_apiHopBytes-byte paths'); } } else { _apiHopBytes = 1; @@ -397,7 +413,8 @@ class ApiService { /// /// @param entries List of wardrive entries (TX/RX) /// @returns Map with success, expires_at, reason, message - Future?> submitWardriveData(List> entries) async { + Future?> submitWardriveData( + List> entries) async { if (_sessionId == null) { throw Exception('Cannot submit: no session_id'); } @@ -410,32 +427,37 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } // Log with data summary including external_antenna values - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive', method: 'POST', @@ -486,24 +508,29 @@ class ApiService { }; } - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -533,7 +560,8 @@ class ApiService { return data; } catch (e) { stopwatch.stop(); - debugError('[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); + debugError( + '[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); return null; } } @@ -547,7 +575,11 @@ class ApiService { }) async { if (_sessionId == null) { debugWarn('[SESSION] No session to validate'); - return (isValid: false, reason: 'no_session', message: 'No active session'); + return ( + isValid: false, + reason: 'no_session', + message: 'No active session' + ); } debugLog('[SESSION] Checking session validity via heartbeat...'); @@ -555,11 +587,16 @@ class ApiService { if (result == null) { debugWarn('[SESSION] Session check failed: no response'); - return (isValid: false, reason: 'no_response', message: 'Server did not respond'); + return ( + isValid: false, + reason: 'no_response', + message: 'Server did not respond' + ); } if (result['success'] == true) { - debugLog('[SESSION] Session is valid (expires_at: ${result['expires_at']})'); + debugLog( + '[SESSION] Session is valid (expires_at: ${result['expires_at']})'); return (isValid: true, reason: null, message: null); } @@ -570,9 +607,15 @@ class ApiService { // Trigger session error callback for critical errors const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); @@ -628,14 +671,17 @@ class ApiService { // Calculate when to send heartbeat (1 minute before expiry) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final secondsUntilExpiry = expiresAt - now; - final secondsUntilHeartbeat = secondsUntilExpiry - heartbeatBuffer.inSeconds; + final secondsUntilHeartbeat = + secondsUntilExpiry - heartbeatBuffer.inSeconds; if (secondsUntilHeartbeat <= 0) { // Session is about to expire or already expired - send heartbeat immediately - debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); } else { - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + debugLog( + '[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { debugLog('[HEARTBEAT] Timer fired, sending keepalive'); @@ -662,11 +708,14 @@ class ApiService { if (_heartbeatRetryCount < _maxHeartbeatRetries) { final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); _heartbeatRetryCount++; - debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + debugWarn( + '[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + _heartbeatRetryTimer = + Timer(Duration(seconds: delay), _sendScheduledHeartbeat); } else { - debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + debugError( + '[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); } _onSessionExpiring?.call(); } else { @@ -676,9 +725,15 @@ class ApiService { debugWarn('[HEARTBEAT] Heartbeat failed: $reason - $message'); const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { @@ -773,7 +828,8 @@ class ApiService { // outside_zone: preserve session (backend auto-transfers on zone re-entry), // but discard this batch (gap-GPS coords would be rejected again) if (reason == 'outside_zone') { - debugWarn('[API] Upload batch outside_zone — discarding batch, preserving session'); + debugWarn( + '[API] Upload batch outside_zone — discarding batch, preserving session'); final message = result['message'] as String?; onSessionError?.call(reason, message); return UploadResult.nonRetryable; @@ -781,10 +837,15 @@ class ApiService { // Errors where the batch data itself is invalid — retrying won't help const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } @@ -803,9 +864,11 @@ class ApiService { try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; - final response = await _client.get( - Uri.parse(url), - ).timeout(const Duration(seconds: 15)); + final response = await _client + .get( + Uri.parse(url), + ) + .timeout(const Duration(seconds: 15)); stopwatch.stop(); @@ -820,7 +883,8 @@ class ApiService { return []; } - final List jsonList = json.decode(response.body) as List; + final List jsonList = + json.decode(response.body) as List; final repeaters = []; for (final item in jsonList) { @@ -865,31 +929,36 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive (offline)', method: 'POST', @@ -933,9 +1002,16 @@ class ApiService { // For offline uploads, session/auth errors are non-retryable but do NOT cascade const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'outside_zone', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { debugError('[API] Offline upload batch session error: $reason'); @@ -943,10 +1019,15 @@ class ApiService { } const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Offline upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 6b74c07c..27cc57b9 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -25,8 +25,10 @@ class AudioService { AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it - bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) - bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) + bool _txEnabled = + true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = + true; // RX sound sub-toggle (only matters when master is on) Timer? _focusReleaseTimer; /// Whether the audio service is initialized @@ -148,13 +150,15 @@ class AudioService { debugError('[AUDIO] Hive box "$boxName" timed out - attempting recovery'); return _attemptRecovery(boxName, timeout); } catch (e) { - debugError('[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); + debugError( + '[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); return _attemptRecovery(boxName, timeout); } } /// Attempt to recover from Hive corruption - Future?> _attemptRecovery(String boxName, Duration timeout) async { + Future?> _attemptRecovery( + String boxName, Duration timeout) async { try { debugLog('[AUDIO] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -163,7 +167,8 @@ class AudioService { debugLog('[AUDIO] Box "$boxName" opened after recovery'); return box; } catch (e) { - debugError('[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); + debugError( + '[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); return null; } } @@ -182,7 +187,8 @@ class AudioService { /// Shared playback logic for both TX and RX sounds. /// Ensures audio session is active before playing and debounces focus release. - Future _playSound(AudioPlayer? player, String assetPath, String label) async { + Future _playSound( + AudioPlayer? player, String assetPath, String label) async { if (!_initialized || !_enabled || player == null) return; try { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 1e76464f..2d7bed52 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -95,7 +95,8 @@ class BackgroundServiceManager { // (e.g., Android resurrecting a previously-killed foreground service). final isRunning = await _service!.isRunning(); if (isRunning) { - debugLog('[BACKGROUND] Service unexpectedly running after configure(), stopping it'); + debugLog( + '[BACKGROUND] Service unexpectedly running after configure(), stopping it'); _service!.invoke('stop'); } @@ -221,7 +222,8 @@ class BackgroundServiceManager { static Future cleanupOrphanedService() async { if (kIsWeb) return; try { - debugLog('[BACKGROUND] Dismissing any orphaned notification from previous session'); + debugLog( + '[BACKGROUND] Dismissing any orphaned notification from previous session'); final plugin = FlutterLocalNotificationsPlugin(); await plugin.cancel(_notificationId); debugLog('[BACKGROUND] Orphaned notification cleanup complete'); diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 8fb3d629..47a5f23f 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -35,10 +35,13 @@ class MobileBluetoothService implements BluetoothService { } void _ensureControllers() { - if (_isDisposed || _connectionController == null || _connectionController!.isClosed) { + if (_isDisposed || + _connectionController == null || + _connectionController!.isClosed) { _initControllers(); } } + DiscoveredDevice? _connectedDevice; fbp.BluetoothDevice? _bleDevice; fbp.BluetoothCharacteristic? _rxCharacteristic; @@ -135,19 +138,21 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] iOS location permission check: $locationPermission'); if (locationPermission == LocationPermission.deniedForever) { - debugLog('[BLE] iOS location permission permanently denied - user must enable in Settings'); + debugLog( + '[BLE] iOS location permission permanently denied - user must enable in Settings'); throw BlePermissionDeniedException( - 'Location permission required for Bluetooth scanning. ' - 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper' - ); + 'Location permission required for Bluetooth scanning. ' + 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper'); } if (locationPermission == LocationPermission.denied) { - debugLog('[BLE] iOS location permission not yet granted (disclosure flow will handle)'); + debugLog( + '[BLE] iOS location permission not yet granted (disclosure flow will handle)'); return false; } - debugLog('[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); + debugLog( + '[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); return true; } else { // Android: Use bluetoothScan and bluetoothConnect (Android 12+) @@ -155,18 +160,26 @@ class MobileBluetoothService implements BluetoothService { // Location requests are handled by the disclosure flow in MainScaffold. final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); - final location = await Permission.locationWhenInUse.status; // CHECK only, don't request + final location = await Permission + .locationWhenInUse.status; // CHECK only, don't request - debugLog('[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); + debugLog( + '[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); // Check for permanently denied permissions - if (bluetoothScan.isPermanentlyDenied || bluetoothConnect.isPermanentlyDenied || location.isPermanentlyDenied) { + if (bluetoothScan.isPermanentlyDenied || + bluetoothConnect.isPermanentlyDenied || + location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) denied.add('Bluetooth Connect'); + if (bluetoothConnect.isPermanentlyDenied) { + denied.add('Bluetooth Connect'); + } if (location.isPermanentlyDenied) denied.add('Location'); - debugLog('[BLE] Android permissions permanently denied: ${denied.join(", ")}'); - throw BlePermissionDeniedException('${denied.join(", ")} permission(s) denied. Please enable in Settings'); + debugLog( + '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); + throw BlePermissionDeniedException( + '${denied.join(", ")} permission(s) denied. Please enable in Settings'); } final granted = bluetoothScan.isGranted && @@ -185,7 +198,7 @@ class MobileBluetoothService implements BluetoothService { Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); _scanController = controller; - + _updateStatus(ConnectionStatus.scanning); try { @@ -203,9 +216,11 @@ class MobileBluetoothService implements BluetoothService { _scanSubscription = fbp.FlutterBluePlus.scanResults.listen((results) { for (final result in results) { final hasName = result.device.platformName.isNotEmpty; - final deviceName = hasName ? result.device.platformName : 'MeshCore Device'; + final deviceName = + hasName ? result.device.platformName : 'MeshCore Device'; if (!hasName) { - debugLog('[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); } final device = DiscoveredDevice( id: result.device.remoteId.str, @@ -222,7 +237,9 @@ class MobileBluetoothService implements BluetoothService { // Complete stream when scan naturally stops (timeout or platform stop) unawaited(() async { - await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + await fbp.FlutterBluePlus.isScanning + .where((isScanning) => !isScanning) + .first; if (!controller.isClosed) { await controller.close(); } @@ -296,7 +313,8 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Connecting to GATT...'); await _bleDevice!.connect( timeout: const Duration(seconds: 15), - mtu: null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android + mtu: + null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android ); debugLog('[BLE] GATT connected'); @@ -313,7 +331,8 @@ class MobileBluetoothService implements BluetoothService { } catch (e) { // MTU negotiation failure is not fatal - continue with default MTU // Some older devices may not support MTU negotiation - debugLog('[BLE] MTU negotiation failed (continuing with default): $e'); + debugLog( + '[BLE] MTU negotiation failed (continuing with default): $e'); } } else { // iOS auto-negotiates MTU, just log the current value @@ -326,7 +345,8 @@ class MobileBluetoothService implements BluetoothService { // Flutter Blue Plus emits the current state immediately when you subscribe, // but we only want to react to CHANGES, not the initial state. // This prevents false disconnection triggers during connection setup. - _connectionStateSubscription = _bleDevice!.connectionState.skip(1).listen((state) { + _connectionStateSubscription = + _bleDevice!.connectionState.skip(1).listen((state) { debugLog('[BLE] Connection state changed: $state'); if (state == fbp.BluetoothConnectionState.disconnected) { _handleDisconnection(); @@ -364,8 +384,11 @@ class MobileBluetoothService implements BluetoothService { // Enable notifications on TX characteristic debugLog('[BLE] Enabling notifications...'); await _txCharacteristic!.setNotifyValue(true); - _notificationSubscription = _txCharacteristic!.lastValueStream.listen((value) { - if (value.isNotEmpty && _dataController != null && !_dataController!.isClosed) { + _notificationSubscription = + _txCharacteristic!.lastValueStream.listen((value) { + if (value.isNotEmpty && + _dataController != null && + !_dataController!.isClosed) { _dataController!.add(Uint8List.fromList(value)); } }); @@ -380,41 +403,48 @@ class MobileBluetoothService implements BluetoothService { deviceName = _bleDevice!.platformName; } else { deviceName = 'MeshCore Device'; - debugLog('[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: deviceId, name: deviceName, ); if (deviceName == 'MeshCore Device') { - debugLog('[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); } else { - debugLog('[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); } debugLog('[BLE] Connection complete'); _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop - } catch (e, stackTrace) { final errorStr = e.toString(); // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + final isError133 = + Platform.isAndroid && errorStr.contains('android-code: 133'); // Check for iOS apple-code 14 (Peer removed pairing information) or // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys final isBondError = Platform.isIOS && - (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')); if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { - debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + debugLog( + '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); await removeBond(deviceId); await Future.delayed(const Duration(seconds: 2)); } else { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + debugLog( + '[BLE] Error 133 on attempt $attempt, retrying after delay...'); await Future.delayed(_retryDelay); } // Force cleanup before retry diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index ef5ed4c2..e93d16f9 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -13,14 +13,16 @@ import 'bluetooth_service.dart'; class WebBluetoothService implements BluetoothService { final _connectionController = StreamController.broadcast(); final _dataController = StreamController.broadcast(); - final fwb.FlutterWebBluetoothInterface _webBluetooth = fwb.FlutterWebBluetooth.instance; + final fwb.FlutterWebBluetoothInterface _webBluetooth = + fwb.FlutterWebBluetooth.instance; ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; DiscoveredDevice? _connectedDevice; fwb.BluetoothDevice? _device; fwb.BluetoothDevice? _pendingDevice; // Store device from scan for connect() - fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) - fwb.BluetoothCharacteristic? _txCharacteristic; // For notifications (device TX) + fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) + fwb.BluetoothCharacteristic? + _txCharacteristic; // For notifications (device TX) StreamSubscription? _notificationSubscription; @override @@ -73,13 +75,15 @@ class WebBluetoothService implements BluetoothService { // Web Bluetooth doesn't support scanning - uses requestDevice dialog // This is a stub that will yield devices from the request dialog _updateStatus(ConnectionStatus.scanning); - debugLog('[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); - + debugLog( + '[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); + try { // Request device filtered by MeshCore service UUID (matches JS implementation) final device = await _webBluetooth.requestDevice( fwb.RequestOptionsBuilder([ - fwb.RequestFilterBuilder(services: [BleUuids.serviceUuid.toLowerCase()]), + fwb.RequestFilterBuilder( + services: [BleUuids.serviceUuid.toLowerCase()]), ]), ); @@ -89,7 +93,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = device.name ?? 'MeshCore Device'; if (device.name == null) { - debugWarn('[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); } yield DiscoveredDevice( id: device.id, @@ -123,7 +128,7 @@ class WebBluetoothService implements BluetoothService { debugError('[BLE] No pending device - must call scanForDevices first'); throw Exception('No device selected. Please scan for devices first.'); } - + _device = _pendingDevice; _pendingDevice = null; // Clear pending debugLog('[BLE] Using stored device: ${_device!.name ?? _device!.id}'); @@ -137,7 +142,7 @@ class WebBluetoothService implements BluetoothService { debugLog('[BLE] Discovering services...'); final services = await _device!.discoverServices(); debugLog('[BLE] Found ${services.length} services'); - + // Find our MeshCore service fwb.BluetoothService? meshCoreService; for (final service in services) { @@ -148,7 +153,7 @@ class WebBluetoothService implements BluetoothService { break; } } - + if (meshCoreService == null) { throw Exception('MeshCore service not found'); } @@ -179,14 +184,15 @@ class WebBluetoothService implements BluetoothService { try { await _txCharacteristic!.startNotifications(); debugLog('[BLE] Notifications started, setting up listener...'); - + // HIGH-LEVEL API: BluetoothCharacteristic.value is a Stream _notificationSubscription = _txCharacteristic!.value.listen( (ByteData data) { try { // Convert ByteData to Uint8List - final buffer = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - + final buffer = data.buffer + .asUint8List(data.offsetInBytes, data.lengthInBytes); + if (buffer.isNotEmpty) { debugLog('[BLE] Received ${buffer.length} bytes'); _dataController.add(buffer); @@ -209,7 +215,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = _device!.name ?? 'MeshCore Device'; if (_device!.name == null) { - debugWarn('[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: _device!.id, diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index bf5c94a2..412bd3fb 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -13,8 +13,8 @@ import '../utils/debug_logger_io.dart'; class CountdownTimerService { Timer? _timer; DateTime? _endTime; - int? _durationMs; // Original duration for progress calculation - final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick + int? _durationMs; // Original duration for progress calculation + final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick CountdownTimerService({this.onUpdate}); @@ -42,11 +42,12 @@ class CountdownTimerService { /// @param durationMs - Duration in milliseconds void start(int durationMs) { stop(); - _durationMs = durationMs; // Track original duration for progress + _durationMs = durationMs; // Track original duration for progress _endTime = DateTime.now().add(Duration(milliseconds: durationMs)); // Start 500ms update timer for responsive countdown - _timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); + _timer = + Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update _update(); @@ -136,7 +137,8 @@ class ManualPingCooldownTimer extends CountdownTimerService { final remaining = remainingMs; super.stop(); if (wasRunning) { - debugLog('[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); + debugLog( + '[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); } } } diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index b839622b..f5ba0c71 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -49,7 +49,8 @@ class CustomApiService { if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) return; // Enrich with contact and iata (custom API only — never sent to MeshMapper) - final contact = prefs.customApiIncludeContact ? contactGetter?.call() : null; + final contact = + prefs.customApiIncludeContact ? contactGetter?.call() : null; final iata = iataGetter?.call(); final enriched = pings.map((ping) { @@ -86,16 +87,21 @@ class CustomApiService { stopwatch.stop(); if (response.statusCode >= 200 && response.statusCode < 300) { - debugLog('[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); } else { final errorType = 'http_${response.statusCode}'; - debugError('[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); - debugError('[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); - _throttledError(errorType, 'Custom API returned HTTP ${response.statusCode}'); + debugError( + '[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); + debugError( + '[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + _throttledError( + errorType, 'Custom API returned HTTP ${response.statusCode}'); } } on TimeoutException { stopwatch.stop(); - debugError('[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); + debugError( + '[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); _throttledError('timeout', 'Custom API request timed out'); } catch (e) { stopwatch.stop(); @@ -124,7 +130,8 @@ class CustomApiService { String _describeError(Object e) { final full = e.toString(); // Look for SocketException detail (e.g. "Failed host lookup: 'blah.blah'") - final socketMatch = RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); + final socketMatch = + RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); if (socketMatch != null) return socketMatch.group(1)!.trim(); // Look for OS-level message final osMatch = RegExp(r'OS Error: (.+?)(?:,|\))').firstMatch(full); diff --git a/lib/services/debug_file_logger.dart b/lib/services/debug_file_logger.dart index 1406023c..5b43551e 100644 --- a/lib/services/debug_file_logger.dart +++ b/lib/services/debug_file_logger.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; /// - Non-persistent (always starts disabled on app launch) class DebugFileLogger { static const int maxLogFiles = 10; + /// Maximum file size for upload (4.5MB, 0.5MB safety margin under 5MB server limit) static const int maxUploadSizeBytes = 4718592; static File? _currentLogFile; @@ -293,7 +294,8 @@ class DebugFileLogger { for (final line in lines) { final lineBytes = line.length + 1; // +1 for newline - if (currentSize + lineBytes > maxUploadSizeBytes && currentChunk.isNotEmpty) { + if (currentSize + lineBytes > maxUploadSizeBytes && + currentChunk.isNotEmpty) { chunkLines.add(currentChunk); currentChunk = []; currentSize = 0; diff --git a/lib/services/debug_submit_service.dart b/lib/services/debug_submit_service.dart index fe902ba3..df147e74 100644 --- a/lib/services/debug_submit_service.dart +++ b/lib/services/debug_submit_service.dart @@ -135,7 +135,8 @@ class DebugSubmitService { ); if (ticketResult == null || ticketResult['success'] != true) { - final error = ticketResult?['message'] as String? ?? 'Failed to create ticket'; + final error = + ticketResult?['message'] as String? ?? 'Failed to create ticket'; debugError('[BUG REPORT] FAILED: Ticket creation failed: $error'); debugLog('[BUG REPORT] ========================================'); return BugReportResult.error(error); @@ -167,11 +168,13 @@ class DebugSubmitService { debugLog('[BUG REPORT] ----------------------------------------'); debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: $filename'); - reportProgress('Uploading $filename...', fileProgress, currentFile: i + 1); + reportProgress('Uploading $filename...', fileProgress, + currentFile: i + 1); // Add delay before file uploads to prevent server overload if (totalFiles > 1) { - final delayMs = i == 0 ? 500 : 1000; // 500ms before first, 1s between others + final delayMs = + i == 0 ? 500 : 1000; // 500ms before first, 1s between others debugLog('[BUG REPORT] Waiting ${delayMs}ms before upload...'); await Future.delayed(Duration(milliseconds: delayMs)); } @@ -188,16 +191,20 @@ class DebugSubmitService { if (success) { uploadedCount++; debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: SUCCESS'); - reportProgress('Uploaded $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress('Uploaded $filename', fileProgress + progressPerFile, + currentFile: i + 1); } else { failedCount++; debugError('[BUG REPORT] File ${i + 1}/$totalFiles: FAILED'); - reportProgress('Failed to upload $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress( + 'Failed to upload $filename', fileProgress + progressPerFile, + currentFile: i + 1); } } debugLog('[BUG REPORT] ----------------------------------------'); - debugLog('[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); + debugLog( + '[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); } reportProgress('Finalizing...', 0.95); @@ -205,7 +212,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] ========================================'); debugLog('[BUG REPORT] Bug report submission complete'); debugLog('[BUG REPORT] Issue: #$issueNumber'); - debugLog('[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); + debugLog( + '[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); debugLog('[BUG REPORT] ========================================'); reportProgress('Complete!', 1.0); @@ -250,13 +258,15 @@ class DebugSubmitService { } // File was split into chunks - debugLog('[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); + debugLog( + '[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); bool allSucceeded = true; try { for (int i = 0; i < chunks.length; i++) { final chunkName = chunks[i].path.split('/').last; - debugLog('[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); + debugLog( + '[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); if (i > 0) { // Delay between chunk uploads @@ -274,11 +284,13 @@ class DebugSubmitService { ); if (!success) { - debugError('[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); + debugError( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); allSucceeded = false; break; } - debugLog('[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); + debugLog( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); } } finally { // Always clean up temp chunk files @@ -306,7 +318,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(file); final fileSize = await file.length(); final fileSizeKb = (fileSize / 1024).toStringAsFixed(1); - debugLog('[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL debugLog('[BUG REPORT] Step 2/4: Requesting upload URL...'); @@ -320,10 +333,12 @@ class DebugSubmitService { ); if (session == null) { - debugError('[BUG REPORT] FAILED: Could not get upload URL for: $filename'); + debugError( + '[BUG REPORT] FAILED: Could not get upload URL for: $filename'); return false; } - debugLog('[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); + debugLog( + '[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); // Step 3: Upload the file (with retry logic) debugLog('[BUG REPORT] Step 3/4: Uploading file data...'); @@ -343,19 +358,22 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; // 2s, 4s backoff - debugWarn('[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); + debugError( + '[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); return false; } // Step 4: Complete the upload with GitHub issue reference debugLog('[BUG REPORT] Step 4/4: Confirming upload...'); - final userNotes = issueNumber != null ? 'GitHub Issue: $issueNumber' : null; + final userNotes = + issueNumber != null ? 'GitHub Issue: $issueNumber' : null; if (userNotes != null) { debugLog('[BUG REPORT] User notes: $userNotes'); } @@ -369,8 +387,10 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); - debugWarn('[BUG REPORT] File was uploaded but confirmation failed - treating as success'); + debugWarn( + '[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); + debugWarn( + '[BUG REPORT] File was uploaded but confirmation failed - treating as success'); } else { debugLog('[BUG REPORT] SUCCESS: Upload confirmed'); } @@ -407,20 +427,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] body: ${body.length} chars'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { debugError('[BUG REPORT] HTTP error: ${response.statusCode}'); debugError('[BUG REPORT] Response body: ${response.body}'); - return {'success': false, 'message': 'Server error: ${response.statusCode}'}; + return { + 'success': false, + 'message': 'Server error: ${response.statusCode}' + }; } final data = json.decode(response.body) as Map; @@ -466,21 +492,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] POST $url'); debugLog('[BUG REPORT] Request payload:'); debugLog('[BUG REPORT] device_id: $deviceId'); - debugLog('[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); - debugLog('[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); + debugLog( + '[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); + debugLog( + '[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); debugLog('[BUG REPORT] file_hash: ${fileHash.substring(0, 16)}...'); debugLog('[BUG REPORT] app_version: $appVersion'); debugLog('[BUG REPORT] platform: $platform'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -492,7 +523,8 @@ class DebugSubmitService { final data = json.decode(response.body) as Map; debugLog('[BUG REPORT] Response JSON:'); debugLog('[BUG REPORT] session_id: ${data['session_id']}'); - debugLog('[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); + debugLog( + '[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); debugLog('[BUG REPORT] expires_at: ${data['expires_at']}'); if (data['upload_url'] == null || data['session_id'] == null) { @@ -532,15 +564,19 @@ class DebugSubmitService { )); final stopwatch = Stopwatch()..start(); - final streamedResponse = await request.send().timeout(const Duration(seconds: 120)); + final streamedResponse = + await request.send().timeout(const Duration(seconds: 120)); final response = await http.Response.fromStream(streamedResponse); stopwatch.stop(); - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); final speedKbps = fileSize > 0 - ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)).toStringAsFixed(1) + ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)) + .toStringAsFixed(1) : '0'; - debugLog('[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); + debugLog( + '[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -556,7 +592,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] message: ${data['message']}'); } if (data['stored_hash'] != null) { - debugLog('[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); + debugLog( + '[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); } final success = data['success'] == true; @@ -598,14 +635,17 @@ class DebugSubmitService { } final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -658,7 +698,8 @@ class DebugSubmitService { if (isChunked) { final fileSize = await file.length(); - debugLog('[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); + debugLog( + '[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); } // Progress range: 0.1 to 0.9 divided across chunks @@ -676,9 +717,11 @@ class DebugSubmitService { } void reportChunkProgress(String status, double chunkProgress) { - final overallProgress = chunkBase + (chunkProgress * progressPerChunk); + final overallProgress = + chunkBase + (chunkProgress * progressPerChunk); onProgress?.call(BugReportProgress( - status: isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, + status: + isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, progress: overallProgress.clamp(0.0, 1.0), currentFile: isChunked ? i + 1 : 1, totalFiles: isChunked ? totalChunks : 1, @@ -697,7 +740,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(chunk); final chunkSize = await chunk.length(); final chunkSizeKb = (chunkSize / 1024).toStringAsFixed(1); - debugLog('[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL reportChunkProgress('Requesting upload...', 0.2); @@ -712,7 +756,8 @@ class DebugSubmitService { ); if (session == null) { - debugError('[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); allSucceeded = false; break; } @@ -737,13 +782,15 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; - debugWarn('[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); allSucceeded = false; break; } @@ -760,7 +807,8 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[DEBUG UPLOAD] Confirmation failed but file was uploaded'); + debugWarn( + '[DEBUG UPLOAD] Confirmation failed but file was uploaded'); } debugLog('[DEBUG UPLOAD] Chunk ${i + 1}/$totalChunks complete'); @@ -781,7 +829,8 @@ class DebugSubmitService { totalFiles: totalChunks, )); debugLog('[DEBUG UPLOAD] ========================================'); - debugLog('[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); + debugLog( + '[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); debugLog('[DEBUG UPLOAD] ========================================'); } else { debugLog('[DEBUG UPLOAD] ========================================'); diff --git a/lib/services/device_model_service.dart b/lib/services/device_model_service.dart index c9aecb2a..6e9f9aac 100644 --- a/lib/services/device_model_service.dart +++ b/lib/services/device_model_service.dart @@ -6,7 +6,7 @@ import '../models/device_model.dart'; /// Device model service for auto-power selection /// Ported from parseDeviceModel() and autoSetPowerLevel() in wardrive.js -/// +/// /// CRITICAL: Correct power configuration is essential for PA amplifier models /// to prevent hardware damage. class DeviceModelService { @@ -24,9 +24,10 @@ class DeviceModelService { if (_isLoaded) return; try { - final jsonString = await rootBundle.loadString('assets/device-models.json'); + final jsonString = + await rootBundle.loadString('assets/device-models.json'); final jsonData = json.decode(jsonString) as Map; - + final database = DeviceModelsDatabase.fromJson(jsonData); _models = database.devices; _isLoaded = true; @@ -39,7 +40,7 @@ class DeviceModelService { /// Match device manufacturer string to known model /// Reference: parseDeviceModel() in wardrive.js - /// + /// /// Strips build suffix (e.g., "nightly-e31c46f") and matches against database DeviceModel? matchDevice(String manufacturerString) { if (_models.isEmpty) return null; @@ -68,7 +69,7 @@ class DeviceModelService { final parts = cleanManufacturer.split(RegExp(r'[\s\-_()]+')); for (final model in _models) { final modelParts = model.manufacturer.split(RegExp(r'[\s\-_()]+')); - + // Check if key identifying parts match int matchCount = 0; for (final modelPart in modelParts) { @@ -76,7 +77,7 @@ class DeviceModelService { matchCount++; } } - + // Require at least 2 matching parts if (matchCount >= 2) { return model; diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 6eced5cd..43f951a1 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -15,15 +15,15 @@ import 'gps_simulator_service.dart'; class GpsService { /// Minimum distance (meters) from last ping before allowing new ping static const double minDistanceMeters = 25.0; - + /// Maximum GPS age for manual pings (60 seconds) /// Reference: GPS_WATCH_MAX_AGE_MS in wardrive.js static const Duration maxGpsAgeForManualPing = Duration(seconds: 60); - + /// Maximum GPS accuracy threshold for pings (100 meters) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js docs static const double maxAccuracyMetersForPing = 100.0; - + /// Maximum GPS accuracy threshold for zone checks (50 meters) /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; @@ -36,8 +36,10 @@ class GpsService { /// Set the minimum ping distance (clamped to 25m floor) void setMinPingDistance(double meters) { - _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; - debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + _configuredMinDistance = + meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog( + '[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); } final _statusController = StreamController.broadcast(); @@ -105,7 +107,8 @@ class GpsService { } if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); _updateStatus(GpsStatus.permissionDenied); return false; } @@ -143,7 +146,8 @@ class GpsService { // If denied forever, can't request again - user must go to settings if (current == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); return false; } @@ -176,7 +180,8 @@ class GpsService { // Ensure only one active position stream subscription exists. // startWatching() can be called multiple times (e.g. after permission flow). if (_positionSubscription != null) { - debugLog('[GPS] Existing position subscription found, restarting watcher'); + debugLog( + '[GPS] Existing position subscription found, restarting watcher'); await _positionSubscription?.cancel(); _positionSubscription = null; } @@ -185,7 +190,8 @@ class GpsService { final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); if (!serviceEnabled) { - debugLog('[GPS] Location services DISABLED at system level - user must enable in Settings'); + debugLog( + '[GPS] Location services DISABLED at system level - user must enable in Settings'); _updateStatus(GpsStatus.disabled); return; } @@ -199,18 +205,22 @@ class GpsService { final permission = await Geolocator.checkPermission(); final hasPermission = permission == LocationPermission.always || permission == LocationPermission.whileInUse; - debugLog('[GPS] Permission check: $permission (hasPermission=$hasPermission)'); + debugLog( + '[GPS] Permission check: $permission (hasPermission=$hasPermission)'); if (!hasPermission) { if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); } else { - debugLog('[GPS] Permission not granted - waiting for disclosure flow'); + debugLog( + '[GPS] Permission not granted - waiting for disclosure flow'); } _updateStatus(GpsStatus.permissionDenied); return; } } else { - debugLog('[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); + debugLog( + '[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); } debugLog('[GPS] Starting position stream listener...'); @@ -228,11 +238,13 @@ class GpsService { _positionSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, - distanceFilter: 10, // Trigger every 10m movement (check RX batches at 25m) + distanceFilter: + 10, // Trigger every 10m movement (check RX batches at 25m) ), ).listen( (position) { - debugLog('[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' 'lon=${position.longitude.toStringAsFixed(5)}, accuracy=${position.accuracy.toStringAsFixed(1)}m'); _lastPosition = position; _positionController.add(position); @@ -253,7 +265,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); - debugLog('[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; // Note: Don't emit via _positionController here — the stream listener @@ -261,7 +274,8 @@ class GpsService { // would cause duplicate position events (~0.15ms apart). _updateStatus(GpsStatus.locked); } catch (e) { - debugLog('[GPS] Initial position request failed: $e (will wait for stream updates)'); + debugLog( + '[GPS] Initial position request failed: $e (will wait for stream updates)'); // Will receive updates from stream } } @@ -303,19 +317,19 @@ class GpsService { final age = DateTime.now().difference(position.timestamp); return age <= maxGpsAgeForManualPing; } - + /// Check if GPS position has acceptable accuracy for pings (< 100m) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js bool isAccuracyAcceptableForPing(Position position) { return position.accuracy <= maxAccuracyMetersForPing; } - + /// Check if GPS position has acceptable accuracy for zone checks (< 50m) /// Reference: getValidGpsForZoneCheck() in wardrive.js bool isAccuracyAcceptableForZoneCheck(Position position) { return position.accuracy <= maxAccuracyMetersForZoneCheck; } - + /// Validate position for ping operation /// Checks freshness (< 60s old) and accuracy (< 100m) /// Returns null if valid, error message if invalid @@ -326,17 +340,17 @@ class GpsService { debugWarn('[GPS] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy if (!isAccuracyAcceptableForPing(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] Position too inaccurate: ${accuracy}m (max 100m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } - + /// Validate position for zone check operation /// Checks freshness (< 60s old) and accuracy (< 50m, stricter than ping) /// Returns null if valid, error message if invalid @@ -347,21 +361,22 @@ class GpsService { debugWarn('[GPS] [AUTH] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy (stricter for zone checks) if (!isAccuracyAcceptableForZoneCheck(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] [AUTH] Position too inaccurate: ${accuracy}m (max 50m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } /// Request a fresh GPS position from the hardware for auto-ping accuracy. /// On mobile, this forces a warm-start GPS read (typically < 1 second when /// GPS is already streaming). Falls back to lastPosition on timeout/error. - Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) async { // Simulator provides its own positions — use cached if (_simulatorEnabled) { return _lastPosition; @@ -372,7 +387,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: timeout, ); - debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; return position; diff --git a/lib/services/gps_simulator_service.dart b/lib/services/gps_simulator_service.dart index 92185b81..0c1b4499 100644 --- a/lib/services/gps_simulator_service.dart +++ b/lib/services/gps_simulator_service.dart @@ -10,10 +10,13 @@ import '../utils/debug_logger_io.dart'; enum SimulatorPattern { /// Move in a straight line in the configured direction straight, + /// Move in a circle around the start point circle, + /// Random walk with smooth direction changes randomWalk, + /// Follow a loaded route (KML/GPX) route, } @@ -143,7 +146,8 @@ class GpsSimulatorService { _circleAngle = 0; } - debugLog('[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); + debugLog( + '[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); } /// Start the simulator @@ -151,7 +155,8 @@ class GpsSimulatorService { if (_isRunning) return; _isRunning = true; - debugLog('[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); + debugLog( + '[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); // Emit initial position immediately _emitPosition(); @@ -184,7 +189,8 @@ class GpsSimulatorService { _targetHeading = 45; _routeIndex = 0; _routeProgress = 0; - debugLog('[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); + debugLog( + '[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); } /// Load route from KML file content @@ -236,7 +242,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing KML: $e'); @@ -263,10 +270,12 @@ class GpsSimulatorService { final lat = double.tryParse(pt.getAttribute('lat') ?? ''); final lon = double.tryParse(pt.getAttribute('lon') ?? ''); final eleElement = pt.findElements('ele').firstOrNull; - final alt = eleElement != null ? double.tryParse(eleElement.innerText) : null; + final alt = + eleElement != null ? double.tryParse(eleElement.innerText) : null; if (lat != null && lon != null) { - coordinates.add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); + coordinates + .add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); } } @@ -309,7 +318,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing GPX: $e'); @@ -320,18 +330,30 @@ class GpsSimulatorService { /// Extract route name from GPX document String _extractGpxName(XmlDocument document) { // Try track name first - final trkName = document.findAllElements('trk').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final trkName = document + .findAllElements('trk') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (trkName != null) return trkName; // Try route name - final rteName = document.findAllElements('rte').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final rteName = document + .findAllElements('rte') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (rteName != null) return rteName; // Try metadata name - final metaName = document.findAllElements('metadata').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final metaName = document + .findAllElements('metadata') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (metaName != null) return metaName; return 'Unnamed Route'; @@ -412,8 +434,10 @@ class GpsSimulatorService { // Calculate distance between current and next point final segmentDistanceM = _haversineDistance( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); if (segmentDistanceM < 1) { @@ -461,24 +485,31 @@ class GpsSimulatorService { final nextPoint = _routePoints[nextIndex]; final t = _routeProgress.clamp(0.0, 1.0); - _latitude = currentPoint.latitude + (nextPoint.latitude - currentPoint.latitude) * t; - _longitude = currentPoint.longitude + (nextPoint.longitude - currentPoint.longitude) * t; + _latitude = currentPoint.latitude + + (nextPoint.latitude - currentPoint.latitude) * t; + _longitude = currentPoint.longitude + + (nextPoint.longitude - currentPoint.longitude) * t; // Calculate heading towards next point _heading = _calculateBearing( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); } /// Haversine distance between two points in meters - double _haversineDistance(double lat1, double lon1, double lat2, double lon2) { + double _haversineDistance( + double lat1, double lon1, double lat2, double lon2) { const R = 6371000.0; // Earth radius in meters final dLat = (lat2 - lat1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180; final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * - sin(dLon / 2) * sin(dLon / 2); + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); final c = 2 * atan2(sqrt(a), sqrt(1 - a)); return R * c; } @@ -490,8 +521,8 @@ class GpsSimulatorService { final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; // Normalize to 0-360 } @@ -509,7 +540,8 @@ class GpsSimulatorService { // 1 degree latitude ≈ 111 km // 1 degree longitude ≈ 111 km * cos(latitude) final latChange = (distanceKm / 111) * cos(headingRad); - final lonChange = (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); + final lonChange = + (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); _latitude += latChange; _longitude += lonChange; @@ -530,7 +562,8 @@ class GpsSimulatorService { // Calculate position on circle final angleRad = _circleAngle * pi / 180; _latitude = _circleCenterLat + _circleRadius * cos(angleRad); - _longitude = _circleCenterLon + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); + _longitude = _circleCenterLon + + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); // Update heading to be tangent to circle _heading = (_circleAngle + 90) % 360; diff --git a/lib/services/meshcore/buffer_utils.dart b/lib/services/meshcore/buffer_utils.dart index 57345363..d7f80362 100644 --- a/lib/services/meshcore/buffer_utils.dart +++ b/lib/services/meshcore/buffer_utils.dart @@ -106,7 +106,6 @@ class BufferReader { } return value; } - } /// Buffer writer for creating binary data for MeshCore devices @@ -155,16 +154,16 @@ class BufferWriter { void writeCString(String string, int maxLength) { final encoded = utf8.encode(string); final bytes = Uint8List(maxLength); - + // Copy string bytes up to maxLength - 1 final copyLength = math.min(encoded.length, maxLength - 1); for (int i = 0; i < copyLength; i++) { bytes[i] = encoded[i]; } - + // Ensure last byte is null terminator bytes[maxLength - 1] = 0; - + writeBytes(bytes); } diff --git a/lib/services/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index 44873978..d92573c9 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -38,13 +38,17 @@ class ChannelService { // Always add #wardriving (required for TX) final wardrivingKey = CryptoService.getChannelKey(wardrivingChannelName); final wardrivingHash = CryptoService.computeChannelHash(wardrivingKey); - _allowedChannels[wardrivingChannelName] = _ChannelData(key: wardrivingKey, hash: wardrivingHash); + _allowedChannels[wardrivingChannelName] = + _ChannelData(key: wardrivingKey, hash: wardrivingHash); debugLog('[CHANNEL] Added: $wardrivingChannelName -> hash=$wardrivingHash'); // Add regional channels from API for (final name in channelNames) { - final channelName = name.toLowerCase() == 'public' ? 'Public' : - name.startsWith('#') ? name : '#$name'; + final channelName = name.toLowerCase() == 'public' + ? 'Public' + : name.startsWith('#') + ? name + : '#$name'; // Skip if already added if (_allowedChannels.containsKey(channelName)) continue; @@ -95,7 +99,8 @@ class ChannelService { /// Get all allowed channels for RX validation /// Returns a map of channel hash -> channel info for use with PacketValidator - static Map getAllowedChannelsForValidator() { + static Map + getAllowedChannelsForValidator() { final result = {}; for (final entry in _allowedChannels.entries) { result[entry.value.hash] = ( @@ -114,7 +119,8 @@ class ChannelService { /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the created channel /// @throws Exception if no empty slots or creation fails - static Future createWardrivingChannel(MeshCoreConnection connection) async { + static Future createWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Attempting to create channel: $wardrivingChannelName'); // Get all channels @@ -143,9 +149,11 @@ class ChannelService { final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); // Create the channel - debugLog('[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); + debugLog( + '[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); await connection.setChannel(emptyIdx, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); // Return channel info return ChannelInfo( @@ -161,7 +169,8 @@ class ChannelService { /// /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the wardriving channel - static Future ensureWardrivingChannel(MeshCoreConnection connection) async { + static Future ensureWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Looking up channel: $wardrivingChannelName'); // Scan ALL channels to find #wardriving or first empty slot @@ -179,7 +188,8 @@ class ChannelService { try { channel = await connection.getChannel(channelIdx); } catch (e) { - debugLog('[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); + debugLog( + '[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); await Future.delayed(const Duration(milliseconds: 100)); channel = await connection.getChannel(channelIdx); } @@ -189,7 +199,8 @@ class ChannelService { // Found existing #wardriving channel - return immediately! if (channel.name == wardrivingChannelName) { - debugLog('[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); + debugLog( + '[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); return channel; } @@ -211,16 +222,20 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { - debugError('[CHANNEL] No empty channel slots found in first $channelIdx channels'); + debugError( + '[CHANNEL] No empty channel slots found in first $channelIdx channels'); throw Exception( 'No empty channel slots available. Please free a channel slot on your companion first.', ); } - debugLog('[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); + debugLog( + '[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); - await connection.setChannel(firstEmptySlot, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); + await connection.setChannel( + firstEmptySlot, wardrivingChannelName, channelKey); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); return ChannelInfo( channelIndex: firstEmptySlot, @@ -230,7 +245,7 @@ class ChannelService { } /// Delete #wardriving channel on disconnect - /// + /// /// @param connection - Active MeshCore connection /// @param channelIdx - Index of the channel to delete static Future deleteWardrivingChannel( diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 66bc30c7..1ed79776 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,8 +17,10 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 - final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) - final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) + final String? + firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? + pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, @@ -47,7 +49,10 @@ class SelfInfo { }); /// Get public key as hex string - String get publicKeyHex => publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + String get publicKeyHex => publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// MeshCore connection manager @@ -67,10 +72,13 @@ class MeshCoreConnection { final BluetoothService _bluetooth; bool _disposed = false; final _stepController = StreamController.broadcast(); - final _channelMessageController = StreamController.broadcast(); + final _channelMessageController = + StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); - final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); - final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _logRxDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _controlDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -108,7 +116,8 @@ class MeshCoreConnection { int? _lastBatteryMilliVolts; // millivolts or null if not supported Timer? _batteryTimer; - MeshCoreConnection({required BluetoothService bluetooth}) : _bluetooth = bluetooth { + MeshCoreConnection({required BluetoothService bluetooth}) + : _bluetooth = bluetooth { _dataSubscription = _bluetooth.dataStream.listen(_onFrameReceived); } @@ -116,16 +125,19 @@ class MeshCoreConnection { Stream get stepStream => _stepController.stream; /// Stream of channel messages (for RX pings) - Stream get channelMessageStream => _channelMessageController.stream; + Stream get channelMessageStream => + _channelMessageController.stream; /// Stream of raw data pushes Stream> get rawDataStream => _rawDataController.stream; /// Stream of LogRxData packets (for unified RX handler) - Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => _logRxDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => + _logRxDataController.stream; /// Stream of ControlData packets (for discovery responses) - Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + _controlDataController.stream; /// Stream of TraceData packets (for trace path responses) /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly @@ -173,13 +185,16 @@ class MeshCoreConnection { /// Wardriving channel hash (for echo correlation) - null if not connected int? get wardrivingChannelHash { final channel = _wardrivingChannel; - return channel != null ? CryptoService.computeChannelHash(channel.secret) : null; + return channel != null + ? CryptoService.computeChannelHash(channel.secret) + : null; } void _updateStep(ConnectionStep step) { _currentStep = step; if (_disposed || _stepController.isClosed) { - debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); + debugLog( + '[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -189,7 +204,8 @@ class MeshCoreConnection { /// Execute the full connection workflow /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info - Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect( + String deviceId, List deviceModels) async { if (_disposed) { throw Exception('Connection instance has been disposed'); } @@ -206,7 +222,8 @@ class MeshCoreConnection { // Step 3: Device Query _updateStep(ConnectionStep.deviceQuery); - _deviceInfo = await deviceQuery(ProtocolConstants.supportedCompanionProtocolVersion); + _deviceInfo = await deviceQuery( + ProtocolConstants.supportedCompanionProtocolVersion); // Step 3b: Get Self Info (contains public key) // This is critical for geo-auth API authentication @@ -216,7 +233,8 @@ class MeshCoreConnection { if (pubKeyHex == null) { throw Exception('getSelfInfo() returned null public key'); } - debugLog('[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); } catch (e) { debugError('[CONN] Failed to get self info (public key): $e'); // Public key is REQUIRED for geo-auth API @@ -232,9 +250,11 @@ class MeshCoreConnection { final matchedModel = _deviceModel; if (matchedModel != null) { deviceModelMatched = true; - debugLog('[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); + debugLog( + '[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); } else { - debugLog('[CONN] Device model not recognized - user must manually select power level for reporting'); + debugLog( + '[CONN] Device model not recognized - user must manually select power level for reporting'); } // Step 5: Time Sync @@ -249,20 +269,24 @@ class MeshCoreConnection { if (authResult == null || authResult['success'] != true) { final reason = authResult?['reason'] ?? 'unknown'; final message = authResult?['message'] ?? 'Authentication failed'; - debugError('[CONN] API session acquisition failed: $reason - $message'); + debugError( + '[CONN] API session acquisition failed: $reason - $message'); // Throw with reason code prefix for proper error handling throw Exception('AUTH_FAILED:$reason:$message'); } - debugLog('[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); + debugLog( + '[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); } else { - debugLog('[CONN] No auth callback set, skipping API session acquisition'); + debugLog( + '[CONN] No auth callback set, skipping API session acquisition'); } // Step 7: Channel Setup _updateStep(ConnectionStep.channelSetup); debugLog('[CONN] Creating #wardriving channel'); _wardrivingChannel = await ChannelService.ensureWardrivingChannel(this); - debugLog('[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); + debugLog( + '[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); // Step 8: GPS Init (handled externally) _updateStep(ConnectionStep.gpsInit); @@ -282,7 +306,10 @@ class MeshCoreConnection { // This may fail on older firmware (< v1.11.0) _startNoiseFloorPolling(); - return (deviceModel: _deviceModel, deviceModelMatched: deviceModelMatched); + return ( + deviceModel: _deviceModel, + deviceModelMatched: deviceModelMatched + ); } catch (e) { debugError('[CONN] Connection failed: $e'); _updateStep(ConnectionStep.error); @@ -338,24 +365,25 @@ class MeshCoreConnection { /// Match manufacturer string to device model /// Reference: parseDeviceModel() in wardrive.js - DeviceModel? _matchDeviceModel(String manufacturer, List models) { + DeviceModel? _matchDeviceModel( + String manufacturer, List models) { // Strip build suffix (e.g., "nightly-e31c46f") final cleanManufacturer = manufacturer.split(' ').first; - + for (final model in models) { if (manufacturer.contains(model.manufacturer) || cleanManufacturer.contains(model.manufacturer)) { return model; } } - + // Try partial match on short name for (final model in models) { if (manufacturer.toLowerCase().contains(model.shortName.toLowerCase())) { return model; } } - + return null; } @@ -364,12 +392,14 @@ class MeshCoreConnection { if (frame.isEmpty) return; try { - debugLog('[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); - + debugLog( + '[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); + final reader = BufferReader(frame); final responseCode = reader.readByte(); - - debugLog('[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); + + debugLog( + '[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); switch (responseCode) { case ResponseCodes.ok: @@ -378,14 +408,17 @@ class MeshCoreConnection { _setTimeCompleter = null; break; case ResponseCodes.err: - final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; + final errorCode = + reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success if (_setTimeCompleter != null) { if (errorCode == 6) { - debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + debugLog( + '[CONN] Time sync not needed (error code 6) - treating as success'); } else { - debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + debugWarn( + '[CONN] Time sync error (code $errorCode) - continuing anyway'); } _setTimeCompleter?.complete(); _setTimeCompleter = null; @@ -440,7 +473,8 @@ class MeshCoreConnection { break; default: // Log unhandled response codes (like JS implementation) - debugLog('[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); + debugLog( + '[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); break; } } catch (e, stack) { @@ -490,7 +524,8 @@ class MeshCoreConnection { // path_hash_mode: 1 byte (v10+) if (reader.remainingBytesCount >= 1) { pathHashMode = reader.readByte(); - debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + debugLog( + '[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); } } @@ -513,12 +548,12 @@ class MeshCoreConnection { reader.readBytes(32); // skip public key debugLog('[CONN] Manufacturer: $manufacturer'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturer, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } @@ -539,14 +574,14 @@ class MeshCoreConnection { // Skip additional fields added in newer firmware versions // These fields exist between publicKey and name if (reader.remainingBytesCount >= 22) { - reader.readInt32LE(); // advLat - reader.readInt32LE(); // advLon - reader.readBytes(3); // reserved - reader.readByte(); // manualAddContacts + reader.readInt32LE(); // advLat + reader.readInt32LE(); // advLon + reader.readBytes(3); // reserved + reader.readByte(); // manualAddContacts reader.readUInt32LE(); // radioFreq reader.readUInt32LE(); // radioBw - reader.readByte(); // radioSf - reader.readByte(); // radioCr + reader.readByte(); // radioSf + reader.readByte(); // radioCr } // Read name from remaining bytes @@ -561,7 +596,8 @@ class MeshCoreConnection { ); _selfInfo = selfInfo; - debugLog('[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; @@ -697,7 +733,8 @@ class MeshCoreConnection { // Consume any remaining bytes (firmware may send extended format) if (reader.remainingBytesCount > 0) { final extraBytes = reader.readRemainingBytes(); - debugLog('[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); + debugLog( + '[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); } _batteryController.add(percent); // Emit percentage to stream @@ -719,10 +756,13 @@ class MeshCoreConnection { void _onExportContactResponse(BufferReader reader) { try { final advertPacketBytes = reader.readRemainingBytes(); - final hexString = advertPacketBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + final hexString = advertPacketBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); final contactUri = 'meshcore://$hexString'; - debugLog('[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); + debugLog( + '[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); _exportContactCompleter?.complete(contactUri); _exportContactCompleter = null; @@ -755,7 +795,8 @@ class MeshCoreConnection { /// Get device self info (includes public key) /// Reference: getSelfInfo() in connection.js - Future getSelfInfo({Duration timeout = const Duration(seconds: 5)}) async { + Future getSelfInfo( + {Duration timeout = const Duration(seconds: 5)}) async { _selfInfoCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -845,10 +886,11 @@ class MeshCoreConnection { final future = _channelInfoCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.getChannel); // 31 (0x1F) + data.writeByte(CommandCodes.getChannel); // 31 (0x1F) data.writeByte(channelIdx); final bytes = data.toBytes(); - debugLog('[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); + debugLog( + '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); await _bluetooth.write(bytes); return future.timeout( @@ -926,7 +968,8 @@ class MeshCoreConnection { Future findChannelBySecret(Uint8List secret) async { final channels = await getChannels(); try { - return channels.firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); + return channels + .firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); } catch (e) { return null; // Not found } @@ -943,7 +986,8 @@ class MeshCoreConnection { /// Send channel text message (for TX pings) /// Reference: sendCommandSendChannelTxtMsg in connection.js - Future sendChannelTextMessage(int txtType, int channelIdx, int senderTimestamp, String text) async { + Future sendChannelTextMessage( + int txtType, int channelIdx, int senderTimestamp, String text) async { _sentCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -982,7 +1026,8 @@ class MeshCoreConnection { debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await sendChannelTextMessage(TxtTypes.plain, channel.channelIndex, timestamp, message); + await sendChannelTextMessage( + TxtTypes.plain, channel.channelIndex, timestamp, message); } /// Send discovery request to find nearby repeaters/rooms @@ -1010,11 +1055,12 @@ class MeshCoreConnection { '${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendControlData); // 0x37 - data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ - data.writeByte(DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM - data.writeBytes(tag); // 4-byte random tag - data.writeUInt32LE(0); // timestamp = 0 (discover all) + data.writeByte(CommandCodes.sendControlData); // 0x37 + data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ + data.writeByte( + DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM + data.writeBytes(tag); // 4-byte random tag + data.writeUInt32LE(0); // timestamp = 0 (discover all) await _sendToRadio(data); return tag; @@ -1023,31 +1069,41 @@ class MeshCoreConnection { /// Send trace path to a specific repeater (targeted ping / zero-hop trace) /// Returns the 4-byte tag used for matching the response /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) - Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) async { final random = Random.secure(); final tag = Uint8List.fromList([ - random.nextInt(256), random.nextInt(256), - random.nextInt(256), random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), ]); // Trace uses bitshift encoding: actual_bytes = 1 << path_sz // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 final int pathSz; switch (hopBytes) { - case 4: pathSz = 2; break; - case 2: pathSz = 1; break; - default: pathSz = 0; break; + case 4: + pathSz = 2; + break; + case 2: + pathSz = 1; + break; + default: + pathSz = 0; + break; } final int flags = pathSz & 0x03; - debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + debugLog( + '[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendTracePath); // 0x24 - data.writeBytes(tag); // 4-byte tag - data.writeUInt32LE(0); // auth_code = 0 - data.writeByte(flags); // flags with path_sz in bits 0-1 - data.writeBytes(repeaterIdBytes); // target repeater ID + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID await _sendToRadio(data); return tag; } @@ -1061,12 +1117,13 @@ class MeshCoreConnection { /// Export signed contact URI for API authentication /// Returns meshcore:// URI containing signed ADVERT packet - Future exportContact({Duration timeout = const Duration(seconds: 5)}) async { + Future exportContact( + {Duration timeout = const Duration(seconds: 5)}) async { _exportContactCompleter = Completer(); final future = _exportContactCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.exportContact); // 0x11 + data.writeByte(CommandCodes.exportContact); // 0x11 await _sendToRadio(data); return future.timeout( @@ -1129,7 +1186,8 @@ class MeshCoreConnection { _noiseFloorFailCount++; debugLog('[CONN] Noise floor fetch failed ($_noiseFloorFailCount/3): $e'); if (_noiseFloorFailCount >= 3) { - debugLog('[CONN] Noise floor polling stopped after 3 consecutive failures'); + debugLog( + '[CONN] Noise floor polling stopped after 3 consecutive failures'); _stopNoiseFloorPolling(); } } finally { diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index 30da8862..ea6f559b 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -12,28 +12,43 @@ class CryptoService { /// Fixed key for "Public" channel (non-hashtag channels) /// From MeshCore default: 8b3387e9c5cdea6ac9e5edbaa115cd72 static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a channel name using SHA-256 - /// + /// /// Matches JS implementation: `sha256(channelName).subarray(0, 16)` - /// + /// /// @param channelName - Channel name (must start with # for hashtag channels) /// @returns 16-byte channel key /// @throws FormatException if channel name is invalid static Uint8List deriveChannelKey(String channelName) { debugLog('[CRYPTO] Deriving channel key for: $channelName'); - + // Validate channel name format: must start with # and contain only letters, numbers, and dashes if (!channelName.startsWith('#')) { - throw FormatException('Channel name must start with # (got: "$channelName")'); + throw FormatException( + 'Channel name must start with # (got: "$channelName")'); } - + // Normalize channel name to lowercase (MeshCore convention) final normalizedName = channelName.toLowerCase(); - + // Check that the part after # contains only letters, numbers, and dashes final nameWithoutHash = normalizedName.substring(1); if (!RegExp(r'^[a-z0-9-]+$').hasMatch(nameWithoutHash)) { @@ -42,16 +57,17 @@ class CryptoService { 'Only letters, numbers, and dashes are allowed.', ); } - + // Hash using SHA-256 final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); - + // Take the first 16 bytes of the hash as the channel key final channelKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - - debugLog('[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); - + + debugLog( + '[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); + return channelKey; } @@ -65,12 +81,13 @@ class CryptoService { final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + debugLog( + '[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); return scopeKey; } /// Get channel key for any channel (handles both Public and hashtag channels) - /// + /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") /// @returns 16-byte channel key static Uint8List getChannelKey(String channelName) { @@ -83,9 +100,9 @@ class CryptoService { } /// Compute channel hash from channel secret (first byte of SHA-256) - /// + /// /// Used for identifying echo packets that match our channel - /// + /// /// @param channelSecret - The 16-byte channel secret /// @returns Channel hash (first byte of SHA-256) static int computeChannelHash(Uint8List channelSecret) { @@ -94,9 +111,9 @@ class CryptoService { } /// Decrypt channel message using AES-ECB mode - /// + /// /// MeshCore uses AES-128-ECB for channel message encryption - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decrypted message bytes @@ -105,17 +122,18 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Decrypting message (${encryptedPayload.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(false, params); // false = decrypt mode - + // Decrypt the payload final decrypted = Uint8List(encryptedPayload.length); var offset = 0; @@ -137,7 +155,7 @@ class CryptoService { } /// Encrypt channel message using AES-ECB mode - /// + /// /// @param plaintext - The message bytes to encrypt /// @param channelKey - The 16-byte channel key /// @returns Encrypted message bytes @@ -146,29 +164,30 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Encrypting message (${plaintext.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Add PKCS7 padding final padded = _addPkcs7Padding(plaintext, 16); - + // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(true, params); // true = encrypt mode - + // Encrypt the payload final encrypted = Uint8List(padded.length); var offset = 0; - + while (offset < padded.length) { cipher.processBlock(padded, offset, encrypted, offset); offset += cipher.blockSize; } - + debugLog('[CRYPTO] Encrypted successfully (${encrypted.length} bytes)'); return encrypted; } catch (e) { @@ -189,9 +208,9 @@ class CryptoService { } /// Parse channel message to extract text content - /// + /// /// Decrypts and decodes the message, returning the text if printable - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decoded text or null if not printable @@ -202,15 +221,17 @@ class CryptoService { try { final decrypted = decryptChannelMessage(encryptedPayload, channelKey); final text = utf8.decode(decrypted, allowMalformed: true); - + // Check if text is printable (contains mostly ASCII printable characters) - final printableCount = text.codeUnits.where((c) => c >= 32 && c <= 126).length; + final printableCount = + text.codeUnits.where((c) => c >= 32 && c <= 126).length; final printableRatio = printableCount / text.length; - + if (printableRatio > 0.8) { return text; } else { - debugWarn('[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); + debugWarn( + '[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); return null; } } catch (e) { diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 23dd9d60..688eac4d 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -34,7 +34,10 @@ class DiscTracker { /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. final int hopBytes; - DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); + DiscTracker( + {this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -48,7 +51,8 @@ class DiscTracker { Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); - debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; startTime = DateTime.now(); @@ -58,12 +62,14 @@ class DiscTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking and return collected nodes List stopTracking() { - debugLog('[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); + debugLog( + '[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); final result = nodes.values.toList(); @@ -116,14 +122,16 @@ class DiscTracker { // Check if this is a discovery response (upper nibble = 0x90) if (upperNibble != DiscoveryConstants.discoverRespFlag) { - debugLog('[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); return false; } // Check node type (lower nibble must be REPEATER=0x01 or ROOM=0x02) if (lowerNibble != DiscoveryConstants.nodeTypeRepeater && lowerNibble != DiscoveryConstants.nodeTypeRoom) { - debugLog('[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); + debugLog( + '[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); return false; } @@ -135,28 +143,36 @@ class DiscTracker { // Extract public key (bytes 7-38) final pubkey = rawBytes.sublist(7, 39); - final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + final pubkeyHex = pubkey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); // Get repeater ID (first N hex chars based on hopBytes setting) final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); + debugLog( + '[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // Check RSSI (carpeater failsafe) if (disableRssiFilter) { - debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[DISC] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(localRssi)) { - debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); return false; } - final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater + ? 'REPEATER' + : 'ROOM'; debugLog('[DISC] Received response from $repeaterId ($nodeType): ' 'localSnr=${localSnr.toStringAsFixed(2)}, remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' @@ -212,12 +228,14 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") - final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String + pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) DiscoveredNode({ required this.repeaterId, @@ -229,8 +247,10 @@ class DiscoveredNode { }); /// Get node type as display string - String get nodeTypeName => nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + String get nodeTypeName => + nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; /// Get short display label: "(R)" for REPEATER, "(RM)" for ROOM - String get nodeTypeLabel => nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; + String get nodeTypeLabel => + nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; } diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index 49e1f0a4..6c0f41ab 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -67,14 +67,17 @@ class PacketMetadata { final int rssi = data['lastRssi'] as int; // Dump raw packet for debugging - final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); + final rawHex = raw + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); + debugLog( + '[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 @@ -92,7 +95,8 @@ class PacketMetadata { final int pathHashCount = pathLenRaw & 63; final int pathByteLen = pathHashCount * pathHashSize; - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); // Path data starts after path length byte @@ -105,11 +109,13 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; if (payloadOffset > raw.length) { - throw RangeError('Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + throw RangeError( + 'Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); } final Uint8List encryptedPayload = raw.sublist(payloadOffset); - debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' @@ -155,19 +161,22 @@ class PacketMetadata { /// Check if packet is GROUP_TEXT (channel message, header 0x15) bool get isGroupText { // Extract payload type from header - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.grpTxt; } /// Check if packet is ADVERT (node advertisement, header 0x11) bool get isAdvert { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.advert; } /// Check if packet is TRACE (trace path response, header 0x26) bool get isTrace { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.trace; } @@ -195,12 +204,18 @@ class PacketMetadata { /// Convert N bytes to uppercase hex string String _bytesToHex(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// Static version for use in factory constructor static String _bytesToHexStatic(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } @override diff --git a/lib/services/meshcore/packet_parser.dart b/lib/services/meshcore/packet_parser.dart index 6dfee5b6..84842eea 100644 --- a/lib/services/meshcore/packet_parser.dart +++ b/lib/services/meshcore/packet_parser.dart @@ -253,12 +253,12 @@ class ChannelInfo { final channelIndex = reader.readByte(); final name = reader.readCString(32); final remainingBytes = reader.remainingBytesCount; - + // Protocol v8 uses 16-byte (128-bit) keys, v1 used 32-byte keys if (remainingBytes != 16 && remainingBytes != 32) { throw Exception('ChannelInfo has unexpected key length: $remainingBytes'); } - + return ChannelInfo( channelIndex: channelIndex, name: name, diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index e9cec944..0b6af86f 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -12,7 +12,7 @@ class PacketValidator { /// Packets stronger than this are likely from co-located repeaters /// Reference: MAX_RX_RSSI_THRESHOLD in wardrive.js static const int maxRssiThreshold = -30; - + /// Minimum printable character ratio (60%) /// Lowered from 90% to allow emojis and Unicode in messages /// Still filters out completely corrupted data @@ -24,33 +24,40 @@ class PacketValidator { /// When true, skip RSSI carpeater check (user setting) final bool disableRssiFilter; - PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); + PacketValidator( + {required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) - Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { + Future validate(PacketMetadata metadata, + {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' '); debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); - debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); - debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' + debugLog( + '[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); + debugLog( + '[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) if (skipRssiCheck) { debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); } else if (isCarpeater(metadata.rssi)) { - debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' + debugLog( + '[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); } else { - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); + debugLog( + '[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } // VALIDATION 2: Check packet type @@ -83,7 +90,8 @@ class PacketValidator { // Extract channel hash final channelHash = metadata.channelHash!; - debugLog('[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); // Check if channel is in allowed list final channelInfo = allowedChannels[channelHash]; @@ -109,7 +117,8 @@ class PacketValidator { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); + debugLog( + '[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); return ValidationResult.failed('decrypted too short'); } @@ -122,21 +131,24 @@ class PacketValidator { // Remove trailing nulls and trim plaintext = plaintext.replaceAll(RegExp(r'\x00+$'), '').trim(); } catch (e) { - debugLog('[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); + debugLog( + '[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); return ValidationResult.failed('decode failed'); } // Sanitize for logging: remove replacement characters to avoid Flutter UTF-8 warnings final sanitizedForLog = plaintext - .replaceAll('\uFFFD', '') // Remove replacement characters - .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII - final logPreview = sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); + .replaceAll('\uFFFD', '') // Remove replacement characters + .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII + final logPreview = + sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); debugLog('[RX FILTER] Decrypted message (${plaintext.length} chars): ' '"$logPreview${sanitizedForLog.length > 60 ? '...' : ''}"'); // Check printable ratio final printableRatio = getPrintableRatio(plaintext); - debugLog('[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' + debugLog( + '[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' '(threshold: ${(minPrintableRatio * 100).toFixed(1)}%)'); if (printableRatio < minPrintableRatio) { @@ -163,7 +175,8 @@ class PacketValidator { return ValidationResult.failed(nameResult.reason); } - debugLog('[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); + debugLog( + '[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); return ValidationResult.success(); } @@ -199,7 +212,6 @@ class PacketValidator { return printableCount / text.length; } - /// Parse ADVERT packet name field /// Reference: parseAdvertName() in wardrive.js lines 3353-3419 static AdvertNameResult parseAdvertName(Uint8List payload) { @@ -221,7 +233,8 @@ class PacketValidator { // Read flags byte from appData final flags = payload[appDataOffset]; - debugLog('[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); // Flag masks (from advert.js) const advNameMask = 0x80; @@ -259,7 +272,8 @@ class PacketValidator { // Remove trailing nulls and whitespace name = name.replaceAll(RegExp(r'\x00+$'), '').trim(); - debugLog('[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); + debugLog( + '[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); if (name.isEmpty) { return const AdvertNameResult( @@ -271,7 +285,8 @@ class PacketValidator { // Check if name is printable (use same threshold as messages) final printableRatio = getPrintableRatio(name); - debugLog('[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); + debugLog( + '[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); if (printableRatio < minPrintableRatio) { return AdvertNameResult( diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 3dee89f4..1e2de5ed 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -18,12 +18,14 @@ class BleUuids { /// Nordic UART Service UUID static const String serviceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E'; - + /// RX Characteristic (we write to this, device reads from it) - static const String characteristicRxUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; - + static const String characteristicRxUuid = + '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; + /// TX Characteristic (device writes to this, we read from it) - static const String characteristicTxUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; + static const String characteristicTxUuid = + '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; } /// Command codes sent to device @@ -63,7 +65,8 @@ class CommandCodes { static const int signData = 34; static const int signFinish = 35; static const int sendTracePath = 36; - static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) + static const int sendControlData = + 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE @@ -115,7 +118,8 @@ class PushCodes { static const int newAdvert = 0x8A; static const int telemetryResponse = 0x8B; static const int binaryResponse = 0x8C; - static const int controlData = 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) + static const int controlData = + 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) } /// Text message types @@ -140,11 +144,11 @@ class StatsTypes { class PacketHeader { PacketHeader._(); - static const int routeMask = 0x03; // 2-bits + static const int routeMask = 0x03; // 2-bits static const int typeShift = 2; - static const int typeMask = 0x0F; // 4-bits + static const int typeMask = 0x0F; // 4-bits static const int verShift = 6; - static const int verMask = 0x03; // 2-bits + static const int verMask = 0x03; // 2-bits } /// Route types diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 0451fffc..c78829a2 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -9,14 +9,14 @@ import 'packet_validator.dart'; /// Reference: handleRxLogging() + handleRxBatching() in wardrive.js (lines 3812-4140) class RxLogger { bool isWardriving = false; - + /// Map of repeaterId (hex) -> RxBatch final Map _batchBuffer = {}; - + /// Configuration constants static const int batchDistanceMeters = 25; static const Duration batchTimeout = Duration(seconds: 30); - + /// Callback for batched/finalized RX entries (API queue posting) final Future Function(RxApiEntry) onRxEntry; @@ -67,14 +67,15 @@ class RxLogger { PacketValidator validator, ) async { if (!isWardriving) return false; - + try { debugLog('[RX LOG] Processing packet for passive logging'); - + // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info if (metadata.pathHashCount == 0) { - debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); + debugLog( + '[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } @@ -88,7 +89,8 @@ class RxLogger { // CARpeater check: the carpeater is co-located with us, so it only // appears as the last hop (the delivery repeater) on RX packets - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); return false; @@ -98,7 +100,8 @@ class RxLogger { carpeaterStripped = true; reportedSnr = null; reportedRssi = null; - debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + debugLog( + '[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); } else { repeaterId = lastHopHex; } @@ -114,14 +117,18 @@ class RxLogger { // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore // Skip for CARpeater pass-through (CARpeater itself was already handled) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(repeaterId)) { + debugLog( + '[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging // Skip RSSI check for CARpeater pass-through - final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); + final validation = + await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -131,12 +138,14 @@ class RxLogger { // Log carpeater drops to error log (without auto-switching) if (validation.reason == 'carpeater-rssi') { - onCarpeaterDrop?.call(repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); + onCarpeaterDrop?.call( + repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); } return false; } - debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + debugLog( + '[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); @@ -172,15 +181,17 @@ class RxLogger { // IMPORTANT: Use the batch's bestObservation which has the FIRST location // where we heard this repeater, not the current GPS location. // This ensures map pins stay at the original location. - final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; + final batchedObservation = + _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { - debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' + debugLog( + '[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } - + return true; } catch (error, stackTrace) { debugError('[RX LOG] Error processing passive RX: $error'); @@ -223,7 +234,8 @@ class RxLogger { ); _batchBuffer[repeaterId] = buffer; wasKept = true; // New repeater, observation is kept - debugLog('[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); + debugLog( + '[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); // Start 30-second timeout timer for this repeater buffer.timeoutTimer = Timer(batchTimeout, () { @@ -250,8 +262,8 @@ class RxLogger { rssi: rssi, pathLength: pathLength, header: header, - lat: buffer.firstLocation.lat, // Keep original location - lon: buffer.firstLocation.lon, // Keep original location + lat: buffer.firstLocation.lat, // Keep original location + lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, ); @@ -276,7 +288,8 @@ class RxLogger { '(threshold=${batchDistanceMeters}m)'); if (distance >= batchDistanceMeters) { - debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); + debugLog( + '[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); await _flushRepeater(repeaterId); } @@ -285,43 +298,45 @@ class RxLogger { /// Check all active RX batches for distance threshold on GPS position update /// Called from GPS service when position changes - Future checkDistanceTriggers(({double lat, double lon}) currentLocation) async { + Future checkDistanceTriggers( + ({double lat, double lon}) currentLocation) async { if (_batchBuffer.isEmpty) { return; // No active batches to check } - debugLog('[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); - + debugLog( + '[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); + final repeatersToFlush = []; - + // Check each active batch for (final entry in _batchBuffer.entries) { final repeaterId = entry.key; final buffer = entry.value; - + final distance = _calculateHaversineDistance( currentLocation.lat, currentLocation.lon, buffer.firstLocation.lat, buffer.firstLocation.lon, ); - + debugLog('[RX BATCH] Distance check for repeater $repeaterId: ' '${distance.toStringAsFixed(2)}m from first observation ' '(threshold=${batchDistanceMeters}m)'); - + if (distance >= batchDistanceMeters) { debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, ' 'marking for flush'); repeatersToFlush.add(repeaterId); } } - + // Flush all repeaters that met the distance threshold for (final repeaterId in repeatersToFlush) { await _flushRepeater(repeaterId); } - + if (repeatersToFlush.isNotEmpty) { debugLog('[RX BATCH] Flushed ${repeatersToFlush.length} repeater(s) ' 'due to GPS movement'); @@ -331,20 +346,20 @@ class RxLogger { /// Flush a single repeater's batch - post best observation to API Future _flushRepeater(String repeaterId) async { debugLog('[RX BATCH] Flushing repeater $repeaterId'); - + final buffer = _batchBuffer[repeaterId]; if (buffer == null) { debugLog('[RX BATCH] No buffer to flush for repeater $repeaterId'); return; } - + // Clear timeout timer if it exists buffer.timeoutTimer?.cancel(); buffer.timeoutTimer = null; debugLog('[RX BATCH] Cleared timeout timer for repeater $repeaterId'); - + final best = buffer.bestObservation; - + // Build API entry using BEST observation's location final entry = RxApiEntry( repeaterId: repeaterId, @@ -357,13 +372,13 @@ class RxLogger { timestamp: best.timestamp, metadata: best.metadata, ); - + debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' 'location=${best.lat.toStringAsFixed(5)},${best.lon.toStringAsFixed(5)}'); - + // Queue for API posting await onRxEntry(entry); - + // Remove from buffer _batchBuffer.remove(repeaterId); debugLog('[RX BATCH] Repeater $repeaterId removed from buffer'); @@ -373,18 +388,18 @@ class RxLogger { Future flushAllBatches({String trigger = 'session_end'}) async { debugLog('[RX BATCH] Flushing all repeaters, trigger=$trigger, ' 'active_repeaters=${_batchBuffer.length}'); - + if (_batchBuffer.isEmpty) { debugLog('[RX BATCH] No repeaters to flush'); return; } - + // Iterate all repeaters and flush each one final repeaterIds = _batchBuffer.keys.toList(); for (final repeaterId in repeaterIds) { await _flushRepeater(repeaterId); } - + debugLog('[RX BATCH] All repeaters flushed: ${repeaterIds.length} total'); } @@ -397,18 +412,18 @@ class RxLogger { double lon2, ) { const earthRadiusM = 6371000.0; - + final dLat = _degreesToRadians(lat2 - lat1); final dLon = _degreesToRadians(lon2 - lon1); - + final a = sin(dLat / 2) * sin(dLat / 2) + cos(_degreesToRadians(lat1)) * cos(_degreesToRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2); - + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - + return earthRadiusM * c; } @@ -427,12 +442,12 @@ class RxLogger { /// Dispose of resources void dispose() { debugLog('[RX LOG] Disposing RX Logger'); - + // Cancel all timeout timers for (final buffer in _batchBuffer.values) { buffer.timeoutTimer?.cancel(); } - + _batchBuffer.clear(); isWardriving = false; } @@ -454,8 +469,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -481,8 +496,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart index ad7b5297..265c6e42 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -6,9 +6,9 @@ import '../../utils/debug_logger_io.dart'; /// Result of a trace path probe to a specific repeater class TraceResult { final String targetRepeaterId; - final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) - final int localRssi; // RSSI from BLE event metadata - final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) final bool success; const TraceResult({ @@ -52,7 +52,8 @@ class TraceTracker { Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); - debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; _expectedTag = tag; @@ -65,7 +66,8 @@ class TraceTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); } /// Handle incoming trace data packet (0x89) @@ -86,7 +88,8 @@ class TraceTracker { try { // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes if (rawBytes.length < 11) { - debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + debugLog( + '[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); return false; } @@ -99,7 +102,8 @@ class TraceTracker { final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; - debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + debugLog( + '[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); // Extract tag (bytes 3-6) final tag = rawBytes.sublist(3, 7); @@ -127,7 +131,8 @@ class TraceTracker { final pathEnd = pathStart + (hopCount * hashSize); if (rawBytes.length < pathEnd) { - debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + debugLog( + '[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); return false; } @@ -135,7 +140,10 @@ class TraceTracker { String repeaterId = ''; if (hopCount > 0) { final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); - repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + repeaterId = idBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } // Extract path SNRs (hopCount+1 bytes after path hashes) @@ -179,7 +187,8 @@ class TraceTracker { /// Stop tracking and return result TraceResult? stopTracking() { - debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + debugLog( + '[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); final result = _result; isListening = false; @@ -192,7 +201,8 @@ class TraceTracker { /// Handle trace window completion void _endWindow() { - debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + debugLog( + '[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); final result = _result; isListening = false; diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 575536ee..b4090e26 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -29,7 +29,8 @@ class TxTracker { /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) - void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? + onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -43,7 +44,7 @@ class TxTracker { bool disableRssiFilter = false; /// Start tracking echoes for a sent ping - /// + /// /// @param payload - The message text sent (for content verification) /// @param channelIdx - Channel index where ping was sent /// @param channelHash - Expected channel hash for validation @@ -58,8 +59,9 @@ class TxTracker { }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); - debugLog('[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); - + debugLog( + '[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + isListening = true; sentTimestamp = DateTime.now(); sentPayload = payload; @@ -67,26 +69,29 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); - + // Start window timer _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, stopTracking); - - debugLog('[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); + + debugLog( + '[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking echoes void stopTracking() { - debugLog('[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); - + debugLog( + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); + isListening = false; _windowTimer?.cancel(); _windowTimer = null; - + // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + debugLog( + '[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); } } } @@ -95,12 +100,13 @@ class TxTracker { /// Returns true if packet was an echo and tracked Future handlePacket(PacketMetadata metadata) async { if (!isListening) return false; - + final originalPayload = sentPayload; final expectedHash = expectedChannelHash; - + try { - debugLog('[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog( + '[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); // VALIDATION STEP 1: Header validation (must be GROUP_TEXT) if (!metadata.isGroupText) { @@ -108,12 +114,14 @@ class TxTracker { '(header=0x${metadata.header.toRadixString(16).padLeft(2, '0')})'); return false; } - debugLog('[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops if (metadata.pathHashCount == 0) { - debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); + debugLog( + '[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } @@ -125,14 +133,16 @@ class TxTracker { double? reportedSnr = metadata.snr; int? reportedRssi = metadata.rssi; - if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); return false; } // Multi-hop: strip CARpeater, report underlying repeater (second hop) final underlyingHex = metadata.getHopHex(1)!; - debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + debugLog( + '[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); pathHex = underlyingHex; carpeaterStripped = true; reportedSnr = null; @@ -143,15 +153,19 @@ class TxTracker { // that heard our TX: the radio reports last-hop link quality, so for any // multi-hop relay the metrics describe a different link entirely. if (!carpeaterStripped && metadata.pathHashCount > 1) { - debugLog('[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' + debugLog( + '[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); return false; } // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { - debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(pathHex.toUpperCase())) { + debugLog( + '[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } @@ -160,20 +174,26 @@ class TxTracker { if (carpeaterStripped) { debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[TX LOG] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(metadata.rssi)) { - debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); - debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); - onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); + debugLog( + '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); + onCarpeaterDrop?.call( + pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) } else { - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); + debugLog( + '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { - debugLog('[TX LOG] Ignoring: payload too short to contain channel hash'); + debugLog( + '[TX LOG] Ignoring: payload too short to contain channel hash'); return false; } @@ -186,11 +206,13 @@ class TxTracker { debugLog('[TX LOG] Ignoring: channel hash mismatch'); return false; } - debugLog('[TX LOG] Channel hash match confirmed - this is a message on our channel'); + debugLog( + '[TX LOG] Channel hash match confirmed - this is a message on our channel'); // VALIDATION STEP 3: Message content verification if (channelKey != null && originalPayload != null) { - debugLog('[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); + debugLog( + '[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); try { // Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] @@ -204,18 +226,24 @@ class TxTracker { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); return false; } final messageBytes = decryptedBytes.sublist(5); // Convert bytes to string and strip null terminators - var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); - decryptedMessage = decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); - - debugLog('[MESSAGE_CORRELATION] Decryption successful, comparing content...'); - debugLog('[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); - debugLog('[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); + var decryptedMessage = + utf8.decode(messageBytes, allowMalformed: true); + decryptedMessage = + decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); + + debugLog( + '[MESSAGE_CORRELATION] Decryption successful, comparing content...'); + debugLog( + '[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); + debugLog( + '[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); // Check if our expected message is contained in the decrypted text // This handles both exact matches and messages with sender prefixes @@ -223,29 +251,37 @@ class TxTracker { decryptedMessage.contains(originalPayload); if (!messageMatches) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); - debugLog('[MESSAGE_CORRELATION] This is a different message on the same channel'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); + debugLog( + '[MESSAGE_CORRELATION] This is a different message on the same channel'); return false; } if (decryptedMessage == originalPayload) { - debugLog('[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); + debugLog( + '[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); } else { - debugLog('[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' + debugLog( + '[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' '- this is an echo of our ping!'); } } catch (e) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); return false; } } else { - debugWarn('[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); - debugWarn('[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); + debugWarn( + '[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); + debugWarn( + '[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); } // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + debugLog( + '[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater @@ -260,7 +296,8 @@ class TxTracker { ? reportedSnr > existing.snr! : reportedSnr != null && existing.snr == null; if (shouldUpdate) { - debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' + debugLog( + '[PING] Deduplication decision: updating path $pathHex with better SNR: ' '${existing.snr} -> $reportedSnr'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, @@ -269,7 +306,8 @@ class TxTracker { seenCount: existing.seenCount + 1, ); } else { - debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' + debugLog( + '[PING] Deduplication decision: keeping existing SNR for path $pathHex ' '(existing ${existing.snr} >= new $reportedSnr)'); // Still increment seen count existing.seenCount++; @@ -277,7 +315,8 @@ class TxTracker { } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + debugLog( + '[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, @@ -289,7 +328,8 @@ class TxTracker { // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; final bestRssi = repeaters[pathHex]!.rssi; - debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); @@ -312,10 +352,10 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { - final String repeaterId; // Hex string - double? snr; // Best SNR seen (null for CARpeater pass-through) - int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) - int seenCount; // Times observed + final String repeaterId; // Hex string + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) + int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 1c95dd2c..2f5e71df 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -41,7 +41,7 @@ class UnifiedRxHandler { /// Start unified RX listening void startListening() { if (isListening) return; - + debugLog('[UNIFIED RX] Starting unified RX listening'); isListening = true; debugLog('[UNIFIED RX] ✅ Unified listening started successfully'); @@ -50,7 +50,7 @@ class UnifiedRxHandler { /// Stop unified RX listening void stopListening() { if (!isListening) return; - + debugLog('[UNIFIED RX] Stopping unified RX listening'); isListening = false; debugLog('[UNIFIED RX] ✅ Unified listening stopped'); @@ -62,17 +62,18 @@ class UnifiedRxHandler { try { // Defensive check: ensure listener is marked as active if (!isListening) { - debugWarn('[UNIFIED RX] Received event but listener marked inactive - reactivating'); + debugWarn( + '[UNIFIED RX] Received event but listener marked inactive - reactivating'); isListening = true; } - + // Parse metadata ONCE final metadata = PacketMetadata.fromRawPacket( raw: rawPacket, snr: snr, rssi: rssi, ); - + debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); @@ -83,7 +84,8 @@ class UnifiedRxHandler { if (metadata.isTrace) { final tt = traceTracker; if (tt != null && tt.isListening) { - debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + debugLog( + '[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); tt.pendingBleSnr = metadata.snr; tt.pendingBleRssi = metadata.rssi; } @@ -99,16 +101,15 @@ class UnifiedRxHandler { return; } } - + // Route to RX wardriving if active if (rxLogger.isWardriving) { debugLog('[UNIFIED RX] RX wardriving active - logging observation'); await rxLogger.handlePacket(metadata, validator); } - + // If neither active, packet is received but ignored // Listener stays on, just not processing for wardriving - } catch (error, stackTrace) { debugError('[UNIFIED RX] Error processing rx_log entry: $error'); debugError('[UNIFIED RX] Stack trace: $stackTrace'); diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index d37cd8db..761a315f 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -10,10 +10,10 @@ class OfflineSession { final DateTime createdAt; final int pingCount; final Map data; - final String? devicePublicKey; // Device public key for auth during upload - final String? deviceName; // Device name for display - final String? contactUri; // Signed contact URI for registration during upload - final bool uploaded; // Track upload status + final String? devicePublicKey; // Device public key for auth during upload + final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload + final bool uploaded; // Track upload status OfflineSession({ required this.filename, @@ -106,14 +106,18 @@ class OfflineSessionService { /// Load sessions from storage Future _loadSessions() async { final sessionsJson = _prefs?.getStringList(_sessionsKey) ?? []; - _sessions = sessionsJson.map((json) { - try { - return OfflineSession.fromJson(jsonDecode(json) as Map); - } catch (e) { - debugError('[OFFLINE] Failed to parse session: $e'); - return null; - } - }).whereType().toList(); + _sessions = sessionsJson + .map((json) { + try { + return OfflineSession.fromJson( + jsonDecode(json) as Map); + } catch (e) { + debugError('[OFFLINE] Failed to parse session: $e'); + return null; + } + }) + .whereType() + .toList(); // Sort by date, newest first _sessions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); @@ -129,10 +133,12 @@ class OfflineSessionService { /// Generate filename for new session String _generateFilename() { final now = DateTime.now(); - final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; // Check if we already have sessions for today - final todaySessions = _sessions.where((s) => s.filename.startsWith(dateStr)).length; + final todaySessions = + _sessions.where((s) => s.filename.startsWith(dateStr)).length; if (todaySessions == 0) { return '$dateStr.json'; @@ -183,7 +189,8 @@ class OfflineSessionService { _sessions.insert(0, session); // Add at beginning (newest first) await _saveSessions(); - debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); + debugLog( + '[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } /// Update the current in-progress session with the latest pings snapshot. @@ -202,7 +209,8 @@ class OfflineSessionService { // If we have a tracked session, update it in-place if (_currentSessionFilename != null) { - final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + final index = + _sessions.indexWhere((s) => s.filename == _currentSessionFilename); if (index != -1) { final existing = _sessions[index]; final updatedData = Map.from(existing.data); @@ -219,11 +227,13 @@ class OfflineSessionService { contactUri: contactUri ?? existing.contactUri, ); await _saveSessions(); - debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + debugLog( + '[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); return; } // Session was deleted externally — fall through to create new - debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + debugWarn( + '[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); _currentSessionFilename = null; } @@ -237,7 +247,8 @@ class OfflineSessionService { // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { _currentSessionFilename = _sessions.first.filename; - debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + debugLog( + '[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); } } diff --git a/lib/services/permission_disclosure_service.dart b/lib/services/permission_disclosure_service.dart index 1fd5d8ad..c6ca111a 100644 --- a/lib/services/permission_disclosure_service.dart +++ b/lib/services/permission_disclosure_service.dart @@ -44,7 +44,8 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Track where you send pings on the mesh network'), + _BulletPoint( + text: 'Track where you send pings on the mesh network'), _BulletPoint(text: 'Map coverage areas for the community'), _BulletPoint(text: 'Record which repeaters hear your device'), SizedBox(height: 16), @@ -79,7 +80,8 @@ class PermissionDisclosureService { /// Show the background location disclosure (for "Always" permission) /// Returns true if user accepts, false if they decline - static Future showBackgroundLocationDisclosure(BuildContext context) async { + static Future showBackgroundLocationDisclosure( + BuildContext context) async { final result = await showDialog( context: context, barrierDismissible: false, @@ -103,8 +105,12 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Continue tracking coverage while the app is minimized'), - _BulletPoint(text: 'Send automatic pings during extended wardriving sessions'), + _BulletPoint( + text: + 'Continue tracking coverage while the app is minimized'), + _BulletPoint( + text: + 'Send automatic pings during extended wardriving sessions'), SizedBox(height: 16), Text( 'This grants "always on" location access, but we only collect what\'s needed: tagging pings while wardriving and checking if you\'re in a supported zone.', diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 50bc46eb..0482f818 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -42,12 +42,16 @@ import 'wakelock_service.dart'; class PingService { /// RX listening window duration (5 seconds - matches cooldown duration) static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); + /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); @@ -102,7 +106,7 @@ class PingService { bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; bool _targetedModeEnabled = false; - bool _nextPingIsDiscovery = true; // Start hybrid with discovery + bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; // Targeted mode tracking @@ -128,7 +132,8 @@ class PingService { StreamSubscription? _controlDataSubscription; Timer? _discoveryTimer; Position? _discoveryStartPosition; - Position? _lastDiscoveryPosition; // Track last discovery position for 25m check + Position? + _lastDiscoveryPosition; // Track last discovery position for 25m check // Validation callbacks bool Function()? checkExternalAntennaConfigured; @@ -150,6 +155,7 @@ class PingService { void Function(TxPing)? onTxPing; void Function(RxPing)? onRxPing; void Function(PingStats)? onStatsUpdated; + /// Called in real-time when each echo is received during tracking window /// Parameters: (TxPing txPing, HeardRepeater repeater, bool isNew) void Function(TxPing, HeardRepeater, bool isNew)? onEchoReceived; @@ -160,7 +166,8 @@ class PingService { /// Called in real-time when each node is discovered during tracking window /// Parameters: (DiscLogEntry discPing, DiscoveredNodeEntry nodeEntry, bool isNew) - void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? onDiscNodeDiscovered; + void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? + onDiscNodeDiscovered; /// Callback when TX window ends (for noise floor graph) /// Parameters: (bool success) - true if any repeaters heard, false if none @@ -252,7 +259,8 @@ class PingService { String? get skipReason => _skipReason; /// Get the manual ping cooldown timer (for UI display) - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; /// Set auto-ping interval (15000, 30000, or 60000 ms) /// Reference: getSelectedIntervalMs() in wardrive.js @@ -477,7 +485,8 @@ class PingService { // Guard: don't send pings if connection is not in connected state // Handles race where timer callback fires after reconnect started if (_connection.currentStep != ConnectionStep.connected) { - debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + debugLog( + '[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); return false; } @@ -502,7 +511,8 @@ class PingService { // Manual ping: 15-second cooldown, no distance check if (isInManualCooldown()) { final remainingSec = getRemainingManualCooldownSeconds(); - debugLog('[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -519,7 +529,8 @@ class PingService { // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -530,7 +541,8 @@ class PingService { if (_autoPingEnabled && !_passiveModeEnabled) { if (validation == PingValidation.tooCloseToLastPing) { _skipReason = 'too close'; - debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); + debugLog( + '[PING] Auto ping blocked: too close to last ping, scheduling next'); } if (_hybridModeEnabled) { _scheduleNextHybridPing(); @@ -556,7 +568,8 @@ class PingService { // Build ping message (same format used for TxTracker correlation) // Power is no longer included in the mesh message — sent per-ping in API payload - final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; + final coordsStr = + '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time @@ -586,13 +599,17 @@ class PingService { final channelHash = _connection.wardrivingChannelHash; final channelKey = _connection.wardrivingChannelKey; - if (_txTracker != null && channelIndex != null && channelHash != null && channelKey != null) { + if (_txTracker != null && + channelIndex != null && + channelHash != null && + channelKey != null) { debugLog('[PING] Starting TX echo tracking for: "$pingMessage"'); // Wire up real-time echo callback before starting tracking final txTracker = _txTracker; txTracker.onEchoReceived = (repeaterId, snr, rssi, isNew) { - debugLog('[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); + debugLog( + '[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); final txPing = _lastTxPing; if (txPing != null) { final repeater = HeardRepeater( @@ -605,18 +622,22 @@ class PingService { if (isNew) { // Add new repeater to the list txPing.heardRepeaters.add(repeater); - debugLog('[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); + debugLog( + '[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); } else { // Update existing repeater's SNR if better - final idx = txPing.heardRepeaters.indexWhere((r) => r.repeaterId == repeaterId); + final idx = txPing.heardRepeaters + .indexWhere((r) => r.repeaterId == repeaterId); if (idx >= 0) { txPing.heardRepeaters[idx] = repeater; - debugLog('[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); + debugLog( + '[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); } } // Notify for real-time UI updates - debugLog('[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); onEchoReceived?.call(txPing, repeater, isNew); debugLog('[PING] onEchoReceived callback completed'); } else { @@ -632,7 +653,8 @@ class PingService { windowDuration: _rxListeningWindow, ); } else { - debugWarn('[PING] TX tracking not available - channel info missing or no tracker'); + debugWarn( + '[PING] TX tracking not available - channel info missing or no tracker'); } // Play transmit sound immediately before sending @@ -706,7 +728,8 @@ class PingService { final txTracker = _txTracker; final txSuccess = txTracker != null && txTracker.repeaters.isNotEmpty; if (txSuccess) { - debugLog('[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); + debugLog( + '[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); // Format heard_repeats: "repeaterId(snr),repeaterId(snr)" // Reference: buildHeardRepeatsString() in wardrive.js @@ -723,7 +746,8 @@ class PingService { heardRepeats = repeaterStrings.join(','); // Update RX count stat for the echoes heard - _stats = _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); + _stats = + _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); onStatsUpdated?.call(_stats); } else { debugLog('[PING] No repeater echoes detected during listening window'); @@ -782,7 +806,7 @@ class PingService { debugLog('[PING] Pending disable complete, cooldown started'); // Notify AppStateProvider to update its state and cleanup await onPendingDisableComplete?.call(); - return; // Don't schedule next auto ping + return; // Don't schedule next auto ping } // Schedule next ping based on mode @@ -791,10 +815,12 @@ class PingService { // Reference: scheduleNextAutoPing() called after RX window in wardrive.js if (_autoPingEnabled && !isInCooldown()) { if (_hybridModeEnabled) { - debugLog('[HYBRID] Scheduling next hybrid ping after RX window completion'); + debugLog( + '[HYBRID] Scheduling next hybrid ping after RX window completion'); _scheduleNextHybridPing(); } else if (!_passiveModeEnabled) { - debugLog('[ACTIVE MODE] Scheduling next auto ping after RX window completion'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping after RX window completion'); _scheduleNextAutoPing(); } } else if (isInCooldown()) { @@ -808,7 +834,8 @@ class PingService { /// Reference: scheduleNextAutoPing() in wardrive.js void _scheduleNextAutoPing() { if (!_autoPingEnabled || _passiveModeEnabled) { - debugLog('[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); + debugLog( + '[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); return; } @@ -817,7 +844,8 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; - debugLog('[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); // Start countdown display (with skip reason if applicable) // The AutoPingTimer in countdown_timer_service.dart handles the display @@ -887,7 +915,8 @@ class PingService { bool targetedMode = false, String? targetRepeaterId, }) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); + debugLog( + '[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); @@ -895,7 +924,8 @@ class PingService { } // Targeted mode requires a repeater ID - if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + if (targetedMode && + (targetRepeaterId == null || targetRepeaterId.isEmpty)) { debugLog('[AUTO] Targeted mode requires a repeater ID'); return false; } @@ -920,7 +950,7 @@ class PingService { _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; _targetedModeEnabled = targetedMode; - _nextPingIsDiscovery = true; // Always start hybrid with discovery + _nextPingIsDiscovery = true; // Always start hybrid with discovery if (targetedMode) { _targetRepeaterId = targetRepeaterId; @@ -933,17 +963,20 @@ class PingService { if (targetedMode) { // Targeted Mode: send trace path to specific repeater - debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + debugLog( + '[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); await _startTargetedMode(); } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery - debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); + debugLog( + '[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); // First ping was discovery, so next should be TX _nextPingIsDiscovery = false; } else if (passiveMode) { // Passive Mode: send discovery requests instead of TX pings - debugLog('[PASSIVE MODE] Passive Mode started - using discovery protocol'); + debugLog( + '[PASSIVE MODE] Passive Mode started - using discovery protocol'); await _startDiscoveryMode(); } else { // Active Mode: send first ping immediately, then schedule timer @@ -970,14 +1003,15 @@ class PingService { if (_pingInProgress) { debugLog('[PING] Ping in progress, queuing disable for after RX window'); _pendingDisable = true; - return true; // Return true to indicate disable was accepted (pending) + return true; // Return true to indicate disable was accepted (pending) } // Check cooldown before stopping (unless forced) // Reference: isInCooldown() check in stopAutoPing() in wardrive.js if (!_passiveModeEnabled && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); return false; } @@ -1015,7 +1049,7 @@ class PingService { /// Force disable auto-ping (ignores cooldown, used for disconnect) Future forceDisableAutoPing() async { debugLog('[PING] Force disabling auto-ping'); - _pendingDisable = false; // Clear any pending disable + _pendingDisable = false; // Clear any pending disable _autoTimer?.cancel(); _autoTimer = null; _skipReason = null; @@ -1052,7 +1086,8 @@ class PingService { _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { - debugLog('[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); + debugLog( + '[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); final discPing = _lastDiscPing; if (discPing != null) { final nodeEntry = DiscoveredNodeEntry( @@ -1066,7 +1101,8 @@ class PingService { if (isNew) { discPing.discoveredNodes.add(nodeEntry); } else { - final idx = discPing.discoveredNodes.indexWhere((n) => n.repeaterId == node.repeaterId); + final idx = discPing.discoveredNodes + .indexWhere((n) => n.repeaterId == node.repeaterId); if (idx >= 0) discPing.discoveredNodes[idx] = nodeEntry; } onDiscNodeDiscovered?.call(discPing, nodeEntry, isNew); @@ -1099,7 +1135,8 @@ class PingService { _discTracker?.dispose(); _discTracker = null; _discoveryStartPosition = null; - _lastDiscoveryPosition = null; // Reset so first discovery always sends on next start + _lastDiscoveryPosition = + null; // Reset so first discovery always sends on next start _lastDiscPing = null; } @@ -1107,7 +1144,8 @@ class PingService { Future _sendDiscoveryRequest() async { // Guard: don't send discovery during reconnect (race with timer queue) if (_connection.currentStep != ConnectionStep.connected) { - debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + debugLog( + '[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); return; } @@ -1135,7 +1173,8 @@ class PingService { position.longitude, ); if (distance < _gpsService.configuredMinDistance) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + debugLog( + '[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1171,7 +1210,8 @@ class PingService { debugLog('[DISC] Created DiscLogEntry, ready for node tracking'); onDiscPing?.call(discPing); - debugLog('[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound immediately before sending @@ -1194,7 +1234,6 @@ class PingService { // Update last discovery position for 25m check _lastDiscoveryPosition = position; - } catch (e) { _pingInProgress = false; debugError('[DISC] Failed to send discovery request: $e'); @@ -1264,7 +1303,8 @@ class PingService { // Fire noise floor callback (entry already in _discLogEntries via onDiscPing) onDiscoveryWindowComplete?.call(discoverySuccess); - debugLog('[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); + debugLog( + '[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); _lastDiscPing = null; _scheduleNextDiscovery(); @@ -1295,7 +1335,8 @@ class PingService { // Notify callback for countdown display (30 seconds hardcoded for discovery) onAutoPingScheduled?.call(_discoveryInterval.inMilliseconds, _skipReason); - debugLog('[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); + debugLog( + '[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); } /// Schedule next hybrid ping (alternates discovery ↔ TX) @@ -1311,10 +1352,12 @@ class PingService { final listenMs = _nextPingIsDiscovery ? _discoveryListeningWindow.inMilliseconds : _rxListeningWindow.inMilliseconds; - final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); + final waitMs = + (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; - debugLog('[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); + debugLog( + '[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); onAutoPingScheduled?.call(waitMs, _skipReason); @@ -1353,10 +1396,12 @@ class PingService { final tracker = TraceTracker(); _traceTracker = tracker; tracker.onTraceReceived = (result) { - debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + debugLog( + '[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); }; tracker.onWindowComplete = (result) { - debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + debugLog( + '[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); _handleTraceWindowComplete(result); }; @@ -1416,11 +1461,14 @@ class PingService { final lastPos = _lastTargetedPosition; if (lastPos != null) { final distance = Geolocator.distanceBetween( - lastPos.latitude, lastPos.longitude, - position.latitude, position.longitude, + lastPos.latitude, + lastPos.longitude, + position.latitude, + position.longitude, ); if (distance < _gpsService.configuredMinDistance) { - debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + debugLog( + '[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextTargetedPing(); @@ -1450,7 +1498,8 @@ class PingService { ); onTracePing?.call(traceEntry); - debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound @@ -1460,11 +1509,13 @@ class PingService { final traceBytes = _traceHopBytes; final repeaterIdBytes = Uint8List(traceBytes); for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { - repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + repeaterIdBytes[i] = + int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); } // Send trace path and get tag - final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); + final tag = await _connection.sendTracePath(repeaterIdBytes, + hopBytes: traceBytes); // Start tracking with the tag _traceTracker?.startTracking( @@ -1481,7 +1532,6 @@ class PingService { // Update last targeted position for 25m check _lastTargetedPosition = position; - } catch (e) { _pingInProgress = false; debugError('[TRACE] Failed to send trace: $e'); @@ -1496,7 +1546,8 @@ class PingService { final targetId = _targetRepeaterId ?? ''; if (result != null && result.success && position != null) { - debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + debugLog( + '[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); // Queue to API (only successful traces) _apiQueue.enqueueTrace( @@ -1556,7 +1607,8 @@ class PingService { // Notify callback for countdown display onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); - debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + debugLog( + '[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); } /// Stop any active TX echo tracking window @@ -1590,32 +1642,32 @@ class PingService { enum PingValidation { /// All conditions met, can ping valid, - + /// Not connected to device notConnected, - + /// External antenna not configured externalAntennaRequired, - + /// Power level not set (unknown device model) powerLevelRequired, - + /// No GPS lock noGpsLock, - + /// GPS data too old (> 60 seconds) gpsDataStale, - + /// GPS accuracy too low (> 100 meters) gpsInaccurate, - + /// Outside service area (zone validation handled by API) /// Reserved for future use with dynamic zone boundaries outsideGeofence, - + /// Too close to last ping (< 25m) tooCloseToLastPing, - + /// Cooldown period active (< 5s since last ping) cooldownActive, diff --git a/lib/utils/debug_logger.dart b/lib/utils/debug_logger.dart index 2b4d3990..f5d5cd5e 100644 --- a/lib/utils/debug_logger.dart +++ b/lib/utils/debug_logger.dart @@ -9,10 +9,10 @@ import 'package:flutter/foundation.dart'; import 'package:web/web.dart' as web; /// Debug logging utility that mirrors MeshMapper_WebClient debug system. -/// +/// /// Logs are only output when DEBUG_ENABLED is true (set via `?debug=1` URL param). /// All log messages should use tagged format: `[TAG] message` -/// +/// /// Common tags: [BLE], [GPS], [PING], [API], [RX], [UI], [CONN] class DebugLogger { static bool _debugEnabled = false; @@ -30,7 +30,7 @@ class DebugLogger { final uri = Uri.base; final debugParam = uri.queryParameters['debug']; _debugEnabled = debugParam == '1' || debugParam == 'true'; - + if (_debugEnabled) { _consoleLog('[DEBUG] Debug logging ENABLED via URL param'); } @@ -56,9 +56,14 @@ class DebugLogger { /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleLog(args.join(' ')); } else { @@ -70,9 +75,15 @@ class DebugLogger { /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleWarn(args.join(' ')); } else { @@ -82,11 +93,18 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleError(args.join(' ')); } else { diff --git a/lib/utils/debug_logger_io.dart b/lib/utils/debug_logger_io.dart index d26799da..21fa3070 100644 --- a/lib/utils/debug_logger_io.dart +++ b/lib/utils/debug_logger_io.dart @@ -10,6 +10,4 @@ // debugError('[TAG] error'); // ``` -export 'debug_logger_stub.dart' - if (dart.library.html) 'debug_logger.dart'; - +export 'debug_logger_stub.dart' if (dart.library.html) 'debug_logger.dart'; diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index 4dc5a98d..c702fc74 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -22,7 +22,7 @@ class DebugLogger { // Enable debug logging by default on all builds _debugEnabled = true; - + if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); } @@ -39,7 +39,12 @@ class DebugLogger { /// Log a general info message to the console. /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -54,7 +59,13 @@ class DebugLogger { /// Log a warning message to the console. /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -68,8 +79,15 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart index ec8f0d91..001da568 100644 --- a/lib/utils/ping_colors.dart +++ b/lib/utils/ping_colors.dart @@ -7,11 +7,11 @@ import '../utils/debug_logger_io.dart'; /// The app adapts all semantic colors (ping types, signal quality, /// repeater status, noise floor) to a distinguishable palette. enum ColorVisionType { - none, // Default — current palette - protanopia, // Red-blind (~1% males) - deuteranopia, // Green-blind (~1% males) - tritanopia, // Blue-blind (~0.003%) - achromatopsia, // Total color blindness (monochrome) + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) } /// Immutable palette holding every semantic color the app uses. @@ -119,20 +119,20 @@ class ColorPalettes { /// Protanopia (red-blind) — replaces red/green axis with blue/orange. /// Also used for deuteranopia since both are red-green CVD. static const protanopia = ColorPalette( - txSuccess: Color(0xFF0072B2), // Wong blue + txSuccess: Color(0xFF0072B2), // Wong blue txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue - txFail: Color(0xFFD55E00), // Wong vermillion - rx: Color(0xFFCC79A7), // Wong reddish purple - discSuccess: Color(0xFF56B4E9), // Wong sky blue - discFail: Color(0xFF9E9E9E), // Grey (unchanged) - traceSuccess: Color(0xFF009E73), // Wong bluish green - noResponse: Color(0xFF9E9E9E), // Grey (unchanged) - signalGood: Color(0xFF0072B2), // Blue - signalMedium: Color(0xFFF0E442), // Wong yellow - signalBad: Color(0xFFD55E00), // Vermillion - repeaterActive: Color(0xFFCC79A7), // Reddish purple - repeaterNew: Color(0xFFF0E442), // Yellow - repeaterDead: Color(0xFF9E9E9E), // Grey + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey repeaterDuplicate: Color(0xFFD55E00), // Vermillion noiseFloorGood: Color(0xFF0072B2), noiseFloorMedium: Color(0xFFF0E442), @@ -148,20 +148,20 @@ class ColorPalettes { /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. /// Red/green distinction is preserved since tritan users can see those. static const tritanopia = ColorPalette( - txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccess: Color(0xFF009E73), // Wong bluish green txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) - txFail: Color(0xFFD55E00), // Wong vermillion - rx: Color(0xFFCC79A7), // Wong reddish purple - discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) - discFail: Color(0xFF9E9E9E), // Grey (unchanged) - traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) - noResponse: Color(0xFF9E9E9E), // Grey (unchanged) - signalGood: Color(0xFF009E73), // Bluish green - signalMedium: Color(0xFFE69F00), // Orange - signalBad: Color(0xFFD55E00), // Vermillion - repeaterActive: Color(0xFFCC79A7), // Reddish purple - repeaterNew: Color(0xFFE69F00), // Orange - repeaterDead: Color(0xFF9E9E9E), // Grey + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey repeaterDuplicate: Color(0xFFD55E00), // Vermillion noiseFloorGood: Color(0xFF009E73), noiseFloorMedium: Color(0xFFE69F00), @@ -178,20 +178,20 @@ class ColorPalettes { /// Relies on maximum brightness contrast between categories. /// Secondary indicators (icons, text) are essential with this palette. static const achromatopsia = ColorPalette( - txSuccess: Color(0xFFE0E0E0), // Light + txSuccess: Color(0xFFE0E0E0), // Light txSuccessLegend: Color(0xFFE0E0E0), - txFail: Color(0xFF616161), // Dark - rx: Color(0xFF9E9E9E), // Medium - discSuccess: Color(0xFFBDBDBD), // Medium-light - discFail: Color(0xFF757575), // Medium-dark - traceSuccess: Color(0xFF757575), // Medium-dark - noResponse: Color(0xFF616161), // Dark - signalGood: Color(0xFFE0E0E0), // Light - signalMedium: Color(0xFF9E9E9E), // Medium - signalBad: Color(0xFF424242), // Very dark - repeaterActive: Color(0xFFE0E0E0), // Light - repeaterNew: Color(0xFFBDBDBD), // Medium-light - repeaterDead: Color(0xFF616161), // Dark + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark repeaterDuplicate: Color(0xFF424242), // Very dark noiseFloorGood: Color(0xFFE0E0E0), noiseFloorMedium: Color(0xFF9E9E9E), diff --git a/lib/widgets/bug_report_dialog.dart b/lib/widgets/bug_report_dialog.dart index 9c34d2ba..ecf1c020 100644 --- a/lib/widgets/bug_report_dialog.dart +++ b/lib/widgets/bug_report_dialog.dart @@ -165,7 +165,8 @@ class _BugReportSheetState extends State { 'not-connected'; // Use last connected device name (companion name without MeshCore- prefix) - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; // Format description with username if provided final username = _usernameController.text.trim(); @@ -193,7 +194,8 @@ class _BugReportSheetState extends State { if (!mounted) return; if (result.success) { - debugLog('[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); + debugLog( + '[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); Navigator.of(context).pop(result); } else { setState(() { @@ -245,13 +247,15 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submit Feedback', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -270,272 +274,295 @@ class _BugReportSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - // Ticket type selector - SegmentedButton - _buildSectionLabel(theme, Icons.category, 'Report Type'), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'bug', - label: Text('Bug'), - icon: Icon(Icons.bug_report, size: 18), - ), - ButtonSegment( - value: 'enhancement', - label: Text('Feature'), - icon: Icon(Icons.lightbulb_outline, size: 18), - ), - ], - selected: {_ticketType}, - onSelectionChanged: _isSubmitting - ? null - : (selected) => setState(() => _ticketType = selected.first), - showSelectedIcon: false, - ), - const SizedBox(height: 24), - - // Username field (optional, auto-populated from remembered device) - _buildSectionLabel(theme, Icons.person, 'Username (optional)'), - const SizedBox(height: 8), - TextFormField( - controller: _usernameController, - textCapitalization: TextCapitalization.words, - decoration: _buildInputDecoration( - theme, - hintText: 'Your MeshCore companion name', - ), - maxLength: 50, - enabled: !_isSubmitting, - ), - const SizedBox(height: 16), - - // Title field - _buildSectionLabel(theme, Icons.title, 'Title'), - const SizedBox(height: 8), - TextFormField( - controller: _titleController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Brief summary of the issue', + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Ticket type selector - SegmentedButton + _buildSectionLabel(theme, Icons.category, 'Report Type'), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'bug', + label: Text('Bug'), + icon: Icon(Icons.bug_report, size: 18), + ), + ButtonSegment( + value: 'enhancement', + label: Text('Feature'), + icon: Icon(Icons.lightbulb_outline, size: 18), + ), + ], + selected: {_ticketType}, + onSelectionChanged: _isSubmitting + ? null + : (selected) => + setState(() => _ticketType = selected.first), + showSelectedIcon: false, ), - maxLength: 100, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Title is required'; - } - if (value.trim().length < 5) { - return 'Title must be at least 5 characters'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Description field - _buildSectionLabel(theme, Icons.description, 'Description'), - const SizedBox(height: 8), - TextFormField( - controller: _descriptionController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Describe the issue or feature request...', - alignLabelWithHint: true, + const SizedBox(height: 24), + + // Username field (optional, auto-populated from remembered device) + _buildSectionLabel( + theme, Icons.person, 'Username (optional)'), + const SizedBox(height: 8), + TextFormField( + controller: _usernameController, + textCapitalization: TextCapitalization.words, + decoration: _buildInputDecoration( + theme, + hintText: 'Your MeshCore companion name', + ), + maxLength: 50, + enabled: !_isSubmitting, ), - maxLines: 5, - maxLength: 2000, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Description is required'; - } - if (value.trim().length < 20) { - return 'Please provide more detail (at least 20 characters)'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Platform selector - _buildSectionLabel(theme, Icons.devices, 'Platform'), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _buildPlatformChip(theme, 'App', 'app', Icons.phone_android), - _buildPlatformChip(theme, 'Map', 'map', Icons.map), - _buildPlatformChip(theme, 'Other', 'other', Icons.more_horiz), - ], - ), + const SizedBox(height: 16), - // Debug logs section (mobile only) - if (!kIsWeb && _isLoadingFiles) ...[ - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), + // Title field + _buildSectionLabel(theme, Icons.title, 'Title'), + const SizedBox(height: 8), + TextFormField( + controller: _titleController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Brief summary of the issue', ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Text( - 'Preparing log files...', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + maxLength: 100, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + if (value.trim().length < 5) { + return 'Title must be at least 5 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Description field + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Describe the issue or feature request...', + alignLabelWithHint: true, ), + maxLines: 5, + maxLength: 2000, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Description is required'; + } + if (value.trim().length < 20) { + return 'Please provide more detail (at least 20 characters)'; + } + return null; + }, ), - ], - // Debug logs section - always visible when files available - if (!kIsWeb && !_isLoadingFiles && _availableLogFiles.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 16), + + // Platform selector + _buildSectionLabel(theme, Icons.devices, 'Platform'), const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + Wrap( + spacing: 8, + children: [ + _buildPlatformChip( + theme, 'App', 'app', Icons.phone_android), + _buildPlatformChip(theme, 'Map', 'map', Icons.map), + _buildPlatformChip( + theme, 'Other', 'other', Icons.more_horiz), + ], + ), + + // Debug logs section (mobile only) + if (!kIsWeb && _isLoadingFiles) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), - ), - child: Column( - children: [ - // Header with attach toggle - SwitchListTile( - title: const Text('Include with feedback'), - subtitle: Text( - 'Select logs to attach to this report', - style: theme.textTheme.bodySmall?.copyWith( + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - value: _uploadLogs, - onChanged: _isSubmitting - ? null - : (value) { - setState(() { - _uploadLogs = value; - if (!_uploadLogs) { - _selectedLogFiles.clear(); - } - }); - }, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - ), - Divider( - height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + ], + ), + ), + ], + // Debug logs section - always visible when files available + if (!kIsWeb && + !_isLoadingFiles && + _availableLogFiles.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), - // Log file list - only shown when toggle is on - if (_uploadLogs) - ...List.generate(_availableLogFiles.length, (index) { - final file = _availableLogFiles[index]; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - - return ListTile( - dense: true, - leading: Checkbox( - value: isSelected, - onChanged: _isSubmitting - ? null - : (_) => _toggleFile(file.path), - ), - title: Text( - filename, - style: const TextStyle(fontSize: 13), + ), + child: Column( + children: [ + // Header with attach toggle + SwitchListTile( + title: const Text('Include with feedback'), + subtitle: Text( + 'Select logs to attach to this report', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), + ), + value: _uploadLogs, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _uploadLogs = value; + if (!_uploadLogs) { + _selectedLogFiles.clear(); + } + }); + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + ), + Divider( + height: 1, + color: theme.colorScheme.outline + .withValues(alpha: 0.3), + ), + // Log file list - only shown when toggle is on + if (_uploadLogs) + ...List.generate(_availableLogFiles.length, + (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = + _selectedLogFiles.contains(file.path); + + // Format size and show part count for oversized files + String sizeDisplay; + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024) + .toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), ), - child: Text( - sizeDisplay, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme + .colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), - ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), - ); - }), - ], - ), - ), - ], - - // Error message - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.3), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), + ); + }), + ], ), ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme.colorScheme.error, + ], + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), ), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle(color: theme.colorScheme.error), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), ), - ), - ], + ], - // Bottom padding for safe area - SizedBox(height: MediaQuery.of(context).padding.bottom + 80), - ], + // Bottom padding for safe area + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), ), ), ), - ), // Sticky bottom action bar Container( @@ -557,7 +584,8 @@ class _BugReportSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -613,7 +641,8 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submitting...', style: theme.textTheme.titleLarge), ], @@ -635,7 +664,8 @@ class _BugReportSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -653,7 +683,9 @@ class _BugReportSheetState extends State { // Status text Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -680,7 +712,8 @@ class _BugReportSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), diff --git a/lib/widgets/connection_panel.dart b/lib/widgets/connection_panel.dart index 490d5e87..47c2c9d6 100644 --- a/lib/widgets/connection_panel.dart +++ b/lib/widgets/connection_panel.dart @@ -31,7 +31,8 @@ class ConnectionPanel extends StatelessWidget { return _buildAntennaSelector(context, appState, prefs); } - Widget _buildAntennaSelector(BuildContext context, AppStateProvider appState, prefs) { + Widget _buildAntennaSelector( + BuildContext context, AppStateProvider appState, prefs) { final isSet = prefs.externalAntennaSet; final hasExternal = prefs.externalAntenna; final colorScheme = Theme.of(context).colorScheme; @@ -64,7 +65,8 @@ class ConnectionPanel extends StatelessWidget { child: Icon( Icons.settings_input_antenna, size: 20, - color: isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, + color: + isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, ), ), const SizedBox(width: 12), @@ -84,7 +86,8 @@ class ConnectionPanel extends StatelessWidget { if (appState.antennaRestoredFromDevice) Text( 'Remembered for ${appState.displayDeviceName}', - style: TextStyle(fontSize: 11, color: colorScheme.onSurfaceVariant), + style: TextStyle( + fontSize: 11, color: colorScheme.onSurfaceVariant), ), ], ), @@ -108,7 +111,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: No'); appState.updatePreferences( - prefs.copyWith(externalAntenna: false, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: false, externalAntennaSet: true), ); }, ), @@ -119,7 +123,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: Yes'); appState.updatePreferences( - prefs.copyWith(externalAntenna: true, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: true, externalAntennaSet: true), ); }, ), @@ -153,7 +158,12 @@ class ConnectionPanel extends StatelessWidget { : Colors.transparent, borderRadius: BorderRadius.circular(6), boxShadow: isSelected && !isDark - ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2, offset: const Offset(0, 1))] + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(0, 1)) + ] : null, ), child: Text( @@ -162,8 +172,12 @@ class ConnectionPanel extends StatelessWidget { fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected - ? (isDark ? Colors.white : const Color(0xFF1E293B)) // slate-800 for light - : (isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B)), // slate-400/500 + ? (isDark + ? Colors.white + : const Color(0xFF1E293B)) // slate-800 for light + : (isDark + ? const Color(0xFF94A3B8) + : const Color(0xFF64748B)), // slate-400/500 ), ), ), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 848f432c..2e838584 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -252,6 +252,22 @@ extension MapStyleExtension on MapStyle { } } +<<<<<<< HEAD +======= +/// Custom tile provider that silently handles HTTP errors (404, 503, etc.) +/// instead of flooding the console with exceptions +final class SilentCancellableNetworkTileProvider + extends CancellableNetworkTileProvider { + SilentCancellableNetworkTileProvider() + : super( + dioClient: Dio( + BaseOptions( + validateStatus: (status) => true, // Accept all status codes + ), + ), + ); +} +>>>>>>> a431a6a (format with dart) /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID @@ -302,11 +318,14 @@ class _MapWidgetState extends State { bool _prefsApplied = false; // Guard to load saved prefs only once bool _isMapReady = false; LatLng? _lastGpsPosition; - bool _hasInitialZoomed = false; // Track if we've done the one-time initial zoom to GPS - bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _hasInitialZoomed = + false; // Track if we've done the one-time initial zoom to GPS + bool _hasZoomedToLastKnown = + false; // Track if we've zoomed to last known position (before GPS) // Map rotation mode - bool _alwaysNorth = true; // true = north always up, false = rotate with heading + bool _alwaysNorth = + true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation // Desired camera zoom while auto-follow is active. Set when the user taps @@ -479,7 +498,7 @@ class _MapWidgetState extends State { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && _autoFollow && _isMapReady && _lastGpsPosition != null) { @@ -508,6 +527,66 @@ class _MapWidgetState extends State { } } +<<<<<<< HEAD +======= + /// Smoothly animate the map to a new position + void _animateToPosition(LatLng target) { + if (!_isMapReady || !mounted) return; + + // Get current position + final currentCenter = _mapController.camera.center; + + // Skip if already at target (within small threshold) + final distance = + const Distance().as(LengthUnit.Meter, currentCenter, target); + if (distance < 1) return; // Less than 1 meter, don't animate + + // Cancel any running animation + _animationController?.stop(); + _animationController?.dispose(); + + // Create new animation controller + // Duration based on distance - shorter for small movements, longer for big jumps + final duration = Duration(milliseconds: distance < 100 ? 200 : 300); + + _animationController = AnimationController( + duration: duration, + vsync: this, + ); + + _animation = CurvedAnimation( + parent: _animationController!, + curve: Curves.easeOutCubic, // Smooth deceleration + ); + + _animationStartPosition = currentCenter; + _animationEndPosition = target; + + _animation!.addListener(() { + if (!mounted || + _animationStartPosition == null || + _animationEndPosition == null) { + return; + } + + // Interpolate between start and end positions + final t = _animation!.value; + final lat = _animationStartPosition!.latitude + + ((_animationEndPosition!.latitude - + _animationStartPosition!.latitude) * + t); + final lng = _animationStartPosition!.longitude + + ((_animationEndPosition!.longitude - + _animationStartPosition!.longitude) * + t); + + _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); + }); + + _animationController!.forward(); + } + +>>>>>>> a431a6a (format with dart) /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { if (_mapController == null || !_isMapReady || !mounted) return; @@ -541,15 +620,57 @@ class _MapWidgetState extends State { )), duration: Duration(milliseconds: durationMs), ); +<<<<<<< HEAD } /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { if (_mapController == null || !_isMapReady || !mounted) return; +======= - final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; + _animationStartPosition = currentCenter; + _animationEndPosition = target; + + _animation!.addListener(() { + if (!mounted || + _animationStartPosition == null || + _animationEndPosition == null) { + return; + } + + // Interpolate between start and end positions + final t = _animation!.value; + final lat = _animationStartPosition!.latitude + + ((_animationEndPosition!.latitude - + _animationStartPosition!.latitude) * + t); + final lng = _animationStartPosition!.longitude + + ((_animationEndPosition!.longitude - + _animationStartPosition!.longitude) * + t); + + // Interpolate zoom + final zoom = currentZoom + ((targetZoom - currentZoom) * t); + + _mapController.move(LatLng(lat, lng), zoom); + }); + + _animationController!.forward(); + } + + /// Zoom to fit a focused ping and its connected repeaters on screen + void _zoomToFocusBounds( + LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + if (!_isMapReady || !mounted) return; +>>>>>>> a431a6a (format with dart) + + final points = [ + pingLocation, + ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) + ]; if (points.length < 2) return; +<<<<<<< HEAD // Build bounding box from all points double minLat = points[0].latitude, maxLat = points[0].latitude; double minLon = points[0].longitude, maxLon = points[0].longitude; @@ -563,6 +684,14 @@ class _MapWidgetState extends State { southwest: LatLng(minLat, minLon), northeast: LatLng(maxLat, maxLon), ); +======= + final fitted = CameraFit.coordinates( + coordinates: points, + padding: EdgeInsets.fromLTRB( + 60, 60, 60, MediaQuery.of(context).size.height * 0.4), + maxZoom: 15, + ).fit(_mapController.camera); +>>>>>>> a431a6a (format with dart) final bottomPad = MediaQuery.of(context).size.height * 0.4; _mapController!.animateCamera( @@ -590,6 +719,7 @@ class _MapWidgetState extends State { CameraUpdate.bearingTo(targetHeading), duration: Duration(milliseconds: delta.abs() < 45 ? 300 : 500), ); +<<<<<<< HEAD } /// Produce a reliable heading in degrees (0..360) from successive GPS fixes. @@ -656,12 +786,54 @@ class _MapWidgetState extends State { double? atBearing, ]) { if (_mapController == null || !_isMapReady) return position; +======= + + _rotationAnimation = CurvedAnimation( + parent: _rotationAnimationController!, + curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + ); + + _rotationStartAngle = currentRotation; + _rotationEndAngle = currentRotation + delta; + + _rotationAnimation!.addListener(() { + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } + + // Interpolate between start and end angles + final t = _rotationAnimation!.value; + final rotation = _rotationStartAngle! + + ((_rotationEndAngle! - _rotationStartAngle!) * t); + + _mapController.rotate(rotation); + }); + + _rotationAnimationController!.forward(); + } + + /// Offset a lat/lon position by screen pixels (to account for UI overlays) + /// Shifts the map center to keep the GPS marker centered in the visible map area + /// - bottomPadding: shifts center down (portrait mode with bottom panel) + /// - rightPadding: shifts center left (landscape mode with side panel) + LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, + [double rightPadding = 0, double? atZoom]) { + if (!_isMapReady) return position; +>>>>>>> a431a6a (format with dart) if (bottomPadding <= 0 && rightPadding <= 0) return position; // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) +<<<<<<< HEAD final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * +======= + final zoom = atZoom ?? _mapController.camera.zoom; + final metersPerPixel = 40075000 / + (256 * math.pow(2, zoom)) * +>>>>>>> a431a6a (format with dart) math.cos(position.latitude * math.pi / 180); // Start with the offset expressed as if the map were north-up @@ -675,7 +847,13 @@ class _MapWidgetState extends State { } if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; +<<<<<<< HEAD lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); +======= + // Longitude degrees per meter varies with latitude + lonOffset = -(meterOffset / + (111000 * math.cos(position.latitude * math.pi / 180))); +>>>>>>> a431a6a (format with dart) } // When the map is rotated, "screen-down" no longer points geographic @@ -698,7 +876,8 @@ class _MapWidgetState extends State { lonOffset = rotatedLon; } - return LatLng(position.latitude + latOffset, position.longitude + lonOffset); + return LatLng( + position.latitude + latOffset, position.longitude + lonOffset); } @override @@ -764,9 +943,14 @@ class _MapWidgetState extends State { if (_autoFollow) { // Auto-follow is on and panel may be open — apply panel offset so // the marker appears centered in the visible map area. - final adjustedPosition = _offsetPositionForPadding(initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0); + final adjustedPosition = _offsetPositionForPadding( + initialPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + 16.0); _animateToPositionWithZoom(adjustedPosition, 16.0); - debugLog('[MAP] Initial zoom to GPS position (with panel offset)'); + debugLog( + '[MAP] Initial zoom to GPS position (with panel offset)'); } else { _animateToPositionWithZoom(initialPosition, 16.0); debugLog('[MAP] Initial zoom to GPS position'); @@ -801,6 +985,7 @@ class _MapWidgetState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { +<<<<<<< HEAD final adjustedPosition = _offsetPositionForPadding( newPosition, widget.bottomPaddingPixels, @@ -813,6 +998,13 @@ class _MapWidgetState extends State { zoom: targetZoom, bearing: targetBearing, ); +======= + // Apply offset for bottom padding when control panel is open + final adjustedPosition = _offsetPositionForPadding(newPosition, + widget.bottomPaddingPixels, widget.rightPaddingPixels); + _animateToPosition( + adjustedPosition); // Smooth animation instead of jump +>>>>>>> a431a6a (format with dart) } }); } @@ -829,7 +1021,8 @@ class _MapWidgetState extends State { // panel offset was computed). Heading mode will begin rotating // on the next GPS update when heading changes. _lastHeading = heading; - debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); + debugLog( + '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -848,11 +1041,13 @@ class _MapWidgetState extends State { // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { + if (_isMapReady && + appState.mapNavigationTrigger != _lastNavigationTrigger) { _lastNavigationTrigger = appState.mapNavigationTrigger; final target = appState.mapNavigationTarget; if (target != null) { // Reset map controls to default state +<<<<<<< HEAD _autoFollow = false; // Disable center on GPS _autoFollowDesiredZoom = null; _alwaysNorth = true; // Set to north-up mode @@ -860,6 +1055,12 @@ class _MapWidgetState extends State { _lastHeading = null; // Reset heading tracking _bearingAnchor = null; // Reset derived-heading anchor _computedHeading = null; +======= + _autoFollow = false; // Disable center on GPS + _alwaysNorth = true; // Set to north-up mode + _rotationLocked = false; // Unlock rotation + _lastHeading = null; // Reset heading tracking +>>>>>>> a431a6a (format with dart) // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle @@ -880,6 +1081,7 @@ class _MapWidgetState extends State { } } +<<<<<<< HEAD // Sync native annotations whenever marker data changes (provider triggers // a rebuild). The version hash detects changes to ping/repeater counts, // GPS position, focus state, prefs, etc. Native annotations stay in sync @@ -916,6 +1118,10 @@ class _MapWidgetState extends State { } final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; +======= + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; +>>>>>>> a431a6a (format with dart) // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; final topPadding = isLandscape ? 16.0 : 8.0; @@ -1006,7 +1212,8 @@ class _MapWidgetState extends State { Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + final onToggle = widget.onMapControlsToggle ?? + () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); return Column( mainAxisSize: MainAxisSize.min, @@ -1030,8 +1237,7 @@ class _MapWidgetState extends State { ), ), // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), + if (isExpanded) _buildMapControls(appState), ], ); } @@ -1126,6 +1332,7 @@ class _MapWidgetState extends State { // listener on `controller.onFeatureTapped` in _onMapCreated // instead — that fires for taps on custom layer features. ), +<<<<<<< HEAD // No widget marker overlay — markers are now native MapLibre // annotations rendered by the platform view itself. ], @@ -1286,6 +1493,149 @@ class _MapWidgetState extends State { _repeaterClusterCountLayerId, _repeaterClusterBubbleLayerId, _repeaterIndividualLayerId, +======= + children: [ + // Tile layer (dynamic based on selected style from preferences) + // Skipped entirely when map tiles are disabled to save mobile data + if (appState.preferences.mapTilesEnabled) + Builder( + builder: (context) { + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); + return TileLayer( + urlTemplate: mapStyle.urlTemplate, + subdomains: mapStyle.subdomains ?? const [], + userAgentPackageName: 'com.meshmapper.app', + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && + RetinaMode.isHighDensity(context), + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, + ), + tileProvider: SilentCancellableNetworkTileProvider(), + ); + }, + ), + + // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) + if (appState.preferences.mapTilesEnabled && + appState.zoneCode != null && + _showMeshMapperOverlay) + TileLayer( + urlTemplate: + 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', + userAgentPackageName: 'com.meshmapper.app', + minZoom: 3, + maxZoom: 17, + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, + ), + tileProvider: SilentCancellableNetworkTileProvider(), + ), + + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top + // During focus mode, the focused marker is excluded and rendered in its own top layer + MarkerLayer( + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + excludeFocused: _focusedPingLocation != null, + ), + ), + + // Focus mode: polylines from focused ping to each connected repeater + // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) + PolylineLayer( + polylines: _focusedRepeaters.map((r) { + final lineColor = + r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + return Polyline( + points: [ + _focusedPingLocation!, + LatLng(r.repeater.lat, r.repeater.lon) + ], + color: lineColor.withValues(alpha: 0.9), + strokeWidth: 3.5, + isDotted: true, + borderStrokeWidth: r.ambiguous ? 1.5 : 0, + borderColor: + r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, + ); + }).toList(), + ), + + // Repeater markers (magenta with ID, rotate with map) + // During focus mode, split into two layers: faded repeaters below, connected on top + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ + // Faded non-connected repeaters (below) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyFaded: true, + ), + ), + // Distance labels (middle) + MarkerLayer( + rotate: true, + markers: _buildFocusDistanceLabels(appState), + ), + // Connected repeaters (on top) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyConnected: true, + ), + ), + // Focused ping marker (above everything except GPS) + MarkerLayer( + markers: _buildFocusedPingMarker( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), + ), + ] else + // Normal mode: single layer with all repeaters + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), + ), + + // Current position marker + if (appState.currentPosition != null) + MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk' && + appState.preferences.gpsMarkerStyle != 'chomper', + markers: [ + Marker( + point: LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ), + width: 48, + height: 48, + child: _buildCurrentPositionMarker( + appState.currentPosition!.heading), + ), + ], + ), +>>>>>>> a431a6a (format with dart) ], null, ); @@ -2670,14 +3020,15 @@ class _MapWidgetState extends State { columnWidths: const { 0: IntrinsicColumnWidth(), // dot 1: IntrinsicColumnWidth(), // ID - 2: FixedColumnWidth(8), // spacer + 2: FixedColumnWidth(8), // spacer 3: IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + _overlayRow(rxSlot.repeaterId, rxSlot.snr, + _overlayTypeColor(OverlayPingType.rx)), ], ), ], @@ -2710,11 +3061,15 @@ class _MapWidgetState extends State { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps + ? formatMeters(position.accuracy, + isImperial: appState.preferences.isImperial) + : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, + color: + hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), // Distance since last TX ping (like wardrive.js) @@ -2727,7 +3082,8 @@ class _MapWidgetState extends State { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, + isImperial: appState.preferences.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -2748,7 +3104,8 @@ class _MapWidgetState extends State { /// Map controls (always vertical, used inside collapsible wrapper) Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); return Container( decoration: BoxDecoration( @@ -2770,7 +3127,9 @@ class _MapWidgetState extends State { _buildControlDivider(), _buildControlButton( icon: Icons.layers, - tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay', + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', onPressed: _toggleMeshMapperOverlay, isActive: _showMeshMapperOverlay, ), @@ -2780,14 +3139,17 @@ class _MapWidgetState extends State { _buildControlButton( icon: _autoFollow ? Icons.my_location : Icons.location_searching, tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: appState.currentPosition != null ? _centerOnPosition : null, + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, isActive: _autoFollow, ), _buildControlDivider(), // Always North toggle _buildControlButton( icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth ? 'Always North (Click to Rotate with Heading)' : 'Rotating with Heading (Click for Always North)', + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', onPressed: _toggleNorthMode, isActive: !_alwaysNorth, ), @@ -2849,7 +3211,8 @@ class _MapWidgetState extends State { void _cycleMapStyle(AppStateProvider appState) { const styles = MapStyle.values; - final currentStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final currentStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); final currentIndex = styles.indexOf(currentStyle); final newStyle = styles[(currentIndex + 1) % styles.length]; appState.setMapStyle(newStyle.name); @@ -2880,6 +3243,7 @@ class _MapWidgetState extends State { _autoFollowDesiredZoom = targetZoom; }); appState.setMapAutoFollow(true); +<<<<<<< HEAD // Bundle target + zoom + bearing into one animation so the // initial centering can't be half-cancelled by a racing GPS tick. final double targetBearing = (!_alwaysNorth && _computedHeading != null) @@ -2898,6 +3262,13 @@ class _MapWidgetState extends State { bearing: targetBearing, durationMs: 500, ); +======= + // Apply offset for bottom padding when control panel is open + final adjustedPosition = _offsetPositionForPadding(targetPosition, + widget.bottomPaddingPixels, widget.rightPaddingPixels); + _animateToPositionWithZoom( + adjustedPosition, 17.0); // Street level zoom when enabling follow +>>>>>>> a431a6a (format with dart) } } @@ -2926,6 +3297,33 @@ class _MapWidgetState extends State { CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), ); +<<<<<<< HEAD +======= + + _rotationAnimation = CurvedAnimation( + parent: _rotationAnimationController!, + curve: Curves.easeInOutCubic, + ); + + _rotationStartAngle = currentRotation; + _rotationEndAngle = 0.0; // North + + _rotationAnimation!.addListener(() { + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } + + final t = _rotationAnimation!.value; + final rotation = _rotationStartAngle! + + ((_rotationEndAngle! - _rotationStartAngle!) * t); + + _mapController.rotate(rotation); + }); + + _rotationAnimationController!.forward(); +>>>>>>> a431a6a (format with dart) } } else if (!_alwaysNorth && appState.currentPosition != null) { // If switching to heading mode, immediately start rotating to current heading @@ -2956,6 +3354,33 @@ class _MapWidgetState extends State { CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), ); +<<<<<<< HEAD +======= + + _rotationAnimation = CurvedAnimation( + parent: _rotationAnimationController!, + curve: Curves.easeInOutCubic, + ); + + _rotationStartAngle = currentRotation; + _rotationEndAngle = 0.0; // North + + _rotationAnimation!.addListener(() { + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } + + final t = _rotationAnimation!.value; + final rotation = _rotationStartAngle! + + ((_rotationEndAngle! - _rotationStartAngle!) * t); + + _mapController.rotate(rotation); + }); + + _rotationAnimationController!.forward(); +>>>>>>> a431a6a (format with dart) } } }); @@ -2986,7 +3411,8 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), child: const Icon(Icons.map, color: Colors.blue, size: 24), ), @@ -2995,8 +3421,8 @@ class _MapWidgetState extends State { child: Text( 'Legend & Info', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -3018,246 +3444,374 @@ class _MapWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Map Markers section - Text( - 'Map Markers', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLegendItem( - context: context, - color: PingColors.txSuccessLegend, - label: 'TX', - description: 'Location where you sent a ping and heard a repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.txFail, - label: 'TX', - description: 'Location where you sent a ping but no repeater was heard', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.rx, - label: 'RX', - description: 'Location where you received a message from the mesh', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.discSuccess, - label: 'DISC', - description: 'Location where you sent a discovery request and a repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.traceSuccess, - label: 'TRC', - description: 'Location where a trace reached the repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.discFail, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.noResponse, - label: 'TRC', - description: 'Location where a trace got no response', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Coverage Layer section - Text( - 'Coverage Layer', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLayerItem( - context: context, - color: PingColors.coverageBidir, - label: 'BIDIR', - description: 'Heard repeats from the mesh AND successfully routed through it', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDisc, - label: 'DISC', - description: 'Wardriving app sent a discovery packet and heard a reply', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageTx, - label: 'TX', - description: 'Successfully routed through, but no repeats heard back', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageRx, - label: 'RX', - description: 'Heard mesh traffic but did not transmit', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDead, - label: 'DEAD', - description: 'Repeater heard it, but no other radio received the repeat', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDrop, - label: 'DROP', - description: 'No repeats heard AND no successful route', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Sound Notifications section - Text( - 'Sound Notifications', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildSoundItem( - context: context, - icon: Icons.cell_tower, - label: 'TX Sound', - description: 'Plays when sending a ping or discovery request', - onPlay: () { - final appState = context.read(); - appState.audioService.playTransmitSound(); - }, - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildSoundItem( - context: context, - icon: Icons.hearing, - label: 'RX Sound', - description: 'Plays when a repeater echo or mesh message is received', - onPlay: () { - final appState = context.read(); - appState.audioService.playReceiveSound(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), - - // Map Controls section - Text( - 'Map Controls', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildHelpItem( - context: context, - icon: Icons.dark_mode, - label: 'Map Style', - description: 'Cycle between Dark, Light, and Satellite map styles', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.layers, - label: 'Coverage Overlay', - description: 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.my_location, - label: 'Center/Follow', - description: 'Center map on GPS position. Tap again to toggle auto-follow mode', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.navigation, - label: 'Always North', - description: 'Toggle between always-north orientation or rotate with heading', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.sync_disabled, - label: 'Lock Rotation', - description: 'Prevent accidental rotation of the map', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.info_outline, - label: 'Legend & Info', - description: 'Show this help popup with legend and control explanations', - ), - ], - ), - ), + Text( + 'Map Markers', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLegendItem( + context: context, + color: PingColors.txSuccessLegend, + label: 'TX', + description: + 'Location where you sent a ping and heard a repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.txFail, + label: 'TX', + description: + 'Location where you sent a ping but no repeater was heard', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.rx, + label: 'RX', + description: + 'Location where you received a message from the mesh', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.discSuccess, + label: 'DISC', + description: + 'Location where you sent a discovery request and a repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.traceSuccess, + label: 'TRC', + description: + 'Location where a trace reached the repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.discFail, + label: 'DISC', + description: + 'Location where you sent a discovery request but no repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.noResponse, + label: 'TRC', + description: + 'Location where a trace got no response', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Coverage Layer section + Text( + 'Coverage Layer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLayerItem( + context: context, + color: PingColors.coverageBidir, + label: 'BIDIR', + description: + 'Heard repeats from the mesh AND successfully routed through it', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDisc, + label: 'DISC', + description: + 'Wardriving app sent a discovery packet and heard a reply', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageTx, + label: 'TX', + description: + 'Successfully routed through, but no repeats heard back', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageRx, + label: 'RX', + description: + 'Heard mesh traffic but did not transmit', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDead, + label: 'DEAD', + description: + 'Repeater heard it, but no other radio received the repeat', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDrop, + label: 'DROP', + description: + 'No repeats heard AND no successful route', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Sound Notifications section + Text( + 'Sound Notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildSoundItem( + context: context, + icon: Icons.cell_tower, + label: 'TX Sound', + description: + 'Plays when sending a ping or discovery request', + onPlay: () { + final appState = + context.read(); + appState.audioService.playTransmitSound(); + }, + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildSoundItem( + context: context, + icon: Icons.hearing, + label: 'RX Sound', + description: + 'Plays when a repeater echo or mesh message is received', + onPlay: () { + final appState = + context.read(); + appState.audioService.playReceiveSound(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Map Controls section + Text( + 'Map Controls', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildHelpItem( + context: context, + icon: Icons.dark_mode, + label: 'Map Style', + description: + 'Cycle between Dark, Light, and Satellite map styles', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.layers, + label: 'Coverage Overlay', + description: + 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.my_location, + label: 'Center/Follow', + description: + 'Center map on GPS position. Tap again to toggle auto-follow mode', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.navigation, + label: 'Always North', + description: + 'Toggle between always-north orientation or rotate with heading', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.sync_disabled, + label: 'Lock Rotation', + description: + 'Prevent accidental rotation of the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.info_outline, + label: 'Legend & Info', + description: + 'Show this help popup with legend and control explanations', + ), + ], + ), + ), ], ), ), @@ -3274,8 +3828,13 @@ class _MapWidgetState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0), - Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, ], ), ), @@ -3469,6 +4028,117 @@ class _MapWidgetState extends State { } /// Build a coverage marker child widget based on the user's marker style preference. +<<<<<<< HEAD +======= + Widget _buildCoverageMarkerChild(Color color) { + final style = context.read().preferences.markerStyle; + switch (style) { + case 'circle': + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.0), + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) + ], + ), + ); + case 'pin': + return CustomPaint( + size: const Size(20, 20), + painter: _PinMarkerPainter(color), + ); + case 'diamond': + return CustomPaint( + size: const Size(20, 20), + painter: _DiamondMarkerPainter(color), + ); + case 'dot': + default: + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) + ], + ), + ); + } + } + + /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). + /// Newer pings always render on top regardless of type. + List _buildCoverageMarkers({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + bool excludeFocused = false, + }) { + final timestamped = <(DateTime, Marker)>[ + for (final ping in txPings) + if (!excludeFocused || + !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + (ping.timestamp, _buildTxMarker(ping)), + for (final ping in rxPings) + if (!excludeFocused || + !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + (ping.timestamp, _buildRxMarker(ping)), + for (final entry in discEntries) + if (!excludeFocused || + !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + for (final entry in traceEntries) + if (!excludeFocused || + !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + (entry.timestamp, _buildTraceMarker(entry)), + ]; + + timestamped.sort((a, b) => a.$1.compareTo(b.$1)); + return timestamped.map((e) => e.$2).toList(); + } + + /// Build just the focused ping marker for rendering in its own top layer. + List _buildFocusedPingMarker({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + if (_focusedPingLocation == null) return []; + + for (final ping in txPings) { + if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { + return [_buildTxMarker(ping)]; + } + } + for (final ping in rxPings) { + if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { + return [_buildRxMarker(ping)]; + } + } + for (final entry in discEntries) { + if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { + return [_buildDiscMarker(entry, discDropEnabled)]; + } + } + for (final entry in traceEntries) { + if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { + return [_buildTraceMarker(entry)]; + } + } + return []; + } + +>>>>>>> a431a6a (format with dart) /// Check if a ping at given lat/lon/timestamp is the currently focused ping. /// Used by the native annotation sync to apply focus-mode styling (size, /// opacity) to the focused ping vs other pings. @@ -3479,6 +4149,83 @@ class _MapWidgetState extends State { _focusedPingLocation!.longitude == lon; } +<<<<<<< HEAD +======= + /// Apply focus fade to a marker color. Returns dimmed color if focus is active + /// and this marker is not the focused one. + Color _applyFocusFade(Color color, bool isFocused) { + if (_focusedPingLocation == null || isFocused) return color; + return color.withValues(alpha: 0.15); + } + + Marker _buildTxMarker(TxPing ping) { + final isFocused = + _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final color = + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), + ), + ); + } + + Marker _buildRxMarker(RxPing ping) { + final isFocused = + _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: _buildCoverageMarkerChild( + _applyFocusFade(PingColors.rx, isFocused)), + ), + ); + } + + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + final isFocused = + _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final color = entry.nodeCount == 0 + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) + : _discMarkerColor; + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), + ), + ); + } + + Marker _buildTraceMarker(TraceLogEntry entry) { + final isFocused = + _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final color = entry.success ? Colors.cyan : Colors.grey; + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), + ), + ); + } + +>>>>>>> a431a6a (format with dart) void _showTraceDetails(TraceLogEntry entry) { // Activate focus mode for successful traces with a known repeater if (entry.success) { @@ -3487,7 +4234,8 @@ class _MapWidgetState extends State { snrValues: [entry.localSnr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -3508,7 +4256,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3521,9 +4270,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.cyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + border: Border.all( + color: Colors.cyan.withValues(alpha: 0.4)), ), - child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + child: const Icon(Icons.gps_fixed, + color: Colors.cyan, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3532,15 +4283,20 @@ class _MapWidgetState extends State { children: [ Text( 'Trace', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -3558,15 +4314,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3603,13 +4367,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -3619,7 +4388,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3630,7 +4401,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3641,7 +4414,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3652,14 +4427,17 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data row Builder(builder: (context) { final localSnr = entry.localSnr ?? 0; @@ -3668,15 +4446,24 @@ class _MapWidgetState extends State { final rxSnrColor = PingColors.snrColor(localSnr); final rssiColor = PingColors.rssiColor(localRssi); - final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // RX SNR Expanded( child: Center( @@ -3721,6 +4508,56 @@ class _MapWidgetState extends State { ).whenComplete(() => _dismissPingFocus()); } +<<<<<<< HEAD +======= + /// Build distance label markers at the midpoint of each focus line. + List _buildFocusDistanceLabels(AppStateProvider appState) { + if (_focusedPingLocation == null) return []; + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + + return _focusedRepeaters.map((r) { + final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); + // Midpoint of the line + final midLat = (ping.latitude + repeaterPos.latitude) / 2; + final midLon = (ping.longitude + repeaterPos.longitude) / 2; + // Distance in meters — use GpsService for consistency with repeater popup + final meters = GpsService.distanceBetween( + ping.latitude, + ping.longitude, + repeaterPos.latitude, + repeaterPos.longitude, + ); + final label = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + return Marker( + point: LatLng(midLat, midLon), + width: 70, + height: 22, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + ); + }).toList(); + } + +>>>>>>> a431a6a (format with dart) /// DISC marker color (delegates to active palette) static Color get _discMarkerColor => PingColors.discSuccess; @@ -3754,7 +4591,8 @@ class _MapWidgetState extends State { ? fullHex.substring(0, 8) : hexIds[i]; final matches = allRepeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); final ambiguous = matches.length > 1; resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); @@ -3763,10 +4601,17 @@ class _MapWidgetState extends State { } /// Activate ping focus mode — draw lines, fade markers, zoom to fit. +<<<<<<< HEAD void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; +======= + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { + _preFocusCenter = _mapController.camera.center; + _preFocusZoom = _mapController.camera.zoom; +>>>>>>> a431a6a (format with dart) _wasAutoFollowBeforeFocus = _autoFollow; _wasRotatingBeforeFocus = !_alwaysNorth; @@ -3865,10 +4710,7 @@ class _MapWidgetState extends State { for (final repeater in repeaters) { idCounts[repeater.id] = (idCounts[repeater.id] ?? 0) + 1; } - return idCounts.entries - .where((e) => e.value > 1) - .map((e) => e.key) - .toSet(); + return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } /// Get marker color for a repeater based on status priority: @@ -3883,11 +4725,146 @@ class _MapWidgetState extends State { return _repeaterMarkerColor; // Active (default) } +<<<<<<< HEAD +======= + List _buildRepeaterMarkers( + List repeaters, + int? regionHopBytesOverride, { + bool onlyFaded = false, + bool onlyConnected = false, + }) { + final duplicateIds = _getDuplicateRepeaterIds(repeaters); + final hasFocus = _focusedPingLocation != null; + + return repeaters.where((repeater) { + if (!hasFocus) return true; // No focus — include all + final isConnected = + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + if (onlyConnected) return isConnected; + if (onlyFaded) return !isConnected; + return true; + }).map((repeater) { + final isDuplicate = duplicateIds.contains(repeater.id); + final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + + // During focus mode, fade repeaters not connected to the focused ping + final isConnected = hasFocus && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + final effectiveColor = (hasFocus && !isConnected) + ? markerColor.withValues(alpha: 0.15) + : markerColor; + final effectiveBorderColor = (hasFocus && !isConnected) + ? Colors.white.withValues(alpha: 0.15) + : Colors.white; + final effectiveTextColor = (hasFocus && !isConnected) + ? Colors.white.withValues(alpha: 0.15) + : Colors.white; + + // Display hex ID based on per-repeater hop_bytes (or regional admin override) + final displayId = + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; + final isLongId = displayId.length > 2; + final markerWidth = displayId.length > 4 + ? 48.0 + : isLongId + ? 40.0 + : 28.0; + + // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded + final borderRadius = effectiveBytes >= 3 + ? BorderRadius.circular(8) + : effectiveBytes == 2 + ? BorderRadius.circular(6) + : BorderRadius.circular(4); + + return Marker( + point: LatLng(repeater.lat, repeater.lon), + width: markerWidth, + height: 28, + child: GestureDetector( + onTap: () => _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride), + child: Container( + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 4) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: effectiveColor, + borderRadius: borderRadius, + border: Border.all(color: effectiveBorderColor, width: 2), + boxShadow: (hasFocus && !isConnected) + ? null + : const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: displayId.length > 4 + ? 8 + : isLongId + ? 9 + : 10, + fontWeight: FontWeight.bold, + color: effectiveTextColor, + fontFamily: 'monospace', + ), + ), + ), + ), + ); + }).toList(); + } + + Widget _buildCurrentPositionMarker(double heading) { + // Convert heading from degrees to radians + // heading is 0-360 degrees, 0 = North, 90 = East + final headingRadians = heading * (math.pi / 180); + final style = context.read().preferences.gpsMarkerStyle; + + // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = + style == 'arrow' || style == 'walk' || style == 'chomper'; + + final CustomPainter painter; + switch (style) { + case 'car': + painter = const _CarMarkerPainter(); + case 'bike': + painter = const _BikeMarkerPainter(); + case 'boat': + painter = const _BoatMarkerPainter(); + case 'walk': + painter = const _WalkMarkerPainter(); + case 'chomper': + painter = const _ChomperMarkerPainter(); + case 'arrow': + default: + painter = const _ArrowPainter(); + } + + final child = CustomPaint(size: const Size(24, 24), painter: painter); + return shouldRotate + ? Transform.rotate(angle: headingRadians, child: child) + : child; + } + +>>>>>>> a431a6a (format with dart) /// Compute node column width based on hop byte count. /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { final appState = context.read(); - final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + final hopBytes = appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes; switch (hopBytes) { case 2: return 70 + extraPadding; @@ -3910,7 +4887,8 @@ class _MapWidgetState extends State { snrValues: heardRepeaters.map((r) => r.snr).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } } @@ -3931,7 +4909,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3945,9 +4924,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), + child: Icon(Icons.arrow_upward, + color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3956,15 +4937,20 @@ class _MapWidgetState extends State { children: [ Text( 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -3982,15 +4968,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4009,7 +5003,9 @@ class _MapWidgetState extends State { // Repeaters section header Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + heardRepeaters.isEmpty + ? 'No repeaters heard' + : 'Heard Repeaters (${heardRepeaters.length})', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -4025,13 +5021,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4041,7 +5042,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4052,7 +5055,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4063,32 +5068,49 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; - final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', + value: + repeater.snr?.toStringAsFixed(1) ?? + '-', color: snrColor, ), ), @@ -4097,7 +5119,9 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', color: rssiColor, ), ), @@ -4130,7 +5154,8 @@ class _MapWidgetState extends State { snrValues: [ping.snr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -4144,7 +5169,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4158,9 +5184,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, color: Colors.blue, size: 24), + child: const Icon(Icons.arrow_downward, + color: Colors.blue, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4170,8 +5198,8 @@ class _MapWidgetState extends State { Text( 'RX Ping', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), @@ -4199,11 +5227,17 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4237,13 +5271,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4253,7 +5292,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4264,7 +5305,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4275,7 +5318,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4285,13 +5330,19 @@ class _MapWidgetState extends State { Divider(height: 1, color: Theme.of(context).dividerColor), // Data row InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -4306,12 +5357,12 @@ class _MapWidgetState extends State { child: Center( child: _buildStatChip( value: '${ping.rssi}', - color: rssiColor, + color: rssiColor, + ), ), ), - ), - ], - ), + ], + ), ), ), ], @@ -4330,10 +5381,12 @@ class _MapWidgetState extends State { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -4354,7 +5407,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4368,9 +5422,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: _discMarkerColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + border: Border.all( + color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: + Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4379,15 +5435,20 @@ class _MapWidgetState extends State { children: [ Text( 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -4405,15 +5466,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4450,13 +5519,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4466,7 +5540,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4477,7 +5553,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4488,7 +5566,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4499,24 +5579,36 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { final rxSnrColor = PingColors.snrColor(node.localSnr); - final rssiColor = PingColors.rssiColor(node.localRssi); - final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Node ID with type @@ -4524,7 +5616,9 @@ class _MapWidgetState extends State { width: _nodeColumnWidth(extraPadding: 20), child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), Text( node.nodeTypeLabel, style: TextStyle( @@ -4558,7 +5652,8 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), + value: + node.remoteSnr.toStringAsFixed(1), color: txSnrColor, ), ), @@ -4601,7 +5696,8 @@ class _MapWidgetState extends State { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + void _showRepeaterDetails(Repeater repeater, + {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -4627,7 +5723,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4637,7 +5734,8 @@ class _MapWidgetState extends State { children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); final isLongId = displayId.length > 2; return Container( constraints: const BoxConstraints(minWidth: 44), @@ -4667,8 +5765,8 @@ class _MapWidgetState extends State { child: Text( repeater.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -4688,7 +5786,8 @@ class _MapWidgetState extends State { Row( children: [ if (isDuplicate) ...[ - _buildRepeaterStatusChip('Duplicate', _repeaterDuplicateColor), + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), const SizedBox(width: 8), ], _buildRepeaterStatusChip(statusLabel, statusColor), @@ -4702,14 +5801,21 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Location row Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4727,7 +5833,10 @@ class _MapWidgetState extends State { // Last heard row Row( children: [ - Icon(Icons.access_time, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.access_time, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4907,7 +6016,13 @@ class _BikeMarkerPainter extends CustomPainter { ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear ..moveTo(cx, cy - 5) ..lineTo(cx + 2, cy - 7); // Handlebar - canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + canvas.drawPath( + framePath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round); // Blue wheels canvas.drawCircle(leftWheel, wheelR, bikePaint); @@ -4961,11 +6076,21 @@ class _BoatMarkerPainter extends CustomPainter { canvas.drawPath(hull, fillPaint); // Mast outline - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round); // Mast - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = const Color(0xFF2196F3) + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round); // Sail outline final sailOutline = ui.Path() @@ -4981,7 +6106,11 @@ class _BoatMarkerPainter extends CustomPainter { ..lineTo(cx + 6, cy - 0.5) ..lineTo(cx + 1, cy - 0.5) ..close(); - canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + canvas.drawPath( + sail, + Paint() + ..color = const Color(0xFF64B5F6) + ..style = PaintingStyle.fill); } @override @@ -5014,7 +6143,12 @@ class _WalkMarkerPainter extends CustomPainter { ..style = PaintingStyle.fill; // Head outline + fill - canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle( + Offset(cx, cy - 7), + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); // Body outline @@ -5023,9 +6157,11 @@ class _WalkMarkerPainter extends CustomPainter { canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); // Arms outline - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); // Arms - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); // Left leg outline canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); @@ -5105,7 +6241,9 @@ class _PinMarkerPainter extends CustomPainter { final cx = size.width / 2; final cy = size.height / 2; - final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; final outlinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke @@ -5141,11 +6279,13 @@ class _PinMarkerPainter extends CustomPainter { canvas.drawCircle(headCenter, headRadius, outlinePaint); // Inner dot - canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + canvas.drawCircle( + headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); } @override - bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a diamond marker for coverage dots @@ -5185,7 +6325,8 @@ class _DiamondMarkerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a repeater marker shape (filled colored rounded box with white border @@ -5364,7 +6505,9 @@ class _SoundItemWidgetState extends State<_SoundItemWidget> { : Colors.blue.withValues(alpha: 0.2), shape: BoxShape.circle, border: Border.all( - color: _isPlaying ? Colors.blue : Colors.blue.withValues(alpha: 0.5), + color: _isPlaying + ? Colors.blue + : Colors.blue.withValues(alpha: 0.5), width: _isPlaying ? 2 : 1, ), ), diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 568807cf..77510bc3 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -12,13 +12,16 @@ class InteractiveNoiseFloorChart extends StatefulWidget { final NoiseFloorSession session; final bool isLive; - const InteractiveNoiseFloorChart({super.key, required this.session, this.isLive = false}); + const InteractiveNoiseFloorChart( + {super.key, required this.session, this.isLive = false}); @override - State createState() => InteractiveNoiseFloorChartState(); + State createState() => + InteractiveNoiseFloorChartState(); } -class InteractiveNoiseFloorChartState extends State { +class InteractiveNoiseFloorChartState + extends State { // View window in seconds late double _viewStart; late double _viewEnd; @@ -68,7 +71,8 @@ class InteractiveNoiseFloorChartState extends State final effectiveTotal = newTotal < 60 ? 60.0 : newTotal; // Detect if user is at full (unzoomed) view: start near 0 and end near total - final isFullView = _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; + final isFullView = + _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; _totalDuration = effectiveTotal; @@ -92,14 +96,18 @@ class InteractiveNoiseFloorChartState extends State double get _visibleDuration => _viewEnd - _viewStart; double get _zoomLevel => _totalDuration / _visibleDuration; - void _handleScaleStart(ScaleStartDetails details, double chartWidth, double chartLeft) { + void _handleScaleStart( + ScaleStartDetails details, double chartWidth, double chartLeft) { _gestureStartViewStart = _viewStart; _gestureStartViewEnd = _viewEnd; _gestureStartFocalX = details.localFocalPoint.dx; } - void _handleScaleUpdate(ScaleUpdateDetails details, double chartWidth, double chartLeft) { - if (_gestureStartViewStart == null || _gestureStartViewEnd == null || _gestureStartFocalX == null) { + void _handleScaleUpdate( + ScaleUpdateDetails details, double chartWidth, double chartLeft) { + if (_gestureStartViewStart == null || + _gestureStartViewEnd == null || + _gestureStartFocalX == null) { return; } @@ -110,7 +118,8 @@ class InteractiveNoiseFloorChartState extends State newDuration = newDuration.clamp(_minVisibleSeconds, _totalDuration); // Calculate focal point ratio in chart space - final focalRatio = ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); + final focalRatio = + ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); // Time at focal point in original view final focalTime = _gestureStartViewStart! + (startDuration * focalRatio); @@ -150,7 +159,8 @@ class InteractiveNoiseFloorChartState extends State } /// Check if tap hit a marker and show popup if so - void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, double chartHeight, double chartTop) { + void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, + double chartHeight, double chartTop) { final session = widget.session; if (session.markers.isEmpty || session.samples.isEmpty) return; @@ -161,7 +171,8 @@ class InteractiveNoiseFloorChartState extends State // Find if tap is within any marker for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < _viewStart || elapsed > _viewEnd) continue; @@ -176,7 +187,8 @@ class InteractiveNoiseFloorChartState extends State final tapX = details.localPosition.dx; final tapY = details.localPosition.dy; - final distance = ((tapX - markerX) * (tapX - markerX) + (tapY - markerY) * (tapY - markerY)); + final distance = ((tapX - markerX) * (tapX - markerX) + + (tapY - markerY) * (tapY - markerY)); if (distance <= _markerTapRadius * _markerTapRadius) { _showMarkerDetails(marker, noiseFloorOnLine.round()); return; @@ -185,9 +197,14 @@ class InteractiveNoiseFloorChartState extends State } /// Interpolate noise floor at given elapsed time - double _interpolateNoiseFloor(double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + double _interpolateNoiseFloor( + double elapsedSeconds, NoiseFloorSession session) { + if (session.samples.isEmpty) { + return widget.session.noiseFloorRange.min.toDouble(); + } + if (session.samples.length == 1) { + return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -195,7 +212,8 @@ class InteractiveNoiseFloorChartState extends State double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -210,8 +228,10 @@ class InteractiveNoiseFloorChartState extends State if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } /// Show marker details popup as a modern bottom sheet @@ -260,7 +280,10 @@ class InteractiveNoiseFloorChartState extends State width: 40, height: 4, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), @@ -282,8 +305,8 @@ class InteractiveNoiseFloorChartState extends State Text( eventTypeLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), Text( @@ -325,7 +348,8 @@ class InteractiveNoiseFloorChartState extends State context, icon: Icons.location_on, label: 'Location', - value: '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', + value: + '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', compact: true, ), ), @@ -334,19 +358,26 @@ class InteractiveNoiseFloorChartState extends State ), // Repeaters section (table format like TX log) - if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ + if (marker.repeaters != null && + marker.repeaters!.isNotEmpty) ...[ const SizedBox(height: 16), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ SizedBox( @@ -356,7 +387,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -367,7 +400,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -378,16 +413,20 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows - ...marker.repeaters!.map((r) => _buildRepeaterRow(context, r)), + ...marker.repeaters! + .map((r) => _buildRepeaterRow(context, r)), ], ), ), @@ -401,7 +440,8 @@ class InteractiveNoiseFloorChartState extends State child: FilledButton.icon( onPressed: () { // Get references before popping - final appState = Provider.of(context, listen: false); + final appState = Provider.of(context, + listen: false); final navigator = Navigator.of(context); // Pop the bottom sheet first @@ -412,7 +452,8 @@ class InteractiveNoiseFloorChartState extends State navigator.popUntil((route) => route.isFirst); // Navigate to map and center on location - appState.navigateToMapCoordinates(marker.latitude!, marker.longitude!); + appState.navigateToMapCoordinates( + marker.latitude!, marker.longitude!); }, icon: const Icon(Icons.map, size: 18), label: const Text('View on Map'), @@ -444,7 +485,10 @@ class InteractiveNoiseFloorChartState extends State return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -487,17 +531,21 @@ class InteractiveNoiseFloorChartState extends State final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fullHexId: repeater.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ // Node ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip( + repeaterId: repeater.repeaterId, fontSize: 11, width: 50), // SNR chip Expanded( child: Center( - child: _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), + child: + _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), ), ), // RSSI chip @@ -575,24 +623,32 @@ class InteractiveNoiseFloorChartState extends State Expanded( child: LayoutBuilder( builder: (context, constraints) { - final chartWidth = constraints.maxWidth - leftPadding - rightPadding; + final chartWidth = + constraints.maxWidth - leftPadding - rightPadding; - final chartHeight = constraints.maxHeight - topPadding - 36.0; // 36 = bottom axis reserved + final chartHeight = constraints.maxHeight - + topPadding - + 36.0; // 36 = bottom axis reserved return RawGestureDetector( gestures: { - ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + ScaleGestureRecognizer>( () => ScaleGestureRecognizer(), (ScaleGestureRecognizer instance) { - instance.onStart = (details) => _handleScaleStart(details, chartWidth, leftPadding); - instance.onUpdate = (details) => _handleScaleUpdate(details, chartWidth, leftPadding); + instance.onStart = (details) => + _handleScaleStart(details, chartWidth, leftPadding); + instance.onUpdate = (details) => + _handleScaleUpdate(details, chartWidth, leftPadding); instance.onEnd = _handleScaleEnd; }, ), - TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + TapGestureRecognizer>( () => TapGestureRecognizer(), (TapGestureRecognizer instance) { - instance.onTapUp = (details) => _handleTap(details, chartWidth, leftPadding, chartHeight, topPadding); + instance.onTapUp = (details) => _handleTap(details, + chartWidth, leftPadding, chartHeight, topPadding); }, ), }, @@ -601,7 +657,8 @@ class InteractiveNoiseFloorChartState extends State children: [ // Line chart - wrapped in IgnorePointer so it doesn't steal gestures Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: LineChart( LineChartData( @@ -622,7 +679,8 @@ class InteractiveNoiseFloorChartState extends State ), // Marker overlay Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: CustomPaint( size: Size.infinite, @@ -664,13 +722,15 @@ class InteractiveNoiseFloorChartState extends State LineChartBarData _buildLineData(NoiseFloorSession session) { // Return cached data if session hasn't changed (prevents rebuilding during zoom) - if (_cachedLineData != null && _cachedSession == session && + if (_cachedLineData != null && + _cachedSession == session && _cachedSampleCount == session.samples.length) { return _cachedLineData!; } final spots = session.samples.map((s) { - final elapsed = s.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + s.timestamp.difference(session.startTime).inSeconds.toDouble(); return FlSpot(elapsed, s.noiseFloor.toDouble()); }).toList(); @@ -703,9 +763,9 @@ class InteractiveNoiseFloorChartState extends State ]; final stops = [ 0.0, - yToStop(-100), // Start fading from green - yToStop(-90), // Orange in middle - yToStop(-80), // Fade to red + yToStop(-100), // Start fading from green + yToStop(-90), // Orange in middle + yToStop(-80), // Fade to red 1.0, ]; @@ -919,7 +979,8 @@ class _MarkerPainter extends CustomPainter { if (visibleRange <= 0 || chartWidth <= 0 || chartHeight <= 0) return; for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < minX || elapsed > maxX) continue; @@ -948,7 +1009,9 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + if (session.samples.length == 1) { + return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -956,7 +1019,8 @@ class _MarkerPainter extends CustomPainter { double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -971,8 +1035,10 @@ class _MarkerPainter extends CustomPainter { if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } @override diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart index b54aec58..af3ed03c 100644 --- a/lib/widgets/offline_mode_toggle.dart +++ b/lib/widgets/offline_mode_toggle.dart @@ -84,16 +84,18 @@ class OfflineModeToggle extends StatelessWidget { } /// Show confirmation dialog explaining what the mode does - static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { - final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + static Future _showConfirmDialog( + BuildContext context, bool switchingToOffline) { + final title = + switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; final iconColor = switchingToOffline ? Colors.orange : Colors.green; final description = switchingToOffline ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' - 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' - 'You can upload saved data later from the Settings tab.' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' - 'This requires an active internet connection.'; + 'This requires an active internet connection.'; final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; return showDialog( @@ -147,7 +149,8 @@ class OfflineModeToggle extends StatelessWidget { return Material( color: Colors.transparent, child: InkWell( - onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + onTap: () => handleOfflineModeToggle( + context, appState, offlineMode, isConnected), borderRadius: BorderRadius.circular(10), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index c05b96f0..b20aee8c 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -15,29 +15,44 @@ class PingControls extends StatelessWidget { Widget build(BuildContext context) { final appState = context.watch(); final validation = appState.pingValidation; - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete - final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode + final isPendingDisable = appState + .isPendingDisable; // Disable pending, waiting for RX window to complete + final cooldownActive = appState + .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; // RX listening window after ping + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = + appState.rxWindowTimer.isRunning; // RX listening window after ping final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; // True immediately when manual ping button clicked - final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) - final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping + final isPingSending = appState + .isPingSending; // True immediately when manual ping button clicked + final isPingInProgress = appState + .isPingInProgress; // True during entire ping + RX window (includes auto pings) + final autoPingWaiting = + appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) + final autoPingSkipped = appState.autoPingTimer.skipReason != + null; // Last ping was skipped (e.g. distance) + final discoveryWindowActive = appState.discoveryWindowTimer + .isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; // TX is blocked when offline mode is active and connected @@ -53,7 +68,9 @@ class PingControls extends StatelessWidget { Color? blockingColor; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; if (!appState.isConnected) { // Don't show hint when disconnected - buttons are obviously disabled @@ -87,89 +104,135 @@ class PingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // Send Ping button - // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" - // Manual pings use 15-second cooldown, no distance requirement - // When Active/Passive Mode is running, just shows "Send Ping" (disabled) - Expanded( - child: _ActionButton( - icon: Icons.cell_tower, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isTxModeRunning - ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running - : isPingSending - ? 'Sending...' - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) - : manualCooldownActive - ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown - : discoveryWindowActive - ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow - onPressed: () => _sendPing(context, appState), - showCooldown: false, // No longer needed - countdown shown in label - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : null, // No "Move Xm" - manual pings have no distance requirement - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : null, + // Send Ping button + // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" + // Manual pings use 15-second cooldown, no distance requirement + // When Active/Passive Mode is running, just shows "Send Ping" (disabled) + Expanded( + child: _ActionButton( + icon: Icons.cell_tower, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isTxModeRunning + ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running + : isPingSending + ? 'Sending...' + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) + : manualCooldownActive + ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown + : discoveryWindowActive + ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: (isPingSending || rxWindowActive) && + !isTxModeRunning, // Only active during manual ping flow + onPressed: () => _sendPing(context, appState), + showCooldown: + false, // No longer needed - countdown shown in label + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : null, // No "Move Xm" - manual pings have no distance requirement + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : null, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), - // Active/Hybrid Mode button (toggle) - // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon - // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle - // When OFF after being ON: shows "Cooldown Xs" like other buttons - // During manual ping: shows "Cooldown Xs" (disabled) - Expanded( - child: _ActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isPendingDisable - ? (rxWindowActive - ? 'Stopping ${rxWindowRemaining}s' - : discoveryWindowActive - ? 'Stopping ${discoveryWindowRemaining}s' - : 'Stopping...') - : isTxModeRunning - ? (isPingInProgress && !rxWindowActive && !discoveryWindowActive - ? 'Sending...' - : discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // TX RX window - : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode', - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - showCooldown: false, - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : (isPendingDisable ? 'Stopping' : null), - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : Colors.orange, + // Active/Hybrid Mode button (toggle) + // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon + // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle + // When OFF after being ON: shows "Cooldown Xs" like other buttons + // During manual ping: shows "Cooldown Xs" (disabled) + Expanded( + child: _ActionButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isPendingDisable + ? (rxWindowActive + ? 'Stopping ${rxWindowRemaining}s' + : discoveryWindowActive + ? 'Stopping ${discoveryWindowRemaining}s' + : 'Stopping...') + : isTxModeRunning + ? (isPingInProgress && + !rxWindowActive && + !discoveryWindowActive + ? 'Sending...' + : discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // TX RX window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next ping ${autoPingRemaining}s') + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode') + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode', + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + showCooldown: false, + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : (isPendingDisable ? 'Stopping' : null), + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : Colors.orange, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), ], // Passive Mode button (toggle) @@ -182,24 +245,35 @@ class PingControls extends StatelessWidget { icon: Icons.hearing, label: isPassiveModeRunning ? (discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window + ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery - : 'Passive Mode') // Initial state before first discovery + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable - ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping + ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening + ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Passive Mode', color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || + autoPingWaiting), // Active during listening/waiting phases onPressed: () => _toggleRxAuto(context, appState), ), ), @@ -231,7 +305,9 @@ class PingControls extends StatelessWidget { // Targeted Ping controls _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, ), @@ -239,7 +315,8 @@ class PingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -249,17 +326,20 @@ class PingControls extends StatelessWidget { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -274,8 +354,8 @@ class _ActionButton extends StatefulWidget { final bool isActive; final bool showCooldown; final VoidCallback onPressed; - final String? subtitle; // Optional subtitle text (e.g., "Move 5m") - final Color? subtitleColor; // Optional subtitle color + final String? subtitle; // Optional subtitle text (e.g., "Move 5m") + final Color? subtitleColor; // Optional subtitle color const _ActionButton({ required this.icon, @@ -338,7 +418,8 @@ class _ActionButtonState extends State<_ActionButton> // Use color when enabled, active (RX listening), or during cooldown // This prevents the button from going grey during cooldown final showColor = widget.enabled || widget.isActive || widget.showCooldown; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; return AnimatedBuilder( @@ -378,7 +459,8 @@ class _ActionButtonState extends State<_ActionButton> size: 26, color: showColor ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Active indicator dot if (widget.isActive) @@ -407,9 +489,12 @@ class _ActionButtonState extends State<_ActionButton> widget.label, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, color: showColor - ? (widget.isActive ? effectiveColor : colorScheme.onSurface) + ? (widget.isActive + ? effectiveColor + : colorScheme.onSurface) : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), @@ -431,7 +516,8 @@ class _ActionButtonState extends State<_ActionButton> style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: widget.subtitleColor ?? Colors.orange.shade600, + color: widget.subtitleColor ?? + Colors.orange.shade600, ), ) : null, @@ -475,7 +561,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { WidgetsBinding.instance.addPostFrameCallback((_) { final appState = context.read(); final existing = appState.targetRepeaterId; - if (existing != null && existing.isNotEmpty && _controller.text != existing) { + if (existing != null && + existing.isNotEmpty && + _controller.text != existing) { _controller.text = existing; } }); @@ -545,14 +633,17 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { final buttonColor = (isTargetedRunning || _isStarting) ? const Color(0xFF22C55E) // green-500 when running/starting : Colors.cyan; - final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + final effectiveColor = + isEnabled ? buttonColor : colorScheme.onSurfaceVariant; return Container( decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), border: Border.all( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), width: isTargetedRunning ? 1.5 : 1, ), ), @@ -567,7 +658,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { HapticFeedback.lightImpact(); if (!isTargetedRunning) { setState(() => _isStarting = true); - appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + appState.setTargetRepeaterId( + _controller.text.trim().toUpperCase()); } await appState.toggleAutoPing(AutoMode.targeted); if (mounted) setState(() => _isStarting = false); @@ -594,8 +686,13 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : 'Trace Mode', style: TextStyle( fontSize: 13, - fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, - color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: isTargetedRunning + ? FontWeight.w600 + : FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), overflow: TextOverflow.ellipsis, ), @@ -622,14 +719,16 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : colorScheme.onSurface, ), decoration: InputDecoration( - hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintText: + 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', hintStyle: TextStyle( fontSize: 12, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), counterText: '', isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), @@ -705,21 +804,28 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -737,12 +843,17 @@ class _CompactPingControlsState extends State { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Determine which button is currently active (not during cooldown) - final sendPingCurrentlyActive = (isPingSending || rxWindowActive || manualCooldownActive) && !isTxModeRunning; + final sendPingCurrentlyActive = + (isPingSending || rxWindowActive || manualCooldownActive) && + !isTxModeRunning; final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; - final passiveModeCurrentlyActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeCurrentlyActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); // Track the last active button for cooldown if (sendPingCurrentlyActive) { @@ -755,14 +866,20 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { + if (!cooldownActive && + !manualCooldownActive && + !sendPingCurrentlyActive && + !activeModeCurrentlyActive && + !passiveModeCurrentlyActive && + !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } // Determine which button should be expanded // During cooldown, the last active button stays expanded final sendPingExpanded = sendPingCurrentlyActive || - (manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing) || + (manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing) || (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); final activeModeExpanded = activeModeCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.activeMode); @@ -770,36 +887,80 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; - final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; + final sendPingEnabled = canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable; + final sendPingActive = (isPingSending || rxWindowActive) && + !isTxModeRunning && + !cooldownActive && + !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet); - final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeEnabled = isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet); + final passiveModeActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; // Trace Mode (only relevant when a repeater ID has been entered) - final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final hasTargetRepeaterId = appState.targetRepeaterId != null && + appState.targetRepeaterId!.isNotEmpty; final targetedCurrentlyActive = isTargetedRunning; final traceModeExpanded = targetedCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); - final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && - !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && - !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeEnabled = hasTargetRepeaterId && + !isTxModeRunning && + !isPassiveModeRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + !manualCooldownActive && + appState.isConnected && + prefs.externalAntennaSet && + isPowerSet; final traceModeActive = isTargetedRunning; final traceModeShowColor = traceModeEnabled || traceModeActive; // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; + final anyExpanded = sendPingExpanded || + activeModeExpanded || + passiveModeExpanded || + traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); + final allDisabled = !sendPingShowColor && + !activeModeShowColor && + !passiveModeShowColor && + (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -822,9 +983,11 @@ class _CompactPingControlsState extends State { isExpanded: sendPingExpanded, progress: rxWindowActive && !isTxModeRunning ? appState.rxWindowTimer.progress - : manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.manualPingCooldownTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : cooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.cooldownTimer.progress : null, onPressed: () => _sendPing(context, appState), @@ -857,13 +1020,18 @@ class _CompactPingControlsState extends State { isActive: activeModeActive, isExpanded: activeModeExpanded, progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning - ? (discoveryWindowActive ? appState.discoveryWindowTimer.progress : appState.rxWindowTimer.progress) + ? (discoveryWindowActive + ? appState.discoveryWindowTimer.progress + : appState.rxWindowTimer.progress) : autoPingWaiting && isTxModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.activeMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode ? appState.cooldownTimer.progress : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), ); final passiveModeButton = _CompactActionButton( @@ -890,7 +1058,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isPassiveModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode ? appState.cooldownTimer.progress : null, onPressed: () => _toggleRxAuto(context, appState), @@ -921,7 +1090,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isTargetedRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + : cooldownActive && + _lastActiveButton == _LastActiveButton.targeted ? appState.cooldownTimer.progress : null, onPressed: () { @@ -937,23 +1107,23 @@ class _CompactPingControlsState extends State { return Row( children: [ if (!txNotAllowed) ...[ - // Send Ping - expanded buttons stay big even when grey (cooldown) - if (sendPingExpanded) - Expanded(child: sendPingButton) - else if (!anyExpanded && (sendPingShowColor || allDisabled)) - Expanded(child: sendPingButton) - else - sendPingButton, - const SizedBox(width: 6), - - // Active Mode - if (activeModeExpanded) - Expanded(child: activeModeButton) - else if (!anyExpanded && (activeModeShowColor || allDisabled)) - Expanded(child: activeModeButton) - else - activeModeButton, - const SizedBox(width: 6), + // Send Ping - expanded buttons stay big even when grey (cooldown) + if (sendPingExpanded) + Expanded(child: sendPingButton) + else if (!anyExpanded && (sendPingShowColor || allDisabled)) + Expanded(child: sendPingButton) + else + sendPingButton, + const SizedBox(width: 6), + + // Active Mode + if (activeModeExpanded) + Expanded(child: activeModeButton) + else if (!anyExpanded && (activeModeShowColor || allDisabled)) + Expanded(child: activeModeButton) + else + activeModeButton, + const SizedBox(width: 6), ], // Passive Mode @@ -993,10 +1163,26 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + if (rxWindowActive) { + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (manualCooldownActive) { + return showFullText + ? 'Cooldown ${manualCooldownRemaining}s' + : '${manualCooldownRemaining}s'; + } + if (discoveryWindowActive) { + return showFullText + ? 'Cooldown ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (cooldownActive) { + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; + } return null; } @@ -1019,19 +1205,45 @@ class _CompactPingControlsState extends State { int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (rxWindowActive) { + return showFullText + ? 'Stopping ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (discoveryWindowActive) { + return showFullText + ? 'Stopping ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (isPingInProgress && !rxWindowActive) { + return showFullText ? 'Sending...' : '...'; + } + if (rxWindowActive) { + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1051,12 +1263,24 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1076,18 +1300,31 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isTargetedRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -1097,17 +1334,20 @@ class _CompactPingControlsState extends State { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1123,21 +1363,28 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -1153,7 +1400,9 @@ class LandscapePingControls extends StatelessWidget { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1172,58 +1421,85 @@ class LandscapePingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // TX Ping button - Expanded( - child: _LandscapeIconButton( - icon: Icons.cell_tower, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, - countdown: isPingSending - ? null - : rxWindowActive && !isTxModeRunning - ? rxWindowRemaining - : manualCooldownActive - ? manualCooldownRemaining - : discoveryWindowActive - ? discoveryWindowRemaining - : cooldownActive - ? cooldownRemaining - : null, - onPressed: () => _sendPing(context, appState), + // TX Ping button + Expanded( + child: _LandscapeIconButton( + icon: Icons.cell_tower, + tooltip: + txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: + (isPingSending || rxWindowActive) && !isTxModeRunning, + countdown: isPingSending + ? null + : rxWindowActive && !isTxModeRunning + ? rxWindowRemaining + : manualCooldownActive + ? manualCooldownRemaining + : discoveryWindowActive + ? discoveryWindowRemaining + : cooldownActive + ? cooldownRemaining + : null, + onPressed: () => _sendPing(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), - // Active/Hybrid Mode button - Expanded( - child: _LandscapeIconButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - countdown: isTxModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : rxWindowActive - ? rxWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : isPendingDisable && (rxWindowActive || discoveryWindowActive) - ? (rxWindowActive ? rxWindowRemaining : discoveryWindowRemaining) - : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + // Active/Hybrid Mode button + Expanded( + child: _LandscapeIconButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + countdown: isTxModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : rxWindowActive + ? rxWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : isPendingDisable && + (rxWindowActive || discoveryWindowActive) + ? (rxWindowActive + ? rxWindowRemaining + : discoveryWindowRemaining) + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), ], // Passive Mode button @@ -1234,10 +1510,18 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || autoPingWaiting), countdown: isPassiveModeRunning ? (discoveryWindowActive ? discoveryWindowRemaining @@ -1254,7 +1538,9 @@ class LandscapePingControls extends StatelessWidget { // Targeted Ping controls (Trace Mode) _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, compact: true, @@ -1263,22 +1549,26 @@ class LandscapePingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); await appState.sendPing(); } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1321,20 +1611,26 @@ class _LandscapeAntennaSelector extends StatelessWidget { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: externalAntennaSet ? colorScheme.onSurfaceVariant : notSetColor, + color: externalAntennaSet + ? colorScheme.onSurfaceVariant + : notSetColor, ), ), if (!externalAntennaSet) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: notSetColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text( 'Required', - style: TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: notSetColor), + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: notSetColor), ), ), ], @@ -1347,7 +1643,8 @@ class _LandscapeAntennaSelector extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), + border: + Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), ), child: Row( children: [ @@ -1361,22 +1658,30 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (!externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(left: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'Internal', style: TextStyle( fontSize: 11, - fontWeight: (!externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (!externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (!externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (!externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), ), ), // Divider - Container(width: 1, height: 18, color: colorScheme.outline.withValues(alpha: 0.3)), + Container( + width: 1, + height: 18, + color: colorScheme.outline.withValues(alpha: 0.3)), // External option Expanded( child: GestureDetector( @@ -1387,15 +1692,20 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(right: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'External', style: TextStyle( fontSize: 11, - fontWeight: (externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), @@ -1475,7 +1785,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; return AnimatedBuilder( animation: _pulseAnimation, @@ -1495,7 +1806,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.25), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.25), width: widget.isActive ? 1.5 : 1, ), ), @@ -1506,7 +1818,9 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Icon( widget.icon, size: 24, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), // Countdown badge (bottom right) if (widget.countdown != null) @@ -1514,7 +1828,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> bottom: 4, right: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), decoration: BoxDecoration( color: effectiveColor, borderRadius: BorderRadius.circular(6), @@ -1540,7 +1855,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> decoration: BoxDecoration( color: const Color(0xFF22C55E), shape: BoxShape.circle, - border: Border.all(color: colorScheme.surface, width: 1.5), + border: Border.all( + color: colorScheme.surface, width: 1.5), ), ), ), @@ -1566,7 +1882,8 @@ class _CompactActionButton extends StatefulWidget { final bool isActive; final bool isExpanded; // When true, show icon + label with wider width final VoidCallback onPressed; - final double? progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar + final double? + progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar const _CompactActionButton({ required this.icon, @@ -1625,7 +1942,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); @@ -1647,7 +1965,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(16), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.3), width: widget.isActive ? 1.5 : 1, ), ), @@ -1683,7 +2002,10 @@ class _CompactActionButtonState extends State<_CompactActionButton> Icon( widget.icon, size: 18, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Animated label - show when label is provided AnimatedSize( @@ -1698,8 +2020,13 @@ class _CompactActionButtonState extends State<_CompactActionButton> widget.label!, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), ), ], diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 10a909e1..b149cde9 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -27,7 +27,8 @@ class RegionalConfigCard extends StatelessWidget { } // When offline mode is enabled, show "-" for zone fields - final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); + final displayZoneName = + isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; return Card( @@ -41,19 +42,22 @@ class RegionalConfigCard extends StatelessWidget { children: [ Icon( isOfflineMode ? Icons.cloud_off : Icons.public, - color: isOfflineMode ? Colors.orange : Theme.of(context).colorScheme.primary, + color: isOfflineMode + ? Colors.orange + : Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( 'Regional Configuration', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), if (isOfflineMode) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -137,16 +141,16 @@ class RegionalConfigCard extends StatelessWidget { Text( 'Regional Settings', style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), if (displayZone != null) Text( displayZone, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), @@ -172,7 +176,8 @@ class RegionalConfigCard extends StatelessWidget { } /// Compact labeled row: small label on left, chips on right - Widget _buildCompactRow(BuildContext context, String label, List chips) { + Widget _buildCompactRow( + BuildContext context, String label, List chips) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -201,7 +206,8 @@ class RegionalConfigCard extends StatelessWidget { ); } - Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, + Widget _buildInfoRow( + BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( children: [ @@ -211,20 +217,26 @@ class RegionalConfigCard extends StatelessWidget { if (value != null) ...[ const SizedBox(width: 8), Expanded( - child: Text(value, style: TextStyle( - color: isOffline - ? Colors.orange.shade700 - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - )), + child: Text(value, + style: TextStyle( + color: isOffline + ? Colors.orange.shade700 + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + )), ), ], ], ); } - Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { + Widget _buildChannelChip(BuildContext context, String name, + {bool isDefault = false}) { // Public channel doesn't use # prefix; scope/plain values pass through as-is - final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + final displayName = + name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); // If it doesn't look like a channel name, show raw value (e.g. scope "Global") final isChannel = name.startsWith('#') || name == 'Public'; final label = isChannel ? displayName : name; @@ -247,7 +259,9 @@ class RegionalConfigCard extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, + color: isDefault + ? Colors.grey + : Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 43a4ca42..2144f049 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -34,10 +34,10 @@ class RepeaterIdChip extends StatelessWidget { Widget build(BuildContext context) { // Scale font size down for longer IDs final effectiveFontSize = repeaterId.length > 4 - ? fontSize - 2.0 // 6-char IDs (3-byte) + ? fontSize - 2.0 // 6-char IDs (3-byte) : repeaterId.length > 2 - ? fontSize - 1.0 // 4-char IDs (2-byte) - : fontSize; // 2-char IDs (1-byte) + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) final child = Row( mainAxisSize: MainAxisSize.min, @@ -57,7 +57,10 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.info_outline, size: fontSize - 1, - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ), ], ); @@ -80,7 +83,8 @@ class RepeaterIdChip extends StatelessWidget { /// /// When [fromLatLng] is provided, distances are measured from that point /// (e.g. the ping's GPS location) instead of the user's current position. - static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId, ({double lat, double lon})? fromLatLng}) { + static void showRepeaterPopup(BuildContext context, String repeaterId, + {String? fullHexId, ({double lat, double lon})? fromLatLng}) { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; @@ -106,7 +110,8 @@ class RepeaterIdChip extends StatelessWidget { ? fullHexId.substring(0, 8) : repeaterId; final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); if (matches.isEmpty) { @@ -130,17 +135,23 @@ class RepeaterIdChip extends StatelessWidget { // Sort by distance (closest first) when a reference point is available if (refLat != null && refLon != null) { matches.sort((a, b) { - final distA = GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); - final distB = GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); + final distA = + GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = + GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); return distA.compareTo(distB); }); } - final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, refLat: refLat, refLon: refLon, regionHopBytesOverride: regionOverride)) + .map((r) => _buildRepeaterRow(context, r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -205,7 +216,8 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; @@ -213,7 +225,10 @@ class RepeaterIdChip extends StatelessWidget { String? distanceText; if (refLat != null && refLon != null) { final meters = GpsService.distanceBetween( - refLat, refLon, repeater.lat, repeater.lon, + refLat, + refLon, + repeater.lat, + repeater.lon, ); debugLog('[UI] Distance to ${repeater.name}: ' 'from (${refLat.toStringAsFixed(5)}, ${refLon.toStringAsFixed(5)}) ' @@ -225,8 +240,7 @@ class RepeaterIdChip extends StatelessWidget { if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -235,7 +249,9 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), + _buildHexBadge( + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), + badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -268,7 +284,8 @@ class RepeaterIdChip extends StatelessWidget { distanceText, style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -314,9 +331,8 @@ class RepeaterIdChip extends StatelessWidget { return Container( constraints: const BoxConstraints(minWidth: 28), height: 28, - padding: isLong - ? const EdgeInsets.symmetric(horizontal: 5) - : EdgeInsets.zero, + padding: + isLong ? const EdgeInsets.symmetric(horizontal: 5) : EdgeInsets.zero, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(14), diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index f78950b5..4bc45fa8 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -69,10 +69,16 @@ class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { // By distance if GPS available if (position != null) { final distA = GpsService.distanceBetween( - position.latitude, position.longitude, a.lat, a.lon, + position.latitude, + position.longitude, + a.lat, + a.lon, ); final distB = GpsService.distanceBetween( - position.latitude, position.longitude, b.lat, b.lon, + position.latitude, + position.longitude, + b.lat, + b.lon, ); return distA.compareTo(distB); } @@ -227,20 +233,23 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; if (position != null) { final meters = GpsService.distanceBetween( - position!.latitude, position!.longitude, repeater.lat, repeater.lon, + position!.latitude, + position!.longitude, + repeater.lat, + repeater.lon, ); if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -323,8 +332,7 @@ class _RepeaterTile extends StatelessWidget { decoration: BoxDecoration( color: badgeColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: - Border.all(color: badgeColor.withValues(alpha: 0.4)), + border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 403b6d99..9b139b7a 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -58,7 +58,8 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -119,51 +120,102 @@ class _StatusBarState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); + return ( + 'Discovery Requests', + 'Discovery requests we have heard a response for.', + Icons.radar, + PingColors.discSuccess + ); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -180,7 +232,11 @@ class _StatusBarState extends State { icon = Icons.flight; color = Colors.grey; text = '-'; - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } // Show GPS region (e.g., "YOW") when locked and inside a zone @@ -191,7 +247,8 @@ class _StatusBarState extends State { icon = Icons.flight; color = appState.isConnected ? (appState.txAllowed ? Colors.green : Colors.red) - : Colors.grey; // Grey when not connected, red when zone is at TX capacity + : Colors + .grey; // Grey when not connected, red when zone is at TX capacity text = appState.zoneCode!; } else if (appState.inZone == false) { // GPS locked but outside any zone @@ -229,7 +286,11 @@ class _StatusBarState extends State { break; } - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } Widget _buildStatsIndicator(BuildContext context, AppStateProvider appState) { @@ -392,7 +453,8 @@ class _AnimatedStatChipState extends State<_AnimatedStatChip> child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: widget.color.withValues(alpha: _highlightAnimation.value), + color: + widget.color.withValues(alpha: _highlightAnimation.value), borderRadius: BorderRadius.circular(8), border: Border.all(color: widget.color.withValues(alpha: 0.4)), ), diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart index 4ba980ea..99660b15 100644 --- a/lib/widgets/upload_logs_dialog.dart +++ b/lib/widgets/upload_logs_dialog.dart @@ -162,15 +162,16 @@ class _UploadLogsSheetState extends State { // Build the upload list using the user's selection applied to the freshly rotated files. // Selected paths from before rotation still match, plus any newly rotated file is included. final selectedPaths = Set.from(_selectedLogFiles); - final filesToUpload = freshFiles - .where((f) => selectedPaths.contains(f.path)) - .toList(); + final filesToUpload = + freshFiles.where((f) => selectedPaths.contains(f.path)).toList(); // If the rotation produced a new file that wasn't in the original selection // (i.e. the previously-active log that just got rotated), include it too // since the user selected "all" initially and this file has new content. - final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); - if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + final newFiles = + freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && + selectedPaths.length == _availableLogFiles.length) { filesToUpload.addAll(newFiles); } @@ -191,7 +192,8 @@ class _UploadLogsSheetState extends State { final publicKey = widget.appState.devicePublicKey ?? widget.appState.lastConnectedPublicKey ?? 'not-connected'; - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; final userNotes = _descriptionController.text.trim(); int uploadedCount = 0; @@ -220,7 +222,8 @@ class _UploadLogsSheetState extends State { onProgress: (p) { _onProgressUpdate(BugReportProgress( status: p.status, - progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + progress: + (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), currentFile: i + 1, totalFiles: totalFiles, )); @@ -242,7 +245,8 @@ class _UploadLogsSheetState extends State { success: uploadedCount > 0, uploadedCount: uploadedCount, failedCount: failedCount, - errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + errorMessage: + failedCount > 0 ? '$failedCount file(s) failed to upload' : null, ); Navigator.of(context).pop(result); @@ -287,13 +291,15 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Upload Logs', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -312,13 +318,15 @@ class _UploadLogsSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, children: [ // Explanation text Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), @@ -355,7 +363,8 @@ class _UploadLogsSheetState extends State { textCapitalization: TextCapitalization.sentences, decoration: _buildInputDecoration( theme, - hintText: 'Briefly describe why you\'re uploading these logs...', + hintText: + 'Briefly describe why you\'re uploading these logs...', alignLabelWithHint: true, ), maxLines: 3, @@ -381,10 +390,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -411,10 +422,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -437,17 +450,20 @@ class _UploadLogsSheetState extends State { else Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Column( children: [ // Select all / deselect all header Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), child: Row( children: [ Text( @@ -460,7 +476,8 @@ class _UploadLogsSheetState extends State { TextButton( onPressed: () { setState(() { - if (_selectedLogFiles.length == _availableLogFiles.length) { + if (_selectedLogFiles.length == + _availableLogFiles.length) { _selectedLogFiles.clear(); } else { _selectedLogFiles.clear(); @@ -471,7 +488,8 @@ class _UploadLogsSheetState extends State { }); }, child: Text( - _selectedLogFiles.length == _availableLogFiles.length + _selectedLogFiles.length == + _availableLogFiles.length ? 'Deselect All' : 'Select All', ), @@ -481,22 +499,28 @@ class _UploadLogsSheetState extends State { ), Divider( height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: theme.colorScheme.outline + .withValues(alpha: 0.3), ), // File list ...List.generate(_availableLogFiles.length, (index) { final file = _availableLogFiles[index]; final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); + final isSelected = + _selectedLogFiles.contains(file.path); String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } return ListTile( @@ -512,9 +536,11 @@ class _UploadLogsSheetState extends State { style: const TextStyle(fontSize: 13), ), trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, + color: + theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( @@ -524,7 +550,9 @@ class _UploadLogsSheetState extends State { ), ), ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), ); }), ], @@ -589,7 +617,8 @@ class _UploadLogsSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -642,7 +671,8 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Uploading...', style: theme.textTheme.titleLarge), ], @@ -663,7 +693,8 @@ class _UploadLogsSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -678,16 +709,16 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 32), - Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), - if (_totalFiles != null && _currentFile != null) Text( 'File $_currentFile of $_totalFiles', @@ -696,7 +727,6 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 24), - SizedBox( width: 250, child: Column( @@ -705,7 +735,8 @@ class _UploadLogsSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), From a7cfd44a748eb5c117f9f4452857643ad253ac53 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 21:43:42 -0400 Subject: [PATCH 071/360] Fix power level hint directing users to Settings instead of Connect tab --- Build.sh | 1 + ios/Podfile.lock | 2 +- ios/Runner/Info.plist | 2 ++ lib/widgets/ping_controls.dart | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Build.sh b/Build.sh index 202b62c0..f6828162 100755 --- a/Build.sh +++ b/Build.sh @@ -153,6 +153,7 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." +(cd ios && pod install) flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" cp build/ios/ipa/mesh_mapper.ipa "$IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "✓ Built: MeshMapper-$FILE_TAG.ipa" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ebb426d3..21b95770 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -29,6 +29,6 @@ SPEC CHECKSUMS: flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 5f6d31cc7a922ccb43b951411657266fcae3377c COCOAPODS: 1.16.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bdea0622..251402f2 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -32,6 +32,8 @@ NSBluetoothAlwaysUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving + NSCameraUsageDescription + This app does not use the camera. This entry is required by a file picker library. NSBluetoothPeripheralUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index b20aee8c..f080ac02 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -79,7 +79,7 @@ class PingControls extends StatelessWidget { blockingIcon = Icons.settings_input_antenna; blockingColor = Colors.orange; } else if (!isPowerSet) { - blockingHint = 'Select power level in Settings'; + blockingHint = 'Select power level in Connect tab'; blockingIcon = Icons.bolt; blockingColor = Colors.orange; } else if (validation == PingValidation.noGpsLock) { From a7a0c325c4a4f3f76a7b61fb53d6802013936078 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 22:15:45 -0400 Subject: [PATCH 072/360] =?UTF-8?q?Fix=20power=20level=20hint:=20Settings?= =?UTF-8?q?=20=E2=86=92=20Connect=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/ping_controls.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index fe44f970..3f76b67a 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -79,7 +79,7 @@ class PingControls extends StatelessWidget { blockingIcon = Icons.settings_input_antenna; blockingColor = Colors.orange; } else if (!isPowerSet) { - blockingHint = 'Select power level in Settings'; + blockingHint = 'Select power level in Connect tab'; blockingIcon = Icons.bolt; blockingColor = Colors.orange; } else if (validation == PingValidation.noGpsLock) { From 8f3c2da7d8ff246b96ad612f70c1afdf2307cb98 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 08:26:17 -0700 Subject: [PATCH 073/360] Center download selection map on user last known or current location clean up small issues --- lib/screens/offline_maps_screen.dart | 35 +++++++++++++++++++++------ lib/services/offline_map_service.dart | 1 - 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index ca4ac1e1..65619bbe 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -449,7 +449,7 @@ class _OfflineMapsScreenState extends State { if (result != null) { await service.setStorageLimit(result); - if (mounted) { + if (context.mounted) { AppToast.success(context, 'Storage limit set to $result MB'); } } @@ -469,7 +469,7 @@ class _OfflineMapsScreenState extends State { // Toast is handled by the _onServiceUpdate listener when the download // completes (which may happen long after this page returns). - if (started == true && mounted) { + if (started == true && context.mounted) { AppToast.simple( context, 'Download started — check notifications for progress'); } @@ -506,7 +506,7 @@ class _OfflineMapsScreenState extends State { if (confirmed == true) { final success = await service.deleteRegion(region.id); - if (mounted) { + if (context.mounted) { if (success) { AppToast.success(context, '"${region.name}" deleted'); } else { @@ -543,7 +543,7 @@ class _OfflineMapsScreenState extends State { if (confirmed == true) { await service.deleteAllRegions(); - if (mounted) { + if (context.mounted) { AppToast.success(context, 'All regions deleted'); } } @@ -612,10 +612,13 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { final _nameController = TextEditingController(); String _selectedStyle = 'Liberty'; double _minZoom = 6; - double _maxZoom = 14; + double _maxZoom = 15; bool _submitting = false; String? _error; + // Default center (Ottawa) + static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); + // Bounds selection via interactive map MapLibreMapController? _mapController; LatLng? _boundsNE; @@ -670,9 +673,24 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { @override Widget build(BuildContext context) { + final appState = context.watch(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; + // Determine map center - prefer current GPS, fallback to last known, then Ottawa + LatLng center = _defaultCenter; + if (appState.currentPosition != null) { + center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + } else if (appState.lastKnownPosition != null) { + center = LatLng( + appState.lastKnownPosition!.lat, + appState.lastKnownPosition!.lon, + ); + } + return Scaffold( appBar: AppBar( toolbarHeight: 40, @@ -687,8 +705,8 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { children: [ MapLibreMap( styleString: _downloadStyles[_selectedStyle]!, - initialCameraPosition: const CameraPosition( - target: LatLng(49.28, -123.12), // Vancouver default + initialCameraPosition: CameraPosition( + target: center, // Vancouver default zoom: 10, ), onMapCreated: (controller) { @@ -1019,8 +1037,9 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { } Future _drawBoundsOverlay() async { - if (_mapController == null || _boundsSW == null || _boundsNE == null) + if (_mapController == null || _boundsSW == null || _boundsNE == null) { return; + } final sw = _boundsSW!; final ne = _boundsNE!; diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 6f5d3a63..8040866d 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -160,7 +160,6 @@ class OfflineMapService extends ChangeNotifier { notifyListeners(); } catch (e) { debugPrint('[OFFLINE_MAP] Init error: $e'); - _initialized = true; notifyListeners(); } } From f16c4594f001605c812b302f23d4ed26e2b17efa Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 10:09:55 -0700 Subject: [PATCH 074/360] update latest deps --- pubspec.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 243980ae..17e8082e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -724,18 +724,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1153,10 +1153,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: From cbaf99fc36389ff004c843c8baa30c3a873287e5 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 10:13:24 -0700 Subject: [PATCH 075/360] clean up missing braces comment out unused _blankStyleJson URL --- lib/providers/app_state_provider.dart | 9 +++-- lib/screens/log_screen.dart | 10 +++-- lib/screens/settings_screen.dart | 12 ++++-- lib/services/api_service.dart | 3 +- lib/services/bluetooth/mobile_bluetooth.dart | 3 +- lib/widgets/map_widget.dart | 7 ++-- lib/widgets/noise_floor_chart.dart | 9 +++-- lib/widgets/ping_controls.dart | 42 +++++++++++++------- 8 files changed, 63 insertions(+), 32 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 9c14b969..4ecc88f2 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3316,8 +3316,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) + if (_errorLogEntries.length > _maxErrorEntries) { _errorLogEntries.removeAt(0); + } if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3774,8 +3775,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) { return; + } final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; @@ -4298,8 +4300,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { return; + } debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index d95172c3..425a0a13 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -354,8 +354,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final event in tx.events) { if (event.repeaterId.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { return true; + } } return false; case PingLogType.rx: @@ -368,10 +369,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final node in disc.discoveredNodes) { if (node.repeaterId.toLowerCase().startsWith(query)) return true; if (node.pubkeyHex != null && - node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + node.pubkeyHex!.toLowerCase().startsWith(query)) { + return true; + } final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { return true; + } } return false; case PingLogType.trace: diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f0981368..29ecb172 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2533,13 +2533,15 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Link is missing the url parameter'); + } return; } if (key == null || key.isEmpty) { - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Link is missing the key parameter'); + } return; } @@ -2548,8 +2550,9 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Invalid URL in link: $rawUrl'); + } return; } @@ -2566,8 +2569,9 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Invalid meshmapper:// link'); + } } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index e147e94b..1cdd4325 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -276,8 +276,9 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) + if (power != null) { payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; payload['coords'] = { diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index cc3a441a..47a5f23f 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -172,8 +172,9 @@ class MobileBluetoothService implements BluetoothService { location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) + if (bluetoothConnect.isPermanentlyDenied) { denied.add('Bluetooth Connect'); + } if (location.isPermanentlyDenied) denied.add('Location'); debugLog( '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3c349b27..d9d2ca7d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -31,8 +31,8 @@ const _satelliteStyleJson = /// (saves mobile data while still showing markers and overlays). /// Includes a `glyphs` URL so native annotations using textField (repeater /// hex IDs, distance labels) can render their text even when tiles are off. -const _blankStyleJson = - '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; +// const _blankStyleJson = +// '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). @@ -594,8 +594,9 @@ class _MapWidgetState extends State { /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) { return; + } final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index dc92a09d..77510bc3 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -199,10 +199,12 @@ class InteractiveNoiseFloorChartState /// Interpolate noise floor at given elapsed time double _interpolateNoiseFloor( double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) + if (session.samples.isEmpty) { return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) + } + if (session.samples.length == 1) { return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -1007,8 +1009,9 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) + if (session.samples.length == 1) { return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 3f76b67a..f080ac02 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -1163,22 +1163,26 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) + if (rxWindowActive) { return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) + } + if (manualCooldownActive) { return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) + } + if (discoveryWindowActive) { return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) + } + if (cooldownActive) { return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + } return null; } @@ -1201,33 +1205,39 @@ class _CompactPingControlsState extends State { int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) + if (rxWindowActive) { return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) + } + if (discoveryWindowActive) { return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + } return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) + if (discoveryWindowActive) { return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) + } + if (isPingInProgress && !rxWindowActive) { return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) + } + if (rxWindowActive) { return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) + } + if (autoPingWaiting) { return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1253,16 +1263,18 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) + if (discoveryWindowActive) { return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) + } + if (autoPingWaiting) { return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1288,16 +1300,18 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isTargetedRunning) { - if (discoveryWindowActive) + if (discoveryWindowActive) { return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) + } + if (autoPingWaiting) { return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it From 9f8f5ac9c349224862c7cc8d7bc79ba1c865cc4f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 19 Apr 2026 16:23:43 -0400 Subject: [PATCH 076/360] feat(offline-maps): add download cancellation, queueing, and storage safeguards - Add "Cancel" button to in-progress download card; discards partial tiles and frees the slot for queued downloads - Queue multiple offline map downloads; starting a second download while one is running enqueues it, and the next begins automatically when the active download finishes or is cancelled. Progress card shows pending count - Check device free space before starting; reject downloads up front if less than 1.5x the estimated region size is available - Label storage usage on Offline Maps screen as an estimate (derived from tile-count heuristics) rather than presenting it as an exact figure - Surface real error causes from the native layer (network, style, IO) instead of the generic "Download error occurred" message - Cap offline zoom slider at z15 (was z18) and set default max zoom to z14; OpenFreeMap vector tiles max out at z14, so higher zooms produced duplicate overzoom tiles and could accidentally explode storage usage - Reject offline regions that cross the antimeridian with a clear message instead of silently producing a 357-degree-wide region - Fix race where the offline download dialog could close before validation errors were shown to the user --- .../plugins/GeneratedPluginRegistrant.java | 5 + ios/Podfile.lock | 8 +- lib/screens/home_screen.dart | 2 + lib/screens/offline_maps_screen.dart | 190 +++++++--- lib/screens/settings_screen.dart | 37 +- lib/services/offline_map_service.dart | 350 +++++++++++++++--- lib/widgets/map_widget.dart | 49 ++- pubspec.lock | 24 +- pubspec.yaml | 1 + 9 files changed, 526 insertions(+), 140 deletions(-) diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index aa77ae3c..b8be97d2 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -20,6 +20,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e); } + try { + flutterEngine.getPlugins().add(new com.piccmaq.disk_space_plus.DiskSpacePlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin disk_space_plus, com.piccmaq.disk_space_plus.DiskSpacePlusPlugin", e); + } try { flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); } catch (Exception e) { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 21b95770..3eb0bde7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - disk_space_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter @@ -8,12 +10,15 @@ PODS: - Flutter DEPENDENCIES: + - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) EXTERNAL SOURCES: + disk_space_plus: + :path: ".symlinks/plugins/disk_space_plus/ios" Flutter: :path: Flutter flutter_background_service_ios: @@ -24,11 +29,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" SPEC CHECKSUMS: + disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 5f6d31cc7a922ccb43b951411657266fcae3377c +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e COCOAPODS: 1.16.2 diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 90c94030..bd65adc8 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -115,6 +115,7 @@ class _HomeScreenState extends State { body: _buildLayout(appState, isLandscape: false), floatingActionButton: !_showControlPanel ? FloatingActionButton.extended( + heroTag: null, onPressed: () => setState(() => _showControlPanel = true), icon: const Icon(Icons.tune), label: const Text('Controls'), @@ -498,6 +499,7 @@ class _HomeScreenState extends State { bottom: 16, left: leftInset, child: FloatingActionButton.small( + heroTag: null, onPressed: _toggleControlPanel, child: const Icon(Icons.tune), ), diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 65619bbe..ba0992c8 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -7,15 +7,15 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/offline_map_service.dart'; +import '../utils/debug_logger_io.dart'; import '../widgets/app_toast.dart'; +import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; -/// Available map styles for offline download. -/// Satellite uses inline raster JSON which doesn't work well with the offline -/// region downloader, so we only offer the vector tile styles. -const _downloadStyles = { - 'Liberty': 'https://tiles.openfreemap.org/styles/liberty', - 'Dark': 'https://tiles.openfreemap.org/styles/dark', - 'Light': 'https://tiles.openfreemap.org/styles/bright', +/// Label → URL map for styles the user can download, derived from +/// [MapStyle.downloadable]. Satellite is excluded (inline raster JSON +/// doesn't work with MapLibre's offline region downloader). +final Map _downloadStyles = { + for (final s in MapStyleExtension.downloadable) s.label: s.styleUrl, }; /// Screen for managing offline map tile downloads. @@ -88,6 +88,7 @@ class _OfflineMapsScreenState extends State { floatingActionButton: (service.initialized && !kIsWeb && !service.isDownloading) ? FloatingActionButton.extended( + heroTag: null, onPressed: () => _showDownloadDialog(context), icon: const Icon(Icons.download), label: const Text('Download Region'), @@ -188,7 +189,7 @@ class _OfflineMapsScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${service.totalUsedDisplay} used', + '~${service.totalUsedDisplay} used (estimated)', style: theme.textTheme.bodySmall?.copyWith( color: barColor, fontWeight: FontWeight.w600, @@ -202,6 +203,15 @@ class _OfflineMapsScreenState extends State { ), ], ), + const SizedBox(height: 2), + Text( + 'Based on tile count heuristic; actual disk use may differ.', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), const SizedBox(height: 4), Text( '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', @@ -365,12 +375,68 @@ class _OfflineMapsScreenState extends State { 'Download continues in the background if you leave this screen', style: TextStyle(fontSize: 11, color: Colors.grey.shade500), ), + if (service.queueLength > 0) ...[ + const SizedBox(height: 4), + Text( + '${service.queueLength} more queued after this', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontStyle: FontStyle.italic, + ), + ), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => _confirmCancelDownload(context, service), + icon: const Icon(Icons.close, size: 16, color: Colors.red), + label: const Text('Cancel', + style: TextStyle(color: Colors.red)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ), ], ), ), ); } + Future _confirmCancelDownload( + BuildContext context, OfflineMapService service) async { + final name = service.downloadingRegionName ?? 'this download'; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel download?'), + content: Text( + 'Tiles downloaded so far for "$name" will be discarded. ' + 'Queued downloads (if any) will continue automatically.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Keep downloading'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Cancel download'), + ), + ], + ), + ); + if (confirmed != true) return; + final ok = await service.cancelActiveDownload(); + if (context.mounted && ok) { + AppToast.info(context, 'Download cancelled'); + } + } + // ────────────────────────────────────────────── // Storage limit dialog // ────────────────────────────────────────────── @@ -424,7 +490,7 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 12), Text( - 'Currently using ${service.totalUsedDisplay}', + 'Currently using ~${service.totalUsedDisplay} (estimated)', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), @@ -511,7 +577,7 @@ class _OfflineMapsScreenState extends State { AppToast.success(context, '"${region.name}" deleted'); } else { AppToast.error( - context, service.lastError ?? 'Failed to delete region'); + context, service.consumeLastError() ?? 'Failed to delete region'); } } } @@ -612,7 +678,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { final _nameController = TextEditingController(); String _selectedStyle = 'Liberty'; double _minZoom = 6; - double _maxZoom = 15; + double _maxZoom = 14; bool _submitting = false; String? _error; @@ -879,9 +945,13 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), RangeSlider( values: RangeValues(_minZoom, _maxZoom), + // OpenFreeMap vector tiles max out at z14; z15+ is pure + // overzoom (duplicate tile data). Slider caps at 15 to + // leave one overzoom step reachable without letting users + // blow out storage at z16+. min: 0, - max: 18, - divisions: 18, + max: 15, + divisions: 15, labels: RangeLabels( _minZoom.round().toString(), _maxZoom.round().toString(), @@ -942,30 +1012,55 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { } void _onMapTap(Point point, LatLng coordinates) { - setState(() { - if (_tapCount == 0) { + if (_tapCount == 0) { + setState(() { _boundsSW = coordinates; _boundsNE = null; _tapCount = 1; + _error = null; _clearBoundsOverlay(); - } else if (_tapCount == 1) { - // Ensure SW is actually southwest and NE is northeast - final lat1 = _boundsSW!.latitude; - final lng1 = _boundsSW!.longitude; - final lat2 = coordinates.latitude; - final lng2 = coordinates.longitude; - - _boundsSW = LatLng( - lat1 < lat2 ? lat1 : lat2, - lng1 < lng2 ? lng1 : lng2, - ); - _boundsNE = LatLng( - lat1 > lat2 ? lat1 : lat2, - lng1 > lng2 ? lng1 : lng2, - ); - _tapCount = 2; - _drawBoundsOverlay(); - } + }); + return; + } + if (_tapCount != 1) return; + + // Ensure SW is actually southwest and NE is northeast + final lat1 = _boundsSW!.latitude; + final lng1 = _boundsSW!.longitude; + final lat2 = coordinates.latitude; + final lng2 = coordinates.longitude; + + final sw = LatLng( + lat1 < lat2 ? lat1 : lat2, + lng1 < lng2 ? lng1 : lng2, + ); + final ne = LatLng( + lat1 > lat2 ? lat1 : lat2, + lng1 > lng2 ? lng1 : lng2, + ); + + // Reject selections that cross the antimeridian (lon span > 180°). + // The SW/NE min-max normalization above silently inverts such boxes + // into a ~357° wide region, which explodes tile count and MapLibre + // would refuse anyway. + if ((ne.longitude - sw.longitude).abs() > 180) { + setState(() { + _error = 'Selected region crosses the antimeridian. ' + 'Split into two regions (one per hemisphere).'; + _boundsSW = null; + _boundsNE = null; + _tapCount = 0; + _clearBoundsOverlay(); + }); + return; + } + + setState(() { + _boundsSW = sw; + _boundsNE = ne; + _tapCount = 2; + _error = null; + _drawBoundsOverlay(); }); } @@ -1005,7 +1100,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _existingFills.add(fill); _existingLines.add(line); } catch (e) { - debugPrint( + debugWarn( '[OFFLINE_MAP] Failed to draw existing region ${region.name}: $e'); } } @@ -1060,7 +1155,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { lineOpacity: 0.8, )); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); + debugWarn('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); } } @@ -1076,7 +1171,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _boundsLine = null; } } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); + debugWarn('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); } } @@ -1105,10 +1200,12 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _error = null; }); - // Fire-and-forget: the service runs the download in the background - // and shows a system notification for progress. We just kick it off - // and return to the management screen. - service.downloadRegion( + // Await the service call. downloadRegion returns once the native + // downloader has accepted (or rejected) the job — actual tile fetches + // continue in the background after this returns. If validation fails + // (quota, free-space, style, antimeridian, etc.), lastError is set and + // isDownloading is false; otherwise the download is queued. + await service.downloadRegion( name: name, bounds: bounds, styleUrl: styleUrl, @@ -1117,19 +1214,16 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { maxZoom: _maxZoom, ); - // Give the service a tick to validate and start - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - if (service.lastError != null && !service.isDownloading) { + if (!service.isDownloading && service.lastError != null) { setState(() { _submitting = false; - _error = service.lastError; + _error = service.consumeLastError(); }); - } else { - // Download is queued — return to the management screen - Navigator.pop(context, true); + return; } + // Download is queued — return to the management screen. + Navigator.pop(context, true); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 29ecb172..a7a29093 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -155,19 +155,30 @@ class _SettingsScreenState extends State { }, ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), - SwitchListTile( - secondary: - Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), - title: const Text('Disable Map Tiles'), - subtitle: Text(prefs.mapTilesEnabled - ? 'Map and coverage tiles load normally' - : 'Network tiles disabled · downloaded regions still visible'), - value: !prefs.mapTilesEnabled, - onChanged: (value) { - appState - .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); - }, - ), + Builder(builder: (_) { + // iOS plugin (maplibre_gl 0.25.0) doesn't implement setOffline, + // and MapLibre-iOS has no public equivalent. Disable the toggle + // there so it doesn't lie to the user. + final iosUnsupported = !kIsWeb && + defaultTargetPlatform == TargetPlatform.iOS; + return SwitchListTile( + secondary: Icon( + prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + title: const Text('Disable Map Tiles'), + subtitle: iosUnsupported + ? const Text('Not available on iOS') + : Text(prefs.mapTilesEnabled + ? 'Map and coverage tiles load normally' + : 'Network tiles disabled · downloaded regions still visible'), + value: !prefs.mapTilesEnabled, + onChanged: iosUnsupported + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(mapTilesEnabled: !value)); + }, + ); + }), if (!kIsWeb) ListTile( leading: const Icon(Icons.download_for_offline), diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 8040866d..e14d8160 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -1,8 +1,36 @@ +import 'dart:async'; + +import 'package:disk_space_plus/disk_space_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/debug_logger_io.dart'; + +/// A pending download waiting in the FIFO queue. +class _QueuedDownload { + final String name; + final LatLngBounds bounds; + final String styleUrl; + final String styleName; + final double minZoom; + final double maxZoom; + final int estBytes; + final Completer completer; + + _QueuedDownload({ + required this.name, + required this.bounds, + required this.styleUrl, + required this.styleName, + required this.minZoom, + required this.maxZoom, + required this.estBytes, + required this.completer, + }); +} + /// Metadata keys stored with each offline region. class _MetaKeys { _MetaKeys._(); @@ -48,8 +76,13 @@ class OfflineMapRegion { createdAt: DateTime.tryParse((meta[_MetaKeys.createdAt] as String?) ?? '') ?? DateTime.now(), - // Platform channel JSON round-trip can return int as num/double. - estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, + // Platform channel JSON round-trip can return int as num, double, or + // (on some Android paths) a stringified form. Tolerate all three. + estimatedBytes: switch (meta[_MetaKeys.estimatedBytes]) { + final num n => n.toInt(), + final String s => int.tryParse(s) ?? 0, + _ => 0, + }, ); } @@ -129,9 +162,36 @@ class OfflineMapService extends ChangeNotifier { bool get isDownloading => _downloadProgress != null; + /// ID of the region currently being downloaded. Set right after MapLibre + /// accepts the download; null when idle. Used by [cancelActiveDownload] to + /// delete the partial region via `deleteOfflineRegion`. + int? _activeRegionId; + + /// Completer tied to the currently-active download. Resolved when + /// [_onDownloadEvent] sees Success or Error, or when the download is + /// cancelled. + Completer? _activeCompleter; + + /// Estimated total bytes for the currently-active download, used by the + /// quota pre-check so queued jobs can't jointly bust the limit. + int _activeEstBytes = 0; + + /// FIFO queue of pending downloads. Drained one at a time after the + /// current download finishes (or is cancelled). + final List<_QueuedDownload> _queue = []; + int get queueLength => _queue.length; + String? _lastError; String? get lastError => _lastError; + /// Read [lastError] and clear it. Use this in UI that displays the error + /// once (banner, toast) so it doesn't linger after dismissal. + String? consumeLastError() { + final e = _lastError; + _lastError = null; + return e; + } + /// Name of the most recently completed download (for one-shot UI toast). /// Call [consumeLastCompletedName] to read and clear. String? _lastCompletedName; @@ -159,7 +219,7 @@ class OfflineMapService extends ChangeNotifier { _initialized = true; notifyListeners(); } catch (e) { - debugPrint('[OFFLINE_MAP] Init error: $e'); + debugError('[OFFLINE_MAP] Init error: $e'); notifyListeners(); } } @@ -193,7 +253,7 @@ class OfflineMapService extends ChangeNotifier { ); _notifInitialized = true; } catch (e) { - debugPrint('[OFFLINE_MAP] Notification init error: $e'); + debugError('[OFFLINE_MAP] Notification init error: $e'); } } @@ -224,7 +284,7 @@ class OfflineMapService extends ChangeNotifier { NotificationDetails(android: androidDetails), ); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to show progress notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to show progress notification: $e'); } } @@ -248,7 +308,7 @@ class OfflineMapService extends ChangeNotifier { const NotificationDetails(android: androidDetails), ); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to show complete notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to show complete notification: $e'); } } @@ -272,7 +332,7 @@ class OfflineMapService extends ChangeNotifier { const NotificationDetails(android: androidDetails), ); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to show error notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to show error notification: $e'); } } @@ -280,7 +340,7 @@ class OfflineMapService extends ChangeNotifier { try { await _notifPlugin.cancel(_progressNotifId); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to dismiss notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to dismiss notification: $e'); } } @@ -296,14 +356,14 @@ class OfflineMapService extends ChangeNotifier { try { parsed.add(OfflineMapRegion.fromOfflineRegion(r)); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); + debugWarn('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); } } _regions = parsed; _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); notifyListeners(); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to list regions: $e'); + debugError('[OFFLINE_MAP] Failed to list regions: $e'); } } @@ -316,7 +376,7 @@ class OfflineMapService extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_storageLimitKey, _storageLimitMb); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to save storage limit: $e'); + debugError('[OFFLINE_MAP] Failed to save storage limit: $e'); } notifyListeners(); } @@ -347,9 +407,24 @@ class OfflineMapService extends ChangeNotifier { /// We use 20 KB as a middle estimate. static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; - /// Check if downloading a region of [estimatedBytes] would exceed the limit. - bool wouldExceedLimit(int estimatedBytes) => - (totalUsedBytes + estimatedBytes) > storageLimitBytes; + /// Check if downloading a region of [estimatedBytes] would exceed the + /// limit. Accounts for the currently-active download and any queued jobs + /// so the UI's pre-check agrees with the service's internal validation. + bool wouldExceedLimit(int estimatedBytes) { + final pending = (isDownloading ? _activePendingBytes : 0) + + _queue.fold(0, (sum, q) => sum + q.estBytes); + return (totalUsedBytes + pending + estimatedBytes) > storageLimitBytes; + } + + /// Rough bytes remaining in the currently-active download. We don't know + /// the real size until completion — use the job's estimate as a proxy by + /// subtracting progress. + int get _activePendingBytes { + final progress = _downloadProgress ?? 0; + return ((1 - progress) * _activeEstBytes) + .clamp(0, _activeEstBytes) + .toInt(); + } // ── Download ── @@ -360,7 +435,14 @@ class OfflineMapService extends ChangeNotifier { /// receives progress callbacks and forwards them to both [notifyListeners] /// and a system notification. /// - /// Returns the new [OfflineMapRegion] on success, null on failure. + /// If another download is already active, the request is appended to a + /// FIFO queue and will start automatically when the current one finishes + /// or is cancelled. + /// + /// Returns the new [OfflineMapRegion] on success, null on failure or if + /// validation rejects the request (quota, free space, antimeridian, etc.). + /// The returned future resolves once the region's native download fully + /// completes — not when it's merely queued. Future downloadRegion({ required String name, required LatLngBounds bounds, @@ -370,43 +452,88 @@ class OfflineMapService extends ChangeNotifier { double maxZoom = 14, }) async { if (kIsWeb) return null; - if (isDownloading) { - _lastError = 'A download is already in progress'; - notifyListeners(); - return null; - } final tileCount = estimateTileCount(bounds, minZoom, maxZoom); final estBytes = estimateSizeBytes(tileCount); if (wouldExceedLimit(estBytes)) { _lastError = - 'Download would exceed storage limit (${_formatBytes(estBytes)} needed, ' - '${_formatBytes(storageLimitBytes - totalUsedBytes)} remaining)'; + 'Download would exceed storage limit (${_formatBytes(estBytes)} ' + 'needed)'; notifyListeners(); return null; } + // Device free-space check. The app-level quota above only caps estimated + // tile usage across our regions — the device itself may be nearly full + // regardless. We require 1.5× the estimate to leave headroom for the + // heuristic being low. Failures of the platform call fall through to + // the existing quota logic. + try { + final freeMb = await DiskSpacePlus().getFreeDiskSpace; + if (freeMb != null) { + final freeBytes = (freeMb * 1024 * 1024).toInt(); + final required = (estBytes * 1.5).toInt(); + if (freeBytes < required) { + _lastError = 'Not enough free space on device ' + '(${_formatBytes(required)} needed, ' + '${_formatBytes(freeBytes)} free)'; + notifyListeners(); + return null; + } + } + } catch (e) { + debugWarn('[OFFLINE_MAP] Free-space check failed: $e'); + } + + final job = _QueuedDownload( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: styleName, + minZoom: minZoom, + maxZoom: maxZoom, + estBytes: estBytes, + completer: Completer(), + ); + + if (isDownloading) { + _queue.add(job); + debugLog('[OFFLINE_MAP] Queued "$name" ' + '(position ${_queue.length} in queue)'); + notifyListeners(); + } else { + unawaited(_startJob(job)); + } + + return job.completer.future; + } + + /// Start a queued job. Also invoked recursively from [_onDownloadEvent] + /// after the active download finishes, to drain the queue. + Future _startJob(_QueuedDownload job) async { _downloadProgress = 0; - _downloadingRegionName = name; + _downloadingRegionName = job.name; + _activeCompleter = job.completer; + _activeEstBytes = job.estBytes; _lastError = null; _lastCompletedName = null; notifyListeners(); - _showProgressNotification(name, 0); + _showProgressNotification(job.name, 0); try { final definition = OfflineRegionDefinition( - bounds: bounds, - mapStyleUrl: styleUrl, - minZoom: minZoom, - maxZoom: maxZoom, + bounds: job.bounds, + mapStyleUrl: job.styleUrl, + minZoom: job.minZoom, + maxZoom: job.maxZoom, ); final metadata = { - _MetaKeys.name: name, - _MetaKeys.styleName: styleName, + _MetaKeys.name: job.name, + _MetaKeys.styleName: job.styleName, _MetaKeys.createdAt: DateTime.now().toIso8601String(), - _MetaKeys.estimatedBytes: estBytes, + _MetaKeys.estimatedBytes: job.estBytes, }; final region = await downloadOfflineRegion( @@ -414,45 +541,70 @@ class OfflineMapService extends ChangeNotifier { metadata: metadata, onEvent: _onDownloadEvent, ); - - // downloadOfflineRegion resolves once the native download is queued, - // not necessarily when it finishes. The _onDownloadEvent callback - // handles completion. But if progress is already null (Success fired - // synchronously), the download completed inline. - if (_downloadProgress != null) { - // Still in progress — the event callback will finalize. - return null; + _activeRegionId = region.id; + + // downloadOfflineRegion resolves once MapLibre has accepted the job; + // progress and completion are delivered via _onDownloadEvent. + // In the rare case Success already fired synchronously before this + // returns, _downloadProgress will be null — resolve the completer now + // so the caller doesn't hang waiting for an event that already fired. + if (_downloadProgress == null && !job.completer.isCompleted) { + final finalized = _regions.firstWhere((r) => r.id == region.id, + orElse: () => OfflineMapRegion.fromOfflineRegion(region)); + job.completer.complete(finalized); } - - // Completed synchronously (small region / cached tiles) - _downloadProgress = null; - _downloadingRegionName = null; - _lastCompletedName = name; - await refreshRegions(); - _showCompleteNotification(name); - return _regions.firstWhere((r) => r.id == region.id, - orElse: () => OfflineMapRegion.fromOfflineRegion(region)); } catch (e) { + debugError('[OFFLINE_MAP] downloadRegion threw: $e'); _downloadProgress = null; _downloadingRegionName = null; - _lastError = 'Download failed: $e'; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _lastError = 'Download failed (${e.runtimeType}): $e'; notifyListeners(); - _showErrorNotification(name); - return null; + _showErrorNotification(job.name); + if (!job.completer.isCompleted) job.completer.complete(null); + _drainQueue(); } } + /// Start the next queued download if any, once the current one has + /// finished, errored, or been cancelled. + void _drainQueue() { + if (_queue.isEmpty) return; + final next = _queue.removeAt(0); + unawaited(_startJob(next)); + } + void _onDownloadEvent(DownloadRegionStatus status) { if (status is Success) { final name = _downloadingRegionName ?? 'Region'; + final completer = _activeCompleter; + final regionId = _activeRegionId; _downloadProgress = null; _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; _lastCompletedName = name; - notifyListeners(); // Immediately clear progress state + notifyListeners(); _showCompleteNotification(name); // Small delay lets the native DB commit before we query it. - Future.delayed(const Duration(milliseconds: 500), () { - refreshRegions(); + Future.delayed(const Duration(milliseconds: 500), () async { + await refreshRegions(); + if (completer != null && !completer.isCompleted) { + OfflineMapRegion? finalized; + if (regionId != null) { + for (final r in _regions) { + if (r.id == regionId) { + finalized = r; + break; + } + } + } + completer.complete(finalized); + } + _drainQueue(); }); } else if (status is InProgress) { _downloadProgress = status.progress / 100.0; @@ -463,16 +615,96 @@ class OfflineMapService extends ChangeNotifier { _showProgressNotification(_downloadingRegionName ?? 'Region', percent); } } else { - // Error status + // DownloadRegionStatus is sealed to Success / InProgress / Error. The + // concrete Error class is named `Error` in maplibre_gl, which collides + // with dart:core.Error — so rather than `status is Error` we reach the + // `cause` field dynamically. `cause` is a PlatformException with + // code/message/details we can surface to the user. final name = _downloadingRegionName ?? 'Region'; + final completer = _activeCompleter; + String detail = 'unknown error'; + try { + final cause = (status as dynamic).cause; + if (cause != null) { + final msg = (cause as dynamic).message; + final code = (cause as dynamic).code; + if (msg is String && msg.isNotEmpty) { + detail = msg; + } else if (code is String && code.isNotEmpty) { + detail = code; + } else { + detail = cause.toString(); + } + } + } catch (_) { + // Swallow — keep the generic 'unknown error' fallback. + } + debugError('[OFFLINE_MAP] Download failed: $detail'); _downloadProgress = null; _downloadingRegionName = null; - _lastError = 'Download error occurred'; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _lastError = 'Download failed: $detail'; _showErrorNotification(name); notifyListeners(); + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + _drainQueue(); } } + /// Cancel the currently-active download, if any. Deletes the partial + /// region from MapLibre's cache. Queued downloads are left in place — + /// the next one will start automatically. Call [clearQueue] to discard + /// queued jobs too. + Future cancelActiveDownload() async { + if (kIsWeb) return false; + final regionId = _activeRegionId; + final completer = _activeCompleter; + final name = _downloadingRegionName; + if (regionId == null && !isDownloading) return false; + + debugLog('[OFFLINE_MAP] Cancelling active download' + '${name != null ? " \"$name\"" : ""}'); + + if (regionId != null) { + try { + await deleteOfflineRegion(regionId); + } catch (e) { + debugWarn('[OFFLINE_MAP] Cancel delete failed: $e'); + } + } + + _downloadProgress = null; + _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + await _dismissProgressNotification(); + notifyListeners(); + + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + + await refreshRegions(); + _drainQueue(); + return true; + } + + /// Discard all queued (but not yet active) downloads. Does not affect + /// the active download; call [cancelActiveDownload] for that. + void clearQueue() { + if (_queue.isEmpty) return; + for (final job in _queue) { + if (!job.completer.isCompleted) job.completer.complete(null); + } + _queue.clear(); + notifyListeners(); + } + // ── Deletion ── /// Delete a downloaded region by ID. @@ -483,7 +715,7 @@ class OfflineMapService extends ChangeNotifier { await refreshRegions(); return true; } catch (e) { - debugPrint('[OFFLINE_MAP] Delete failed: $e'); + debugError('[OFFLINE_MAP] Delete failed: $e'); _lastError = 'Failed to delete region: $e'; notifyListeners(); return false; @@ -498,7 +730,7 @@ class OfflineMapService extends ChangeNotifier { try { await deleteOfflineRegion(id); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to delete region $id: $e'); + debugError('[OFFLINE_MAP] Failed to delete region $id: $e'); } } await refreshRegions(); @@ -514,7 +746,7 @@ class OfflineMapService extends ChangeNotifier { await _initNotifications(); await _notifPlugin.cancel(_progressNotifId); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d9d2ca7d..fe7b9e8f 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -27,13 +29,6 @@ import 'repeater_id_chip.dart'; const _satelliteStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; -/// Blank style with dark background — used when mapTilesEnabled is false -/// (saves mobile data while still showing markers and overlays). -/// Includes a `glyphs` URL so native annotations using textField (repeater -/// hex IDs, distance labels) can render their text even when tiles are off. -// const _blankStyleJson = -// '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; - /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). const _defaultFontStack = ['Noto Sans Regular']; @@ -252,6 +247,23 @@ extension MapStyleExtension on MapStyle { return _satelliteStyleJson; } } + + /// Whether this style can be packaged as an offline region. Satellite uses + /// inline raster JSON which MapLibre's offline downloader doesn't support. + bool get isDownloadable { + switch (this) { + case MapStyle.dark: + case MapStyle.light: + case MapStyle.liberty: + return true; + case MapStyle.satellite: + return false; + } + } + + /// Styles offered in the offline download picker. + static List get downloadable => + MapStyle.values.where((s) => s.isDownloadable).toList(); } /// Resolved repeater with SNR and ambiguity info for ping focus mode. @@ -1085,6 +1097,23 @@ class _MapWidgetState extends State { ); } + /// Toggle MapLibre between online (network tiles) and offline (cache-only). + /// + /// No-op on iOS: maplibre_gl 0.25.0 never implemented the `setOffline` + /// method handler on iOS, and MapLibre-iOS itself doesn't expose an + /// equivalent of Android's `ConnectivityReceiver` hook. The "Disable Map + /// Tiles" Settings switch is marked unavailable on iOS for the same reason. + Future _setOfflineIfSupported(bool offline) async { + if (kIsWeb || Platform.isIOS) return; + try { + await setOffline(offline); + debugLog('[MAP] setOffline($offline) — ' + 'tiles ${offline ? "cache-only" : "enabled"}'); + } catch (e) { + debugWarn('[MAP] setOffline failed: $e'); + } + } + Widget _buildMap(AppStateProvider appState, LatLng center) { final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); @@ -1101,9 +1130,7 @@ class _MapWidgetState extends State { _lastMapTilesEnabled = tilesEnabled; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - setOffline(!tilesEnabled); - debugPrint( - '[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + _setOfflineIfSupported(!tilesEnabled); }); } @@ -1484,7 +1511,7 @@ class _MapWidgetState extends State { final tilesEnabled = appState.preferences.mapTilesEnabled; _lastMapTilesEnabled = tilesEnabled; // Ensure MapLibre offline mode matches the user's preference. - setOffline(!tilesEnabled); + _setOfflineIfSupported(!tilesEnabled); if (tilesEnabled) { _tileLoadFailed = false; _tileLoadTimeoutTimer = diff --git a/pubspec.lock b/pubspec.lock index 17e8082e..9a5dbf7a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + disk_space_plus: + dependency: "direct main" + description: + name: disk_space_plus + sha256: "1e454599a7dcec0c0bc1ad0b7f9f563b3fffbc988175bd452c4d3c7503ed3423" + url: "https://pub.dev" + source: hosted + version: "0.2.6" equatable: dependency: transitive description: @@ -724,18 +732,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1153,10 +1161,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0dcd0c4..fed05ed2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: font_awesome_flutter: ^10.7.0 fl_chart: ^0.69.0 web: ^1.1.0 + disk_space_plus: ^0.2.3 dev_dependencies: flutter_test: From 524435da71197dfb06747dd261b4d29e0ac09c8e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 19 Apr 2026 18:13:39 -0400 Subject: [PATCH 077/360] Making download tiles easier. --- lib/providers/app_state_provider.dart | 52 +++++ lib/screens/offline_maps_screen.dart | 275 +++++++++++++++++++++----- lib/services/api_service.dart | 74 +++++++ lib/widgets/map_widget.dart | 162 +++++++++++++++ 4 files changed, 513 insertions(+), 50 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4ecc88f2..fe609f3f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -325,6 +325,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _repeatersLoaded = false; String? _repeatersLoadedForIata; + // Regional boundary polygons (from /border API — always displayed on map) + List> _regionBorders = []; + String? _bordersLoadedForZone; + bool _bordersFetchInProgress = false; + // Regional channels from API (for UI display) List _regionalChannels = []; @@ -565,6 +570,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); + /// Regional boundary polygons loaded from the /border API. + /// Each entry is a `{code: String, polygon: List>}` map where + /// `polygon` holds `[lat, lon]` pairs in the server's original order. + List> get regionBorders => + List.unmodifiable(_regionBorders); + // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); @@ -3387,6 +3398,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { // Stop auto-save timer when leaving offline mode @@ -3457,6 +3470,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); debugLog('[APP] Successfully switched to offline mode'); @@ -4790,8 +4805,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (newZoneCode.isNotEmpty) { _fetchRepeatersForZone( newZoneCode); // fire-and-forget — don't block zone check + _fetchBorderPolygons(newZoneCode); // fire-and-forget } } else { + _regionBorders = []; + _bordersLoadedForZone = null; _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; @@ -5304,6 +5322,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _repeatersLoadedForIata = null; await _fetchRepeatersForZone(newZoneCode); + // Fetch updated boundary polygons for the new zone + _bordersLoadedForZone = null; + _regionBorders = []; + _fetchBorderPolygons(newZoneCode); // fire-and-forget + // 18. Re-enable heartbeat _apiService.enableHeartbeat( gpsProvider: () { @@ -5390,6 +5413,35 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Fetch regional boundary polygons for the current zone. + /// Called after a successful zone check; idempotent per IATA so the + /// /border endpoint is only hit once per zone transition. + Future _fetchBorderPolygons(String iata) async { + if (_bordersLoadedForZone == iata) return; + if (_bordersFetchInProgress) return; + if (_currentPosition == null) return; + + _bordersFetchInProgress = true; + try { + final result = await _apiService.fetchBorderPolygons( + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + appVersion: _appVersion, + ); + if (result != null && result.isNotEmpty) { + _regionBorders = result; + _bordersLoadedForZone = iata; + debugLog('[BORDER] Loaded ${result.length} polygon(s) for $iata'); + notifyListeners(); + } else { + debugWarn( + '[BORDER] No polygons returned for zone $iata — will retry on next zone check'); + } + } finally { + _bordersFetchInProgress = false; + } + } + // ============================================ // Debug File Logging (Mobile Only) // ============================================ diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index ba0992c8..6cf329a1 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -1,4 +1,4 @@ -import 'dart:math' show Point; +import 'dart:math' show Point, max, min; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; @@ -689,10 +689,19 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { MapLibreMapController? _mapController; LatLng? _boundsNE; LatLng? _boundsSW; - int _tapCount = 0; Line? _boundsLine; Fill? _boundsFill; + // Draggable corner resize handles. Each corner has two stacked circles: + // a larger invisible-ish "halo" below and a smaller visible handle on top. + // Both are draggable so fingers landing on the halo still start a drag — + // maplibre's iOS pan gesture only hit-tests after crossing its activation + // threshold (~10pt of movement), so the touch target has to be bigger than + // the visual. `_handleIdToCorner` maps each Circle id back to its corner + // so onFeatureDrag resolves either shape to the correct bounds update. + final Map> _cornerHandles = {}; + final Map _handleIdToCorner = {}; + // Existing region overlays final List _existingFills = []; final List _existingLines = []; @@ -717,7 +726,17 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { LatLngBounds? get _selectedBounds { if (_boundsNE == null || _boundsSW == null) return null; - return LatLngBounds(southwest: _boundsSW!, northeast: _boundsNE!); + // Normalize so tile estimates stay valid mid-drag when a corner has been + // pulled past its opposite (stored SW/NE may be inverted). + final sw = LatLng( + min(_boundsSW!.latitude, _boundsNE!.latitude), + min(_boundsSW!.longitude, _boundsNE!.longitude), + ); + final ne = LatLng( + max(_boundsSW!.latitude, _boundsNE!.latitude), + max(_boundsSW!.longitude, _boundsNE!.longitude), + ); + return LatLngBounds(southwest: sw, northeast: ne); } bool get _canSubmit => @@ -777,6 +796,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), onMapCreated: (controller) { _mapController = controller; + controller.onFeatureDrag.add(_onFeatureDrag); }, onStyleLoadedCallback: () { if (_showExisting) _drawExistingRegions(); @@ -800,10 +820,8 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), child: Text( _selectedBounds != null - ? 'Region selected · ~$_estimatedTiles tiles · $_estimatedSize' - : _tapCount == 1 - ? 'Tap the opposite corner to complete the region' - : 'Tap two corners on the map to select a region', + ? 'Drag corners to resize · ~$_estimatedTiles tiles · $_estimatedSize' + : 'Tap the map to place a region', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, ), @@ -945,13 +963,15 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), RangeSlider( values: RangeValues(_minZoom, _maxZoom), - // OpenFreeMap vector tiles max out at z14; z15+ is pure - // overzoom (duplicate tile data). Slider caps at 15 to - // leave one overzoom step reachable without letting users - // blow out storage at z16+. - min: 0, + // Min matches the app's MapLibreMap minMaxZoomPreference + // floor (3) so users can't cache tiles the app won't + // display. Max stays at 15: OpenFreeMap vector tiles max + // out at z14, z15+ is pure overzoom (duplicate tile data), + // so 15 leaves one overzoom step reachable without + // blowing out storage. + min: 3, max: 15, - divisions: 15, + divisions: 12, labels: RangeLabels( _minZoom.round().toString(), _maxZoom.round().toString(), @@ -1011,46 +1031,42 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ); } - void _onMapTap(Point point, LatLng coordinates) { - if (_tapCount == 0) { - setState(() { - _boundsSW = coordinates; - _boundsNE = null; - _tapCount = 1; - _error = null; - _clearBoundsOverlay(); - }); + Future _onMapTap(Point point, LatLng coordinates) async { + // Once a region exists, taps on the map do nothing — user resizes via + // the corner handles or resets to start over. + if (_boundsSW != null && _boundsNE != null) return; + if (_mapController == null) return; + + // Size the default box to ~40% of the visible map area centered on the + // tap, so the result feels proportional regardless of zoom level. + LatLngBounds visible; + try { + visible = await _mapController!.getVisibleRegion(); + } catch (e) { + debugWarn('[OFFLINE_MAP] getVisibleRegion failed: $e'); return; } - if (_tapCount != 1) return; - // Ensure SW is actually southwest and NE is northeast - final lat1 = _boundsSW!.latitude; - final lng1 = _boundsSW!.longitude; - final lat2 = coordinates.latitude; - final lng2 = coordinates.longitude; + final halfLat = (visible.northeast.latitude - visible.southwest.latitude) + .abs() * + 0.2; + final halfLng = (visible.northeast.longitude - visible.southwest.longitude) + .abs() * + 0.2; final sw = LatLng( - lat1 < lat2 ? lat1 : lat2, - lng1 < lng2 ? lng1 : lng2, + coordinates.latitude - halfLat, + coordinates.longitude - halfLng, ); final ne = LatLng( - lat1 > lat2 ? lat1 : lat2, - lng1 > lng2 ? lng1 : lng2, + coordinates.latitude + halfLat, + coordinates.longitude + halfLng, ); - // Reject selections that cross the antimeridian (lon span > 180°). - // The SW/NE min-max normalization above silently inverts such boxes - // into a ~357° wide region, which explodes tile count and MapLibre - // would refuse anyway. if ((ne.longitude - sw.longitude).abs() > 180) { setState(() { - _error = 'Selected region crosses the antimeridian. ' - 'Split into two regions (one per hemisphere).'; - _boundsSW = null; - _boundsNE = null; - _tapCount = 0; - _clearBoundsOverlay(); + _error = 'Visible area spans more than half the globe. ' + 'Zoom in before placing a region.'; }); return; } @@ -1058,10 +1074,10 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { setState(() { _boundsSW = sw; _boundsNE = ne; - _tapCount = 2; _error = null; - _drawBoundsOverlay(); }); + await _drawBoundsOverlay(); + if (mounted) setState(() {}); } void _resetBounds() { @@ -1069,7 +1085,6 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { setState(() { _boundsSW = null; _boundsNE = null; - _tapCount = 0; }); } @@ -1136,11 +1151,14 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { return; } - final sw = _boundsSW!; - final ne = _boundsNE!; - final nw = LatLng(ne.latitude, sw.longitude); - final se = LatLng(sw.latitude, ne.longitude); - final ring = [sw, se, ne, nw, sw]; + final corners = _cornerPositions(); + final ring = [ + corners['SW']!, + corners['SE']!, + corners['NE']!, + corners['NW']!, + corners['SW']!, + ]; try { _boundsFill = await _mapController!.addFill(FillOptions( @@ -1154,6 +1172,29 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { lineWidth: 2.0, lineOpacity: 0.8, )); + for (final entry in corners.entries) { + // Halo first so it renders below the visible handle. ~44pt diameter + // gives the finger a comfortable touch target even after iOS's pan + // activation threshold eats ~10pt of travel before hit-testing. + final halo = await _mapController!.addCircle(CircleOptions( + geometry: entry.value, + circleRadius: 22, + circleColor: '#4A90D9', + circleOpacity: 0.18, + draggable: true, + )); + final handle = await _mapController!.addCircle(CircleOptions( + geometry: entry.value, + circleRadius: 7, + circleColor: '#FFFFFF', + circleStrokeColor: '#4A90D9', + circleStrokeWidth: 2.5, + draggable: true, + )); + _cornerHandles[entry.key] = [halo, handle]; + _handleIdToCorner[halo.id] = entry.key; + _handleIdToCorner[handle.id] = entry.key; + } } catch (e) { debugWarn('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); } @@ -1170,11 +1211,145 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { await _mapController!.removeLine(_boundsLine!); _boundsLine = null; } + for (final circles in _cornerHandles.values) { + for (final circle in circles) { + try { + await _mapController!.removeCircle(circle); + } catch (_) {} + } + } + _cornerHandles.clear(); + _handleIdToCorner.clear(); } catch (e) { debugWarn('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); } } + /// The four visual corner LatLngs derived from the stored SW/NE pair. + /// Uses raw (non-normalized) values so handle semantics stay stable + /// mid-drag; the polygon wraps correctly either way. + Map _cornerPositions() { + final sw = _boundsSW!; + final ne = _boundsNE!; + return { + 'SW': sw, + 'SE': LatLng(sw.latitude, ne.longitude), + 'NE': ne, + 'NW': LatLng(ne.latitude, sw.longitude), + }; + } + + Future _refreshBoundsGeometry() async { + if (_mapController == null || + _boundsFill == null || + _boundsLine == null || + _boundsSW == null || + _boundsNE == null) { + return; + } + final corners = _cornerPositions(); + final ring = [ + corners['SW']!, + corners['SE']!, + corners['NE']!, + corners['NW']!, + corners['SW']!, + ]; + try { + await _mapController! + .updateFill(_boundsFill!, FillOptions(geometry: [ring])); + await _mapController! + .updateLine(_boundsLine!, LineOptions(geometry: ring)); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to refresh bounds geometry: $e'); + } + } + + /// Repositions every handle circle except the one maplibre is already + /// dragging. Each corner has a halo + visible pair, and the non-dragged + /// circle in the dragged corner still needs to follow its partner. + Future _refreshHandles({String? skipId}) async { + if (_mapController == null) return; + final corners = _cornerPositions(); + for (final entry in corners.entries) { + final circles = _cornerHandles[entry.key]; + if (circles == null) continue; + for (final circle in circles) { + if (circle.id == skipId) continue; + try { + await _mapController! + .updateCircle(circle, CircleOptions(geometry: entry.value)); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to update ${entry.key} handle: $e'); + } + } + } + } + + void _onFeatureDrag( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, + ) { + final corner = _handleIdToCorner[id]; + if (corner == null || + _boundsSW == null || + _boundsNE == null) { + return; + } + + switch (corner) { + case 'SW': + _boundsSW = current; + break; + case 'NE': + _boundsNE = current; + break; + case 'NW': + _boundsSW = LatLng(_boundsSW!.latitude, current.longitude); + _boundsNE = LatLng(current.latitude, _boundsNE!.longitude); + break; + case 'SE': + _boundsSW = LatLng(current.latitude, _boundsSW!.longitude); + _boundsNE = LatLng(_boundsNE!.latitude, current.longitude); + break; + } + + _refreshBoundsGeometry(); + _refreshHandles(skipId: id); + + if (eventType == DragEventType.end) { + // Snap stored SW/NE to normalized orientation and realign every + // handle (including the one just released) to its semantic corner. + final sw = LatLng( + min(_boundsSW!.latitude, _boundsNE!.latitude), + min(_boundsSW!.longitude, _boundsNE!.longitude), + ); + final ne = LatLng( + max(_boundsSW!.latitude, _boundsNE!.latitude), + max(_boundsSW!.longitude, _boundsNE!.longitude), + ); + _boundsSW = sw; + _boundsNE = ne; + + if ((ne.longitude - sw.longitude).abs() > 180) { + _error = 'Selected region crosses the antimeridian. ' + 'Split into two regions (one per hemisphere).'; + } else { + _error = null; + } + + _refreshHandles(); + _refreshBoundsGeometry(); + } + + if (mounted) setState(() {}); + } + Future _startDownload() async { final bounds = _selectedBounds; if (bounds == null) return; diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 1cdd4325..2901016e 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -25,6 +25,7 @@ class ApiService { static const String wardriveEndpoint = '$baseUrl/wardrive-api.php/wardrive'; static const String geoAuthStatusUrl = '$baseUrl/wardrive-api.php/status'; static const String geoAuthUrl = '$baseUrl/wardrive-api.php/auth'; + static const String borderUrl = '$baseUrl/wardrive-api.php/border'; /// API key — injected at build time via --dart-define=API_KEY=... static const String apiKey = String.fromEnvironment('API_KEY'); @@ -221,6 +222,79 @@ class ApiService { } } + /// Fetch regional boundary polygons from the MeshMapper API. + /// + /// Returns a list of `{code, polygon}` maps where `polygon` is a list of + /// `[lat, lon]` pairs (as sent by the server). Returns `null` on failure + /// or maintenance mode. + Future>?> fetchBorderPolygons({ + required double lat, + required double lon, + required String appVersion, + }) async { + final stopwatch = Stopwatch()..start(); + try { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final payload = { + 'lat': lat, + 'lng': lon, + 'ver': appVersion, + 'timestamp': timestamp, + 'key': apiKey, + }; + + final response = await _client + .post( + Uri.parse(borderUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError( + '[BORDER] /wardrive-api.php/border returned HTTP ${response.statusCode}'); + debugError( + '[BORDER] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map? data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError( + '[BORDER] Non-JSON response from /border (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + } + + _logApiCall( + endpoint: '/wardrive-api.php/border', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: payload, + response: data, + ); + + if (data == null) return null; + if (_checkMaintenanceMode(data)) return null; + + final polygons = data['polygons']; + if (polygons is! List) return null; + + return polygons + .whereType() + .map((p) => Map.from(p)) + .toList(); + } catch (e) { + stopwatch.stop(); + debugError('[BORDER] POST /wardrive-api.php/border failed: $e'); + return null; + } + } + /// Request authentication with MeshMapper geo-auth API /// Matches requestAuth() in wardrive.js /// diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index fe7b9e8f..4485c366 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -447,6 +447,12 @@ class _MapWidgetState extends State { static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + // Regional boundary (from /border API — always visible) + static const _regionBorderSourceId = 'region-border-source'; + static const _regionBorderLineLayerId = 'region-border-line'; + static const _regionBorderLabelLayerId = 'region-border-label'; + int _lastBordersSignature = -1; + // Tracks which marker style preference the coverage images are currently // registered for. When the user changes their preference, we re-register. String? _registeredCoverageStyle; @@ -1169,6 +1175,20 @@ class _MapWidgetState extends State { } } + // Watch for region boundary polygon changes. Signature is derived from + // the polygon codes AND point counts so zone transfers (same code, new + // shape) and refreshes both trigger a rebuild. + final borders = appState.regionBorders; + final bordersSig = Object.hashAll(borders.map( + (p) => Object.hash(p['code'], (p['polygon'] as List?)?.length ?? 0), + )); + if (bordersSig != _lastBordersSignature && _isMapReady && _styleLoaded) { + _lastBordersSignature = bordersSig; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshRegionBorders(appState); + }); + } + // Detect coverage overlay opacity change (user dragged the slider in // Settings → General) and push it to the live raster layer without // rebuilding the whole overlay. Skipped while ping focus mode is active — @@ -1345,6 +1365,13 @@ class _MapWidgetState extends State { return; } + // Regional boundary: either the line or the label → info dialog. + if (layerId == _regionBorderLineLayerId || + layerId == _regionBorderLabelLayerId) { + _showBorderInfoDialog(); + return; + } + // GPS marker tap: the GPS marker is a non-interactive symbol on the // annotation manager layer (which sits ON TOP of all custom layers in // paint order). Without intervention, taps on the GPS marker hit the @@ -1502,6 +1529,12 @@ class _MapWidgetState extends State { await _refreshCoverageOverlay(appState); _lastOverlayZoneCode = appState.zoneCode; + // Regional boundary layer — style reload wipes custom sources/layers. + // Reset the signature so the build()-driven watcher will repaint even + // if the polygon list hasn't changed (it almost always hasn't). + _lastBordersSignature = -1; + await _refreshRegionBorders(appState); + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, // we assume tiles are failing to load (network down, server error, etc.) // and surface a banner. Cleared as soon as onMapIdle fires. @@ -2410,6 +2443,135 @@ class _MapWidgetState extends State { } } + /// Rebuilds the regional boundary layer from `appState.regionBorders`. + /// Always-on: renders whenever polygons are present, independent of BLE + /// or auth state. Idempotent — safe to call repeatedly (removes existing + /// source/layers first). + Future _refreshRegionBorders(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // Remove existing layers (label, line) and source. Order matters: layers + // reference the source, so they must go first. Each try/catch tolerates + // a missing layer on the first call. + try { + await _mapController!.removeLayer(_regionBorderLabelLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_regionBorderLineLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_regionBorderSourceId); + } catch (_) {} + + final borders = appState.regionBorders; + if (borders.isEmpty) return; + + // Build a FeatureCollection. API sends `[lat, lon]` pairs; GeoJSON wants + // `[lon, lat]` — flip during conversion. Polygon rings must be closed, + // so append the first point if the last doesn't already match. + final features = >[]; + for (final entry in borders) { + final code = entry['code']?.toString() ?? ''; + final raw = entry['polygon']; + if (raw is! List || raw.length < 3) continue; + + final ring = >[]; + for (final pt in raw) { + if (pt is! List || pt.length < 2) continue; + final lat = (pt[0] as num).toDouble(); + final lon = (pt[1] as num).toDouble(); + ring.add([lon, lat]); + } + if (ring.length < 3) continue; + if (ring.first[0] != ring.last[0] || ring.first[1] != ring.last[1]) { + ring.add([ring.first[0], ring.first[1]]); + } + + features.add({ + 'type': 'Feature', + 'properties': { + 'iata': code, + 'label': '$code BOUNDARY', + }, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } + + if (features.isEmpty) return; + + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _regionBorderSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Render beneath the repeater cluster so repeaters stay tappable on top. + // Fallback gracefully if cluster layer isn't ready yet. + final belowLayer = + _clusterLayersReady ? _repeaterClusterBubbleLayerId : null; + + await _mapController!.addLineLayer( + _regionBorderSourceId, + _regionBorderLineLayerId, + const LineLayerProperties( + lineColor: '#FF6A00', + lineOpacity: 0.9, + lineWidth: 3.0, + lineCap: 'round', + lineJoin: 'round', + ), + belowLayerId: belowLayer, + ); + + await _mapController!.addSymbolLayer( + _regionBorderSourceId, + _regionBorderLabelLayerId, + const SymbolLayerProperties( + symbolPlacement: 'line', + textField: ['get', 'label'], + textSize: 12, + textColor: '#FF6A00', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textKeepUpright: true, + textFont: _defaultFontStack, + ), + minzoom: 13, + belowLayerId: belowLayer, + ); + } catch (e) { + debugError('[MAP] Failed to add region border layer: $e'); + } + } + + /// Shows the dialog explaining how to expand the regional boundary. + void _showBorderInfoDialog() { + if (!mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Region Boundary'), + content: const Text( + 'To expand the boundary, talk to your MeshMapper regional admin.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + /// Diff-syncs the distance label symbols shown in focus mode. Each label is /// a bitmap pill (white text on a dark rounded rectangle background, baked /// into an addImage icon) placed at the midpoint of the ping→repeater line. From 8b0fe334a55f43666cb69b942e5bdd5d086df09a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 19 Apr 2026 22:13:39 -0400 Subject: [PATCH 078/360] Modifications to offline maps and cache management --- android/app/build.gradle.kts | 6 + .../kotlin/net/meshmapper/app/MainActivity.kt | 42 ++- ios/Runner/AppDelegate.swift | 138 ++++++++++ lib/screens/offline_maps_screen.dart | 255 +++++++++++++++++- lib/screens/settings_screen.dart | 153 +++++------ lib/services/offline_map_service.dart | 44 ++- lib/services/tile_cache_service.dart | 54 ++++ lib/widgets/map_widget.dart | 23 +- 8 files changed, 614 insertions(+), 101 deletions(-) create mode 100644 lib/services/tile_cache_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d7c6a8db..4f06511a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -71,4 +71,10 @@ flutter { dependencies { // Required for flutter_local_notifications core library desugaring coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + + // MapLibre Android SDK — already pulled transitively by maplibre_gl, but + // declaring it explicitly gives MainActivity.kt compile-time access to + // OfflineManager for the tile cache MethodChannel handlers. Version must + // match maplibre_gl-0.25.0's transitive dep. + implementation("org.maplibre.gl:android-sdk:12.3.1") } diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index e7ff4c7c..36efd10c 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -1,5 +1,45 @@ package net.meshmapper.app import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import org.maplibre.android.offline.OfflineManager +import java.io.File -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS + // implementation. Called from Dart's TileCacheService by the Offline + // Maps screen's Tile Cache card. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "meshmapper/tile_cache") + .setMethodCallHandler { call, result -> + when (call.method) { + "getCacheSize" -> { + val dbFile = File(applicationContext.getDatabasePath("mbgl-offline.db").absolutePath) + result.success(if (dbFile.exists()) dbFile.length() else 0L) + } + "clearAmbientCache" -> { + OfflineManager.getInstance(applicationContext).clearAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() = result.success(null) + override fun onError(message: String) = + result.error("clear_failed", message, null) + } + ) + } + "invalidateAmbientCache" -> { + OfflineManager.getInstance(applicationContext).invalidateAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() = result.success(null) + override fun onError(message: String) = + result.error("invalidate_failed", message, null) + } + ) + } + else -> result.notImplemented() + } + } + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 05044953..a5066af2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,9 +1,65 @@ import Flutter +import MapLibre import UIKit import flutter_background_service_ios +/// URLProtocol that fails fast for MapLibre tile/style/glyph/sprite requests +/// while iOS offline-map mode is engaged. MapLibre-iOS reacts to the failure +/// by serving whatever it has in its internal tile cache (downloaded offline +/// regions + opportunistically cached tiles) and renders everything else as +/// the style's background layer, which matches Android's setOffline(true) +/// behavior closely enough for the "Use Downloaded Tiles Only" toggle. +/// +/// Hosts are kept in sync with the map style URLs in map_widget.dart's +/// MapStyleExtension.styleUrl (tiles.openfreemap.org) and the inline satellite +/// style JSON (server.arcgisonline.com). The meshmapper.net coverage overlay +/// isn't listed because _addCoverageOverlay already short-circuits when the +/// preference is off — no network requests are made in that state. +class TileBlockingURLProtocol: URLProtocol { + static let blockedHosts: Set = [ + "tiles.openfreemap.org", + "server.arcgisonline.com", + ] + + override class func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return blockedHosts.contains(host) + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + let error = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNotConnectedToInternet, + userInfo: [NSLocalizedDescriptionKey: "Offline map mode active"] + ) + client?.urlProtocol(self, didFailWithError: error) + } + + override func stopLoading() {} +} + +class IOSMapOfflineBridge { + private var registered = false + + func setOffline(_ offline: Bool) { + if offline, !registered { + URLProtocol.registerClass(TileBlockingURLProtocol.self) + registered = true + } else if !offline, registered { + URLProtocol.unregisterClass(TileBlockingURLProtocol.self) + registered = false + } + } +} + @main @objc class AppDelegate: FlutterAppDelegate { + private let mapOfflineBridge = IOSMapOfflineBridge() + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -19,6 +75,88 @@ import flutter_background_service_ios // Register background service SwiftFlutterBackgroundServicePlugin.taskIdentifier = "net.meshmapper.app.background" + if let controller = window?.rootViewController as? FlutterViewController { + // Method channel: iOS map offline mode bridge. Dart calls + // `setOffline` from map_widget.dart to toggle the URLProtocol + // interceptor that forces tile requests to fail fast, letting + // MapLibre-iOS serve only cached/downloaded tiles. + let offlineChannel = FlutterMethodChannel( + name: "meshmapper/ios_map_offline", + binaryMessenger: controller.binaryMessenger + ) + offlineChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + switch call.method { + case "setOffline": + let args = call.arguments as? [String: Any] + let offline = args?["offline"] as? Bool ?? false + self.mapOfflineBridge.setOffline(offline) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + // 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. + let tileCacheChannel = FlutterMethodChannel( + name: "meshmapper/tile_cache", + binaryMessenger: controller.binaryMessenger + ) + tileCacheChannel.setMethodCallHandler { call, result in + switch call.method { + case "getCacheSize": + result(AppDelegate.mapCacheSizeBytes()) + case "clearAmbientCache": + MLNOfflineStorage.shared.clearAmbientCache { error in + if let error = error { + result(FlutterError( + code: "clear_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + case "invalidateAmbientCache": + MLNOfflineStorage.shared.invalidateAmbientCache { error in + if let error = error { + result(FlutterError( + code: "invalidate_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + default: + result(FlutterMethodNotImplemented) + } + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + /// Mirrors `MapLibreMapsPlugin.getTilesUrl()`: the maplibre_gl iOS plugin + /// stores its tile cache at `//.mapbox/cache.db`. + /// Returns 0 if the file doesn't exist. + private static func mapCacheSizeBytes() -> Int64 { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first, + let bundleId = Bundle.main.bundleIdentifier else { + return 0 + } + let cacheUrl = appSupport + .appendingPathComponent(bundleId) + .appendingPathComponent(".mapbox") + .appendingPathComponent("cache.db") + let attrs = try? FileManager.default.attributesOfItem(atPath: cacheUrl.path) + return (attrs?[.size] as? Int64) ?? 0 + } } diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 6cf329a1..7ef471fd 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:math' show Point, max, min; import 'package:flutter/foundation.dart' show kIsWeb; @@ -7,6 +8,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/offline_map_service.dart'; +import '../services/tile_cache_service.dart'; import '../utils/debug_logger_io.dart'; import '../widgets/app_toast.dart'; import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; @@ -31,12 +33,16 @@ class OfflineMapsScreen extends StatefulWidget { } class _OfflineMapsScreenState extends State { + int? _tileCacheBytes; + bool _tileCacheBusy = false; + @override void initState() { super.initState(); // Listen for background download completions to show a toast. final service = context.read(); service.addListener(_onServiceUpdate); + _refreshTileCacheSize(); } @override @@ -48,6 +54,12 @@ class _OfflineMapsScreenState extends State { super.dispose(); } + Future _refreshTileCacheSize() async { + final bytes = await TileCacheService.instance.getCacheSizeBytes(); + if (!mounted) return; + setState(() => _tileCacheBytes = bytes); + } + void _onServiceUpdate() { if (!mounted) return; final service = context.read(); @@ -77,6 +89,8 @@ class _OfflineMapsScreenState extends State { children: [ _buildStorageCard(context, service, theme, isDark), const SizedBox(height: 8), + _buildTileCacheCard(theme), + const SizedBox(height: 8), _buildDownloadedRegionsCard( context, service, theme, isDark), const SizedBox(height: 8), @@ -226,6 +240,164 @@ class _OfflineMapsScreenState extends State { ); } + // ────────────────────────────────────────────── + // Tile cache card (ambient cache — opportunistically cached tiles) + // ────────────────────────────────────────────── + + Widget _buildTileCacheCard(ThemeData theme) { + final bytes = _tileCacheBytes; + final sizeDisplay = bytes == null + ? '—' + : bytes < 1024 + ? '$bytes B' + : bytes < 1024 * 1024 + ? '${(bytes / 1024).toStringAsFixed(1)} KB' + : '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Tile Cache', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: _tileCacheBusy ? null : _refreshTileCacheSize, + tooltip: 'Refresh size', + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 4), + Text( + 'On-disk size: $sizeDisplay', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + 'Includes both downloaded regions and opportunistically cached ' + 'tiles from normal map panning.', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _tileCacheBusy ? null : _onInvalidateTileCache, + icon: const Icon(Icons.refresh_outlined, size: 18), + label: const Text('Invalidate'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _tileCacheBusy ? null : _onClearTileCache, + icon: const Icon(Icons.delete_sweep_outlined, size: 18), + label: const Text('Clear'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _onInvalidateTileCache() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Invalidate tile cache?'), + content: const Text( + 'Marks cached tiles as stale so they refresh on next view. ' + 'Downloaded regions are not affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Invalidate'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _tileCacheBusy = true); + try { + await TileCacheService.instance.invalidateAmbientCache(); + if (!mounted) return; + AppToast.success(context, 'Tile cache invalidated'); + } catch (e) { + if (!mounted) return; + AppToast.error(context, 'Invalidate failed: $e'); + } finally { + if (mounted) setState(() => _tileCacheBusy = false); + await _refreshTileCacheSize(); + } + } + + Future _onClearTileCache() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear tile cache?'), + content: const Text( + 'Removes opportunistically cached tiles from disk. ' + 'Downloaded regions are preserved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.red), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _tileCacheBusy = true); + try { + await TileCacheService.instance.clearAmbientCache(); + if (!mounted) return; + AppToast.success(context, 'Tile cache cleared'); + } catch (e) { + if (!mounted) return; + AppToast.error(context, 'Clear failed: $e'); + } finally { + if (mounted) setState(() => _tileCacheBusy = false); + await _refreshTileCacheSize(); + } + } + // ────────────────────────────────────────────── // Downloaded regions list // ────────────────────────────────────────────── @@ -316,6 +488,19 @@ class _OfflineMapsScreenState extends State { Widget _buildDownloadProgressCard(BuildContext context, OfflineMapService service, ThemeData theme, bool isDark) { + final progress = service.downloadProgress ?? 0; + final isPreparing = progress == 0; + final speed = service.downloadBytesPerSecond; + final eta = service.downloadEta; + + String? stats; + if (!isPreparing) { + final parts = []; + if (speed != null) parts.add(_formatSpeed(speed)); + if (eta != null) parts.add('${_formatDuration(eta)} remaining'); + if (parts.isNotEmpty) stats = parts.join(' · '); + } + return Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, @@ -354,7 +539,7 @@ class _OfflineMapsScreenState extends State { ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: service.downloadProgress ?? 0, + value: isPreparing ? null : progress, minHeight: 8, ), ), @@ -363,14 +548,24 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(width: 12), Text( - '${((service.downloadProgress ?? 0) * 100).round()}%', + isPreparing ? '—' : '${(progress * 100).round()}%', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 6), + Text( + isPreparing + ? 'Preparing download…' + : (stats ?? 'Calculating speed…'), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + const SizedBox(height: 6), Text( 'Download continues in the background if you leave this screen', style: TextStyle(fontSize: 11, color: Colors.grey.shade500), @@ -406,6 +601,30 @@ class _OfflineMapsScreenState extends State { ); } + /// Formats bytes/sec into a short display string (e.g. "1.2 MB/s"). + String _formatSpeed(double bytesPerSecond) { + if (bytesPerSecond < 1024) { + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; + } + if (bytesPerSecond < 1024 * 1024) { + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + } + return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + } + + /// Formats a Duration as a short human-readable string (e.g. "4m 20s"). + String _formatDuration(Duration d) { + if (d.inSeconds < 60) return '${d.inSeconds}s'; + if (d.inMinutes < 60) { + final s = d.inSeconds % 60; + return s == 0 ? '${d.inMinutes}m' : '${d.inMinutes}m ${s}s'; + } + final minutes = d.inMinutes % 60; + return minutes == 0 + ? '${d.inHours}h' + : '${d.inHours}h ${minutes}m'; + } + Future _confirmCancelDownload( BuildContext context, OfflineMapService service) async { final name = service.downloadingRegionName ?? 'this download'; @@ -1375,30 +1594,42 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _error = null; }); - // Await the service call. downloadRegion returns once the native - // downloader has accepted (or rejected) the job — actual tile fetches - // continue in the background after this returns. If validation fails - // (quota, free-space, style, antimeridian, etc.), lastError is set and - // isDownloading is false; otherwise the download is queued. - await service.downloadRegion( + // Kick off the download without awaiting the completer — that future + // resolves only on full completion (minutes later). We just need the + // download to reach the "active" state so we can pop back to the main + // screen where the progress card is visible. + unawaited(service.downloadRegion( name: name, bounds: bounds, styleUrl: styleUrl, styleName: _selectedStyle, minZoom: _minZoom, maxZoom: _maxZoom, - ); + )); + + // Wait briefly for the service to enter `isDownloading` or surface an + // early validation error (free-space, quota). The pre-download async + // checks complete in well under a second; cap at 2s to stay responsive. + final stopwatch = Stopwatch()..start(); + while (mounted && + stopwatch.elapsed < const Duration(seconds: 2) && + !service.isDownloading && + service.lastError == null) { + await Future.delayed(const Duration(milliseconds: 100)); + } if (!mounted) return; - if (!service.isDownloading && service.lastError != null) { + if (service.lastError != null) { setState(() { _submitting = false; _error = service.consumeLastError(); }); return; } - // Download is queued — return to the management screen. + + // Either actively downloading, still starting, or queued behind another — + // in every case the user should see the main screen's progress card. Navigator.pop(context, true); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a7a29093..3a8326a9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -155,35 +155,61 @@ class _SettingsScreenState extends State { }, ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), - Builder(builder: (_) { - // iOS plugin (maplibre_gl 0.25.0) doesn't implement setOffline, - // and MapLibre-iOS has no public equivalent. Disable the toggle - // there so it doesn't lie to the user. - final iosUnsupported = !kIsWeb && - defaultTargetPlatform == TargetPlatform.iOS; - return SwitchListTile( - secondary: Icon( - prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), - title: const Text('Disable Map Tiles'), - subtitle: iosUnsupported - ? const Text('Not available on iOS') - : Text(prefs.mapTilesEnabled - ? 'Map and coverage tiles load normally' - : 'Network tiles disabled · downloaded regions still visible'), - value: !prefs.mapTilesEnabled, - onChanged: iosUnsupported - ? null - : (value) { - appState.updatePreferences( - prefs.copyWith(mapTilesEnabled: !value)); - }, - ); - }), + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + value: prefs.isImperial, + onChanged: (isImperial) { + appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); + }, + ), + SwitchListTile( + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), + ], + ]), + + // Map Management + _buildSection(context, 'Map Management', [ if (!kIsWeb) ListTile( leading: const Icon(Icons.download_for_offline), title: const Text('Offline Maps'), - subtitle: const Text('Download map tiles for offline use'), + subtitle: const Text('Download map regions for offline use'), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -194,6 +220,19 @@ class _SettingsScreenState extends State { ); }, ), + SwitchListTile( + secondary: Icon( + prefs.mapTilesEnabled ? Icons.cloud : Icons.cloud_off), + title: const Text('Use Downloaded Tiles Only'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Online tiles load normally' + : 'Only downloaded regions are shown · no network tile requests'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState.updatePreferences( + prefs.copyWith(mapTilesEnabled: !value)); + }, + ), if (prefs.mapTilesEnabled) ListTile( leading: const Icon(Icons.opacity), @@ -217,29 +256,6 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showColorVisionSelector(context, appState), ), - SwitchListTile( - secondary: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text( - prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - value: prefs.isImperial, - onChanged: (isImperial) { - appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); - }, - ), - SwitchListTile( - secondary: const Icon(Icons.cell_tower), - title: const Text('Top Repeaters on Map'), - subtitle: - const Text('Show top 3 repeaters by SNR from last ping'), - value: prefs.showTopRepeaters, - onChanged: (value) { - appState - .updatePreferences(prefs.copyWith(showTopRepeaters: value)); - }, - ), ListTile( leading: const Icon(Icons.place), title: const Text('Map Marker Style'), @@ -255,39 +271,16 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon( - appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text( - appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, ), - if (appState.isSoundEnabled) ...[ - SwitchListTile( - secondary: const SizedBox(width: 24), - title: const Text('Ping Sent'), - subtitle: const Text('Sound when TX ping or discovery is sent'), - value: appState.isTxSoundEnabled, - onChanged: (value) => appState.setTxSoundEnabled(value), - ), - SwitchListTile( - secondary: const SizedBox(width: 24), - title: const Text('Response Received'), - subtitle: - const Text('Sound when repeater echo or RX is received'), - value: appState.isRxSoundEnabled, - onChanged: (value) => appState.setRxSoundEnabled(value), - ), - SwitchListTile( - secondary: const SizedBox(width: 24), - title: const Text('Disconnect Alert'), - subtitle: - const Text('Triple beep when pinging stops unexpectedly'), - value: appState.isDisconnectAlertEnabled, - onChanged: (value) => appState.setDisconnectAlertEnabled(value), - ), - ], ]), // Ping Settings diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index e14d8160..130b5dac 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -176,6 +176,38 @@ class OfflineMapService extends ChangeNotifier { /// quota pre-check so queued jobs can't jointly bust the limit. int _activeEstBytes = 0; + /// Rolling window of recent (timestamp, progress 0-1) samples used to + /// compute smoothed download speed and ETA. Oldest-first. Cleared on + /// download start/end/cancel. + final List<({DateTime at, double progress})> _progressSamples = []; + static const int _progressWindowSize = 5; + + /// Instantaneous download speed in bytes/sec, computed over the last few + /// progress events. Null until at least two samples are available. + double? get downloadBytesPerSecond { + if (_progressSamples.length < 2 || _activeEstBytes <= 0) return null; + final first = _progressSamples.first; + final last = _progressSamples.last; + final seconds = last.at.difference(first.at).inMilliseconds / 1000.0; + if (seconds <= 0) return null; + final deltaBytes = (last.progress - first.progress) * _activeEstBytes; + if (deltaBytes <= 0) return null; + return deltaBytes / seconds; + } + + /// Estimated time remaining based on the current smoothed speed. Null if + /// no speed data yet, or if the download is effectively stalled. + /// Capped at 99 minutes to keep the display sane during warm-up wobbles. + Duration? get downloadEta { + final bps = downloadBytesPerSecond; + final progress = _downloadProgress; + if (bps == null || bps <= 0 || progress == null) return null; + final remainingBytes = (1.0 - progress) * _activeEstBytes; + if (remainingBytes <= 0) return Duration.zero; + final seconds = (remainingBytes / bps).round(); + return Duration(seconds: seconds.clamp(0, 99 * 60)); + } + /// FIFO queue of pending downloads. Drained one at a time after the /// current download finishes (or is cancelled). final List<_QueuedDownload> _queue = []; @@ -516,6 +548,7 @@ class OfflineMapService extends ChangeNotifier { _downloadingRegionName = job.name; _activeCompleter = job.completer; _activeEstBytes = job.estBytes; + _progressSamples.clear(); _lastError = null; _lastCompletedName = null; notifyListeners(); @@ -560,6 +593,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); _lastError = 'Download failed (${e.runtimeType}): $e'; notifyListeners(); _showErrorNotification(job.name); @@ -586,6 +620,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); _lastCompletedName = name; notifyListeners(); _showCompleteNotification(name); @@ -607,7 +642,12 @@ class OfflineMapService extends ChangeNotifier { _drainQueue(); }); } else if (status is InProgress) { - _downloadProgress = status.progress / 100.0; + final progress = status.progress / 100.0; + _downloadProgress = progress; + _progressSamples.add((at: DateTime.now(), progress: progress)); + if (_progressSamples.length > _progressWindowSize) { + _progressSamples.removeAt(0); + } notifyListeners(); // Throttle notification updates to every 2% to avoid flooding final percent = status.progress.round(); @@ -645,6 +685,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); _lastError = 'Download failed: $detail'; _showErrorNotification(name); notifyListeners(); @@ -682,6 +723,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); await _dismissProgressNotification(); notifyListeners(); diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart new file mode 100644 index 00000000..c5c13014 --- /dev/null +++ b/lib/services/tile_cache_service.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; + +import '../utils/debug_logger_io.dart'; + +/// Manages MapLibre's ambient tile cache — tiles opportunistically stored +/// during normal map use, separate from the explicitly downloaded offline +/// regions tracked by [OfflineMapService]. +/// +/// Routed through a platform MethodChannel on `meshmapper/tile_cache`: +/// - iOS handler: AppDelegate.swift (uses `MLNOfflineStorage.shared`) +/// - Android handler: MainActivity.kt (uses `OfflineManager.getInstance(ctx)`) +/// +/// Both handlers operate on MapLibre's global offline storage singleton, so +/// they don't depend on any map widget being alive. +/// +/// Size reporting reads the underlying cache database file directly. It +/// includes BOTH ambient cache and downloaded regions — MapLibre stores them +/// in the same SQLite file. +class TileCacheService { + TileCacheService._(); + static final TileCacheService instance = TileCacheService._(); + + static const _channel = MethodChannel('meshmapper/tile_cache'); + + /// Total size in bytes of the MapLibre cache database. Includes both + /// ambient cache and downloaded offline regions. Returns 0 on web or on + /// unexpected channel errors. + Future getCacheSizeBytes() async { + if (kIsWeb) return 0; + try { + final raw = await _channel.invokeMethod('getCacheSize'); + // iOS returns Int64 → Dart int; Android returns Long → Dart int. + if (raw is int) return raw; + if (raw is num) return raw.toInt(); + return 0; + } catch (e) { + debugWarn('[OFFLINE_MAP] Cache size read failed: $e'); + return 0; + } + } + + Future clearAmbientCache() async { + if (kIsWeb) throw UnsupportedError('Not supported on web'); + await _channel.invokeMethod('clearAmbientCache'); + debugLog('[OFFLINE_MAP] Ambient cache cleared'); + } + + Future invalidateAmbientCache() async { + if (kIsWeb) throw UnsupportedError('Not supported on web'); + await _channel.invokeMethod('invalidateAmbientCache'); + debugLog('[OFFLINE_MAP] Ambient cache invalidated'); + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 4485c366..1c61b9c3 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -6,6 +6,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show MethodChannel; import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; @@ -1103,16 +1104,24 @@ class _MapWidgetState extends State { ); } + /// Method channel to the iOS native bridge (AppDelegate.swift). iOS + /// maplibre_gl 0.25.0 has no `setOffline` implementation, so we ship our + /// own: a URLProtocol that fails tile requests fast while offline mode is + /// engaged, letting MapLibre-iOS render only its cached tiles. + static const _iosOfflineChannel = + MethodChannel('meshmapper/ios_map_offline'); + /// Toggle MapLibre between online (network tiles) and offline (cache-only). - /// - /// No-op on iOS: maplibre_gl 0.25.0 never implemented the `setOffline` - /// method handler on iOS, and MapLibre-iOS itself doesn't expose an - /// equivalent of Android's `ConnectivityReceiver` hook. The "Disable Map - /// Tiles" Settings switch is marked unavailable on iOS for the same reason. + /// Android uses the plugin's native `setOffline`; iOS uses our bridge. Future _setOfflineIfSupported(bool offline) async { - if (kIsWeb || Platform.isIOS) return; + if (kIsWeb) return; try { - await setOffline(offline); + if (Platform.isIOS) { + await _iosOfflineChannel + .invokeMethod('setOffline', {'offline': offline}); + } else { + await setOffline(offline); + } debugLog('[MAP] setOffline($offline) — ' 'tiles ${offline ? "cache-only" : "enabled"}'); } catch (e) { From 3e5242fdc412c722bc1c2bca89404078cbb7ef41 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 20 Apr 2026 19:14:08 -0400 Subject: [PATCH 079/360] feat(offline-maps): update offline region management and cache handling - Bump version to 1.3.0 in .build_version. - Modify Build.sh to include export-options-plist for iOS IPA builds. - Enhance MainActivity.kt to support fetching per-region downloaded byte counts. - Update AppDelegate.swift to handle region sizes and improve cache management. - Refactor offline_maps_screen.dart to refresh region sizes and update UI accordingly. - Adjust settings_screen.dart to reflect changes in terminology from "regions" to "areas". - Improve offline_map_service.dart to track actual bytes used by downloaded areas. - Add tile_cache_service.dart method to retrieve per-region sizes from the native SDK. - Modify map_widget.dart to manage coverage overlay based on tile availability. - Create ExportOptions.plist for iOS export settings. --- .build_version | 2 +- Build.sh | 2 +- .../kotlin/net/meshmapper/app/MainActivity.kt | 48 +++ ios/ExportOptions.plist | 18 ++ ios/Runner/AppDelegate.swift | 47 ++- lib/screens/offline_maps_screen.dart | 285 ++++++++++-------- lib/screens/settings_screen.dart | 4 +- lib/services/offline_map_service.dart | 167 ++++++++-- lib/services/tile_cache_service.dart | 22 ++ lib/widgets/map_widget.dart | 17 +- 10 files changed, 448 insertions(+), 164 deletions(-) create mode 100644 ios/ExportOptions.plist diff --git a/.build_version b/.build_version index 23aa8390..f0bb29e7 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.2.2 +1.3.0 diff --git a/Build.sh b/Build.sh index f6828162..3cf94759 100755 --- a/Build.sh +++ b/Build.sh @@ -154,7 +154,7 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." (cd ios && pod install) -flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" +flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" --export-options-plist=ios/ExportOptions.plist cp build/ios/ipa/mesh_mapper.ipa "$IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "✓ Built: MeshMapper-$FILE_TAG.ipa" echo "" diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index 36efd10c..d529fc65 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -4,6 +4,8 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import org.maplibre.android.offline.OfflineManager +import org.maplibre.android.offline.OfflineRegion +import org.maplibre.android.offline.OfflineRegionStatus import java.io.File class MainActivity : FlutterActivity() { @@ -20,6 +22,9 @@ class MainActivity : FlutterActivity() { val dbFile = File(applicationContext.getDatabasePath("mbgl-offline.db").absolutePath) result.success(if (dbFile.exists()) dbFile.length() else 0L) } + "getRegionSizes" -> { + getRegionSizes(result) + } "clearAmbientCache" -> { OfflineManager.getInstance(applicationContext).clearAmbientCache( object : OfflineManager.FileSourceCallback { @@ -42,4 +47,47 @@ class MainActivity : FlutterActivity() { } } } + + /// Per-region downloaded byte counts, keyed by OfflineRegion.getID() which + /// is the same long ID the maplibre_gl plugin returns to Dart. Reads each + /// region's completedResourceSize (the tile+resource byte total for that + /// region as tracked by the SDK). Responds on the main thread with a + /// Map over the platform channel. + private fun getRegionSizes(result: MethodChannel.Result) { + OfflineManager.getInstance(applicationContext).listOfflineRegions( + object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(regions: Array?) { + val list = regions ?: emptyArray() + if (list.isEmpty()) { + result.success(HashMap()) + return + } + val sizes = HashMap() + var remaining = list.size + for (region in list) { + region.getStatus(object : OfflineRegion.OfflineRegionStatusCallback { + override fun onStatus(status: OfflineRegionStatus?) { + if (status != null) { + sizes[region.id] = status.completedResourceSize + } + remaining -= 1 + if (remaining == 0) { + result.success(sizes) + } + } + override fun onError(error: String?) { + remaining -= 1 + if (remaining == 0) { + result.success(sizes) + } + } + }) + } + } + override fun onError(error: String) { + result.error("region_sizes_failed", error, null) + } + } + ) + } } diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 00000000..eb3a79ed --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + app-store-connect + teamID + DQC6TNKG5P + signingStyle + automatic + stripSwiftSymbols + + uploadSymbols + + destination + export + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a5066af2..34f47997 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -12,18 +12,26 @@ import flutter_background_service_ios /// /// Hosts are kept in sync with the map style URLs in map_widget.dart's /// MapStyleExtension.styleUrl (tiles.openfreemap.org) and the inline satellite -/// style JSON (server.arcgisonline.com). The meshmapper.net coverage overlay -/// isn't listed because _addCoverageOverlay already short-circuits when the -/// preference is off — no network requests are made in that state. +/// style JSON (server.arcgisonline.com). The coverage overlay uses per-zone +/// subdomains of meshmapper.net (e.g. `on.meshmapper.net`, `qc.meshmapper.net`) +/// so it's matched by suffix rather than an exact host entry. The Dart-side +/// add-time guard in _addCoverageOverlay covers the "toggle on at startup" +/// case; this suffix match covers the "toggle flipped while overlay is +/// already on the map" case (the Dart code doesn't remove the layer on flip). class TileBlockingURLProtocol: URLProtocol { static let blockedHosts: Set = [ "tiles.openfreemap.org", "server.arcgisonline.com", ] + static let blockedHostSuffixes: [String] = [ + ".meshmapper.net", + ] + override class func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } - return blockedHosts.contains(host) + if blockedHosts.contains(host) { return true } + return blockedHostSuffixes.contains { host.hasSuffix($0) } } override class func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -111,6 +119,8 @@ class IOSMapOfflineBridge { switch call.method { case "getCacheSize": result(AppDelegate.mapCacheSizeBytes()) + case "getRegionSizes": + AppDelegate.regionSizes(result: result) case "clearAmbientCache": MLNOfflineStorage.shared.clearAmbientCache { error in if let error = error { @@ -142,6 +152,35 @@ class IOSMapOfflineBridge { return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + /// Per-region downloaded byte counts, keyed by the Dart-side region ID that + /// maplibre_gl embeds in each pack's context JSON (`{"id": Int, "metadata": …}` + /// — see OfflineRegion.swift in the maplibre_gl plugin). Reads + /// `pack.progress.countOfTileBytesCompleted`, which is the byte size of tile + /// resources (style/sprite/glyph resources live outside this number but are + /// included in the overall cache.db file total reported by `getCacheSize`). + /// + /// Returns `{ idInt64 : bytesInt64 }` over the platform channel. Packs whose + /// context can't be decoded are skipped. + private static func regionSizes(result: @escaping FlutterResult) { + // `.packs` is nil until MLNOfflineStorage finishes its initial database + // load. In practice the maplibre_gl plugin forces that load early, but we + // return an empty map rather than failing if we're called first. + guard let packs = MLNOfflineStorage.shared.packs else { + result([Int64: Int64]()) + return + } + var sizes: [Int64: Int64] = [:] + for pack in packs { + guard let ctx = try? JSONSerialization.jsonObject(with: pack.context), + let dict = ctx as? [String: Any], + let id = dict["id"] as? Int else { + continue + } + sizes[Int64(id)] = Int64(pack.progress.countOfTileBytesCompleted) + } + result(sizes) + } + /// Mirrors `MapLibreMapsPlugin.getTilesUrl()`: the maplibre_gl iOS plugin /// stores its tile cache at `//.mapbox/cache.db`. /// Returns 0 if the file doesn't exist. diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 7ef471fd..4173d44d 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -33,7 +33,6 @@ class OfflineMapsScreen extends StatefulWidget { } class _OfflineMapsScreenState extends State { - int? _tileCacheBytes; bool _tileCacheBusy = false; @override @@ -42,7 +41,10 @@ class _OfflineMapsScreenState extends State { // Listen for background download completions to show a toast. final service = context.read(); service.addListener(_onServiceUpdate); - _refreshTileCacheSize(); + // Pull fresh per-region + total cache sizes so the storage bar reflects + // current on-disk reality (including any ambient-cache growth since the + // service last refreshed). + service.refreshRegions(); } @override @@ -54,12 +56,6 @@ class _OfflineMapsScreenState extends State { super.dispose(); } - Future _refreshTileCacheSize() async { - final bytes = await TileCacheService.instance.getCacheSizeBytes(); - if (!mounted) return; - setState(() => _tileCacheBytes = bytes); - } - void _onServiceUpdate() { if (!mounted) return; final service = context.read(); @@ -89,8 +85,6 @@ class _OfflineMapsScreenState extends State { children: [ _buildStorageCard(context, service, theme, isDark), const SizedBox(height: 8), - _buildTileCacheCard(theme), - const SizedBox(height: 8), _buildDownloadedRegionsCard( context, service, theme, isDark), const SizedBox(height: 8), @@ -105,7 +99,7 @@ class _OfflineMapsScreenState extends State { heroTag: null, onPressed: () => _showDownloadDialog(context), icon: const Icon(Icons.download), - label: const Text('Download Region'), + label: const Text('Download Area'), ) : null, ); @@ -128,7 +122,7 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Use the mobile app to download map regions for offline use', + 'Use the mobile app to download map areas for offline use', style: theme.textTheme.bodySmall ?.copyWith(color: Colors.grey.shade400), textAlign: TextAlign.center, @@ -145,12 +139,23 @@ class _OfflineMapsScreenState extends State { Widget _buildStorageCard(BuildContext context, OfflineMapService service, ThemeData theme, bool isDark) { + final downloadsRatio = service.downloadsRatio; + final ambientRatio = service.ambientRatio; final usageRatio = service.usageRatio; - final barColor = usageRatio > 0.9 + + // Over-quota colors the total-used text red; the bar itself stays + // segmented so the user can still see which bucket is driving the limit. + final overQuota = usageRatio >= 1.0; + final totalColor = overQuota ? Colors.red - : usageRatio > 0.7 - ? Colors.orange - : theme.colorScheme.primary; + : (usageRatio > 0.9 + ? Colors.red + : (usageRatio > 0.7 ? Colors.orange : theme.colorScheme.primary)); + + final downloadsColor = theme.colorScheme.primary; + final ambientColor = theme.colorScheme.tertiary; + final trackColor = + isDark ? Colors.white.withValues(alpha: 0.08) : Colors.grey.shade200; return Card( margin: EdgeInsets.zero, @@ -171,6 +176,14 @@ class _OfflineMapsScreenState extends State { ), ), const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: _tileCacheBusy + ? null + : () => service.refreshRegions(), + tooltip: 'Refresh sizes', + visualDensity: VisualDensity.compact, + ), TextButton.icon( onPressed: () => _showStorageLimitDialog(context, service), icon: const Icon(Icons.tune, size: 16), @@ -184,28 +197,55 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 12), - // Usage bar + // Segmented usage bar: downloads (primary) + ambient (tertiary) + // stacked left-to-right against the storage limit. ClipRRect( borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: usageRatio, - minHeight: 20, - backgroundColor: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.grey.shade200, - color: barColor, + child: SizedBox( + height: 20, + child: LayoutBuilder( + builder: (context, constraints) { + final total = constraints.maxWidth; + final downloadsWidth = + (downloadsRatio.clamp(0.0, 1.0)) * total; + // Ambient is clamped so the two segments can never exceed + // the track width even if the real total briefly overshoots. + final ambientWidth = + ((ambientRatio.clamp(0.0, 1.0)) * total) + .clamp(0.0, total - downloadsWidth); + return Stack( + children: [ + Positioned.fill(child: Container(color: trackColor)), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: downloadsWidth, + child: Container(color: downloadsColor), + ), + Positioned( + left: downloadsWidth, + top: 0, + bottom: 0, + width: ambientWidth, + child: Container(color: ambientColor), + ), + ], + ); + }, + ), ), ), const SizedBox(height: 8), - // Usage text + // Totals Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '~${service.totalUsedDisplay} used (estimated)', + '${service.totalUsedDisplay} used', style: theme.textTheme.bodySmall?.copyWith( - color: barColor, + color: totalColor, fontWeight: FontWeight.w600, ), ), @@ -217,93 +257,36 @@ class _OfflineMapsScreenState extends State { ), ], ), - const SizedBox(height: 2), - Text( - 'Based on tile count heuristic; actual disk use may differ.', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey, - fontSize: 11, - fontStyle: FontStyle.italic, - ), + const SizedBox(height: 10), + + // Legend + _buildLegendRow( + theme, + color: downloadsColor, + label: 'Downloads', + value: service.downloadsDisplay, + sublabel: '${service.regions.length} ' + 'area${service.regions.length == 1 ? '' : 's'}', ), const SizedBox(height: 4), - Text( - '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey.shade500, - fontSize: 11, - ), + _buildLegendRow( + theme, + color: ambientColor, + label: 'Ambient cache', + value: service.ambientDisplay, + sublabel: 'auto-cached while panning', ), - ], - ), - ), - ); - } - - // ────────────────────────────────────────────── - // Tile cache card (ambient cache — opportunistically cached tiles) - // ────────────────────────────────────────────── - Widget _buildTileCacheCard(ThemeData theme) { - final bytes = _tileCacheBytes; - final sizeDisplay = bytes == null - ? '—' - : bytes < 1024 - ? '$bytes B' - : bytes < 1024 * 1024 - ? '${(bytes / 1024).toStringAsFixed(1)} KB' - : '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Tile Cache', - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.refresh, size: 20), - onPressed: _tileCacheBusy ? null : _refreshTileCacheSize, - tooltip: 'Refresh size', - visualDensity: VisualDensity.compact, - ), - ], - ), - const SizedBox(height: 4), - Text( - 'On-disk size: $sizeDisplay', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 4), - Text( - 'Includes both downloaded regions and opportunistically cached ' - 'tiles from normal map panning.', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - ), const SizedBox(height: 12), + + // Ambient cache actions (downloads are managed below). Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _tileCacheBusy ? null : _onInvalidateTileCache, icon: const Icon(Icons.refresh_outlined, size: 18), - label: const Text('Invalidate'), + label: const Text('Invalidate cache'), ), ), const SizedBox(width: 8), @@ -311,7 +294,7 @@ class _OfflineMapsScreenState extends State { child: OutlinedButton.icon( onPressed: _tileCacheBusy ? null : _onClearTileCache, icon: const Icon(Icons.delete_sweep_outlined, size: 18), - label: const Text('Clear'), + label: const Text('Clear cache'), style: OutlinedButton.styleFrom( foregroundColor: Colors.red, ), @@ -325,6 +308,51 @@ class _OfflineMapsScreenState extends State { ); } + Widget _buildLegendRow( + ThemeData theme, { + required Color color, + required String label, + required String value, + required String sublabel, + }) { + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '· $sublabel', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade500, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + value, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + Future _onInvalidateTileCache() async { final confirmed = await showDialog( context: context, @@ -332,7 +360,7 @@ class _OfflineMapsScreenState extends State { title: const Text('Invalidate tile cache?'), content: const Text( 'Marks cached tiles as stale so they refresh on next view. ' - 'Downloaded regions are not affected.', + 'Downloaded areas are not affected.', ), actions: [ TextButton( @@ -357,7 +385,7 @@ class _OfflineMapsScreenState extends State { AppToast.error(context, 'Invalidate failed: $e'); } finally { if (mounted) setState(() => _tileCacheBusy = false); - await _refreshTileCacheSize(); + if (mounted) await context.read().refreshCacheSize(); } } @@ -368,7 +396,7 @@ class _OfflineMapsScreenState extends State { title: const Text('Clear tile cache?'), content: const Text( 'Removes opportunistically cached tiles from disk. ' - 'Downloaded regions are preserved.', + 'Downloaded areas are preserved.', ), actions: [ TextButton( @@ -394,7 +422,7 @@ class _OfflineMapsScreenState extends State { AppToast.error(context, 'Clear failed: $e'); } finally { if (mounted) setState(() => _tileCacheBusy = false); - await _refreshTileCacheSize(); + if (mounted) await context.read().refreshCacheSize(); } } @@ -416,7 +444,7 @@ class _OfflineMapsScreenState extends State { child: Row( children: [ Text( - 'Downloaded Regions', + 'Downloaded Areas', style: theme.textTheme.titleSmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, @@ -442,12 +470,12 @@ class _OfflineMapsScreenState extends State { size: 48, color: Colors.grey.shade400), const SizedBox(height: 8), Text( - 'No offline regions downloaded', + 'No offline areas downloaded', style: TextStyle(color: Colors.grey.shade500), ), const SizedBox(height: 4), Text( - 'Tap "Download Region" to save map tiles for offline use', + 'Tap "Download Area" to save map tiles for offline use', style: TextStyle(fontSize: 12, color: Colors.grey.shade400), textAlign: TextAlign.center, ), @@ -531,7 +559,7 @@ class _OfflineMapsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - service.downloadingRegionName ?? 'Region', + service.downloadingRegionName ?? 'Area', style: theme.textTheme.bodyMedium, overflow: TextOverflow.ellipsis, ), @@ -709,7 +737,7 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 12), Text( - 'Currently using ~${service.totalUsedDisplay} (estimated)', + 'Currently using ${service.totalUsedDisplay}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), @@ -769,11 +797,11 @@ class _OfflineMapsScreenState extends State { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Delete Region?'), + title: const Text('Delete Area?'), content: Text( - 'Delete "${region.name}"? This will free approximately ' + 'Delete "${region.name}"? This will free ' '${region.sizeDisplay} of storage.\n\n' - 'Note: shared tiles used by other regions may not be freed immediately.', + 'Note: shared tiles used by other areas may not be freed immediately.', ), actions: [ TextButton( @@ -796,7 +824,7 @@ class _OfflineMapsScreenState extends State { AppToast.success(context, '"${region.name}" deleted'); } else { AppToast.error( - context, service.consumeLastError() ?? 'Failed to delete region'); + context, service.consumeLastError() ?? 'Failed to delete area'); } } } @@ -807,10 +835,11 @@ class _OfflineMapsScreenState extends State { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Delete All Regions?'), + title: const Text('Delete All Areas?'), content: Text( - 'Delete all ${service.regions.length} downloaded regions? ' - 'This will free approximately ${service.totalUsedDisplay}.', + 'Delete all ${service.regions.length} downloaded areas? ' + 'This will free ${service.downloadsDisplay} of downloaded tiles ' + '(ambient cache is preserved).', ), actions: [ TextButton( @@ -829,7 +858,7 @@ class _OfflineMapsScreenState extends State { if (confirmed == true) { await service.deleteAllRegions(); if (context.mounted) { - AppToast.success(context, 'All regions deleted'); + AppToast.success(context, 'All areas deleted'); } } } @@ -998,7 +1027,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: const Text('Download Region', style: TextStyle(fontSize: 18)), + title: const Text('Download Area', style: TextStyle(fontSize: 18)), ), body: Column( children: [ @@ -1040,7 +1069,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { child: Text( _selectedBounds != null ? 'Drag corners to resize · ~$_estimatedTiles tiles · $_estimatedSize' - : 'Tap the map to place a region', + : 'Tap the map to place an area', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, ), @@ -1126,7 +1155,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { TextField( controller: _nameController, decoration: InputDecoration( - labelText: 'Region Name', + labelText: 'Area Name', hintText: 'e.g. Downtown Vancouver', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8)), @@ -1236,7 +1265,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ) : const Icon(Icons.download), label: - Text(_submitting ? 'Starting...' : 'Download Region'), + Text(_submitting ? 'Starting...' : 'Download Area'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(44), ), @@ -1285,7 +1314,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { if ((ne.longitude - sw.longitude).abs() > 180) { setState(() { _error = 'Visible area spans more than half the globe. ' - 'Zoom in before placing a region.'; + 'Zoom in before placing an area.'; }); return; } @@ -1556,8 +1585,8 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _boundsNE = ne; if ((ne.longitude - sw.longitude).abs() > 180) { - _error = 'Selected region crosses the antimeridian. ' - 'Split into two regions (one per hemisphere).'; + _error = 'Selected area crosses the antimeridian. ' + 'Split into two areas (one per hemisphere).'; } else { _error = null; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3a8326a9..76b9f510 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -209,7 +209,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.download_for_offline), title: const Text('Offline Maps'), - subtitle: const Text('Download map regions for offline use'), + subtitle: const Text('Download map areas for offline use'), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -226,7 +226,7 @@ class _SettingsScreenState extends State { title: const Text('Use Downloaded Tiles Only'), subtitle: Text(prefs.mapTilesEnabled ? 'Online tiles load normally' - : 'Only downloaded regions are shown · no network tile requests'), + : 'Only downloaded areas are shown · no network tile requests'), value: !prefs.mapTilesEnabled, onChanged: (value) { appState.updatePreferences( diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 130b5dac..c5f0e753 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:disk_space_plus/disk_space_plus.dart'; import 'package:flutter/foundation.dart'; @@ -7,6 +8,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/debug_logger_io.dart'; +import 'tile_cache_service.dart'; /// A pending download waiting in the FIFO queue. class _QueuedDownload { @@ -51,8 +53,17 @@ class OfflineMapRegion { final double minZoom; final double maxZoom; final DateTime createdAt; + + /// Heuristic size from tile count × 20 KB, captured at download time. + /// Used as a fallback when the native SDK hasn't reported actual bytes yet. final int estimatedBytes; + /// Real bytes consumed by this region as reported by the native MapLibre + /// SDK (`MLNOfflinePack.progress.countOfTileBytesCompleted` on iOS, + /// `OfflineRegionStatus.completedResourceSize` on Android). `null` when + /// the native map hasn't reported a size for this region yet. + final int? actualBytes; + const OfflineMapRegion({ required this.id, required this.name, @@ -62,9 +73,13 @@ class OfflineMapRegion { required this.maxZoom, required this.createdAt, required this.estimatedBytes, + this.actualBytes, }); - factory OfflineMapRegion.fromOfflineRegion(OfflineRegion region) { + factory OfflineMapRegion.fromOfflineRegion( + OfflineRegion region, { + int? actualBytes, + }) { final meta = region.metadata; return OfflineMapRegion( id: region.id, @@ -83,16 +98,20 @@ class OfflineMapRegion { final String s => int.tryParse(s) ?? 0, _ => 0, }, + actualBytes: actualBytes, ); } + /// Size to show in the UI — real bytes when the native SDK has reported + /// them, falling back to the download-time estimate otherwise. + int get sizeBytes => actualBytes ?? estimatedBytes; + /// Human-readable size string. String get sizeDisplay { - if (estimatedBytes < 1024) return '$estimatedBytes B'; - if (estimatedBytes < 1024 * 1024) { - return '${(estimatedBytes / 1024).toStringAsFixed(1)} KB'; - } - return '${(estimatedBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + final b = sizeBytes; + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; } /// Short bounds description (e.g. "49.2°N, 123.1°W"). @@ -139,16 +158,49 @@ class OfflineMapService extends ChangeNotifier { int get storageLimitMb => _storageLimitMb; int get storageLimitBytes => _storageLimitMb * 1024 * 1024; - /// Total estimated bytes across all downloaded regions. - int get totalUsedBytes => - _regions.fold(0, (sum, r) => sum + r.estimatedBytes); + /// Actual on-disk cache.db size as reported by the native platform + /// (MapLibre stores downloaded regions and ambient tiles in one SQLite + /// file). Refreshed alongside [refreshRegions]. + int _totalCacheBytes = 0; + + /// Actual bytes used by downloaded regions alone (sum of per-region sizes + /// from the native SDK). The difference between this and [_totalCacheBytes] + /// is the ambient/auto-cached tile portion. + int _downloadsBytes = 0; + + /// Total on-disk cache size (downloads + ambient, bytes). + int get totalUsedBytes => _totalCacheBytes; + + /// Bytes attributed to explicit offline downloads. + int get downloadsBytes => _downloadsBytes; + + /// Bytes attributed to tiles cached opportunistically while panning/zooming + /// the map (total minus explicit downloads, clamped ≥ 0 because the two + /// numbers come from different native queries and can briefly diverge). + int get ambientBytes => + (_totalCacheBytes - _downloadsBytes).clamp(0, _totalCacheBytes); double get usageRatio { if (storageLimitBytes == 0) return 0; return (totalUsedBytes / storageLimitBytes).clamp(0.0, 1.0); } + /// Ratio of the storage bar filled by each bucket. Both clamp to [0,1] and + /// are computed against the limit so they visually add up against the same + /// denominator as [usageRatio]. + double get downloadsRatio { + if (storageLimitBytes == 0) return 0; + return (_downloadsBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + double get ambientRatio { + if (storageLimitBytes == 0) return 0; + return (ambientBytes / storageLimitBytes).clamp(0.0, 1.0); + } + String get totalUsedDisplay => _formatBytes(totalUsedBytes); + String get downloadsDisplay => _formatBytes(_downloadsBytes); + String get ambientDisplay => _formatBytes(ambientBytes); String get storageLimitDisplay => '$_storageLimitMb MB'; // ── Download state ── @@ -378,27 +430,51 @@ class OfflineMapService extends ChangeNotifier { // ── Region queries ── - /// Refresh the list of downloaded regions from MapLibre native storage. + /// Refresh the list of downloaded regions from MapLibre native storage, + /// along with per-region byte counts and the overall on-disk cache size. Future refreshRegions() async { if (kIsWeb) return; try { final rawRegions = await getListOfRegions(); + // Pull sizes and the total cache footprint in parallel. Both come from + // our platform channel so they share the same round-trip cost. + final results = await Future.wait([ + TileCacheService.instance.getRegionSizes(), + TileCacheService.instance.getCacheSizeBytes(), + ]); + final sizes = results[0] as Map; + final totalBytes = results[1] as int; + final parsed = []; + int downloadsSum = 0; for (final r in rawRegions) { try { - parsed.add(OfflineMapRegion.fromOfflineRegion(r)); + final actual = sizes[r.id]; + parsed.add( + OfflineMapRegion.fromOfflineRegion(r, actualBytes: actual)); + downloadsSum += actual ?? 0; } catch (e) { debugWarn('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); } } _regions = parsed; _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + _totalCacheBytes = totalBytes; + _downloadsBytes = downloadsSum; notifyListeners(); } catch (e) { debugError('[OFFLINE_MAP] Failed to list regions: $e'); } } + /// Refresh only the overall cache size (fast path, no region enumeration). + /// Useful after ambient cache operations that don't change region state. + Future refreshCacheSize() async { + if (kIsWeb) return; + _totalCacheBytes = await TileCacheService.instance.getCacheSizeBytes(); + notifyListeners(); + } + // ── Storage limit ── /// Update the storage limit (in MB) and persist it. @@ -415,29 +491,68 @@ class OfflineMapService extends ChangeNotifier { // ── Tile estimation ── - /// Estimate tile count for a region (rough heuristic). - /// Uses the standard 2^z tile count formula for each zoom level. + /// Native max zoom of the OpenFreeMap vector tileset. z15+ in the slider is + /// reachable but it's pure overzoom — MapLibre rasterizes z14 source tiles + /// for higher zooms without fetching new data. Capping the estimator loop + /// here keeps overzoom from being counted as additional downloads. + static const int _vectorTilesetMaxZoom = 14; + + /// Web Mercator clamps latitude to ±~85.0511° (atan(sinh(π))); beyond that + /// the projection diverges. Using the literal here keeps the tile-Y math + /// stable if a user drags a corner near a pole. + static const double _mercatorLatLimit = 85.0511; + + /// Average bytes per OpenFreeMap vector tile (MVT/PBF). Empirically ~2.4 KB + /// globally; we round up to 3 KB to leave a small margin without grossly + /// overestimating. Raster tiles would need a higher number (20–40 KB) but + /// the app only offers vector styles for offline download — if that ever + /// changes, this needs to be style-aware. + static const int _bytesPerVectorTile = 3 * 1024; + + /// Estimate tile count for a region using proper Web Mercator tile math. + /// Iterates over each zoom level the SDK will actually fetch source tiles + /// for (capped at the tileset's native max zoom). static int estimateTileCount( LatLngBounds bounds, double minZoom, double maxZoom) { + final zMin = minZoom.floor(); + final zMax = math.min(maxZoom.ceil(), _vectorTilesetMaxZoom); + if (zMax < zMin) return 0; + + final latMin = + bounds.southwest.latitude.clamp(-_mercatorLatLimit, _mercatorLatLimit); + final latMax = + bounds.northeast.latitude.clamp(-_mercatorLatLimit, _mercatorLatLimit); + final lngMin = bounds.southwest.longitude; + final lngMax = bounds.northeast.longitude; + int total = 0; - for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { - final tilesPerSide = 1 << z; // 2^z - final lonFraction = - (bounds.northeast.longitude - bounds.southwest.longitude).abs() / - 360.0; - final latFraction = - (bounds.northeast.latitude - bounds.southwest.latitude).abs() / 180.0; - final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); - final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + for (int z = zMin; z <= zMax; z++) { + final n = 1 << z; + final x0 = ((lngMin + 180.0) / 360.0 * n).floor().clamp(0, n - 1); + final x1 = ((lngMax + 180.0) / 360.0 * n).floor().clamp(0, n - 1); + final xTiles = (x1 - x0 + 1).clamp(1, n); + // North latitude → smaller tile Y in Mercator, so yNorth uses latMax. + final yNorth = _mercatorTileY(latMax, n); + final ySouth = _mercatorTileY(latMin, n); + final yTiles = (ySouth - yNorth + 1).clamp(1, n); total += xTiles * yTiles; } return total; } - /// Rough estimate of download size in bytes from tile count. - /// Vector tiles average ~15-25 KB each; raster tiles ~20-40 KB. - /// We use 20 KB as a middle estimate. - static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; + /// Web Mercator tile-Y index for a given latitude at zoom level `2^z = n`. + static int _mercatorTileY(double latDeg, int n) { + final latRad = latDeg * math.pi / 180.0; + final y = (1 - + math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) / + 2 * + n; + return y.floor().clamp(0, n - 1); + } + + /// Estimate download size in bytes from tile count. + static int estimateSizeBytes(int tileCount) => + tileCount * _bytesPerVectorTile; /// Check if downloading a region of [estimatedBytes] would exceed the /// limit. Accounts for the currently-active download and any queued jobs diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index c5c13014..ee9407ed 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -40,6 +40,28 @@ class TileCacheService { } } + /// Per-region downloaded byte counts keyed by the Dart-side region ID that + /// maplibre_gl assigns. Regions missing from the result (e.g. because the + /// native store hasn't finished loading yet) should be treated as unknown, + /// not zero. Returns an empty map on web or unexpected channel errors. + Future> getRegionSizes() async { + if (kIsWeb) return const {}; + try { + final raw = await _channel.invokeMethod('getRegionSizes'); + if (raw is! Map) return const {}; + final result = {}; + raw.forEach((k, v) { + final id = k is int ? k : (k is num ? k.toInt() : int.tryParse('$k')); + final bytes = v is int ? v : (v is num ? v.toInt() : null); + if (id != null && bytes != null) result[id] = bytes; + }); + return result; + } catch (e) { + debugWarn('[OFFLINE_MAP] Region sizes read failed: $e'); + return const {}; + } + } + Future clearAmbientCache() async { if (kIsWeb) throw UnsupportedError('Not supported on web'); await _channel.invokeMethod('clearAmbientCache'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1c61b9c3..213d30b1 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1140,12 +1140,25 @@ class _MapWidgetState extends State { // online (network tiles) and offline (cache-only) mode. This avoids // a full style reload — the same style stays loaded but MapLibre stops // or starts making network requests for tiles. + // + // When disabling tiles, also drop the coverage overlay source/layer: + // MapLibre's ambient cache would still serve previously-fetched tiles, + // but the overlay is a live-data layer and shouldn't linger on the map + // in an indeterminate half-cached state once the user opted out. When + // re-enabling, re-add it so it reappears without waiting for a style + // reload or cache-bust. final tilesEnabled = appState.preferences.mapTilesEnabled; if (_lastMapTilesEnabled != tilesEnabled && _isMapReady) { + final wasEnabled = _lastMapTilesEnabled; _lastMapTilesEnabled = tilesEnabled; - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; - _setOfflineIfSupported(!tilesEnabled); + await _setOfflineIfSupported(!tilesEnabled); + if (wasEnabled == true && !tilesEnabled) { + await _removeCoverageOverlay(); + } else if (wasEnabled == false && tilesEnabled && _styleLoaded) { + await _addCoverageOverlay(appState); + } }); } From bec7fd30135b6537fb3909d9bedc0d954b55c8d2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 20 Apr 2026 22:09:30 -0400 Subject: [PATCH 080/360] feat(repeater-popup): enhance logging and match count for repeater ID popup --- lib/widgets/repeater_id_chip.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 2144f049..e93db01d 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -88,6 +88,17 @@ class RepeaterIdChip extends StatelessWidget { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; + final matchKey = fullHexId != null && fullHexId.length >= 8 + ? fullHexId.substring(0, 8) + : repeaterId; + final matchCount = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .length; + debugLog('[UI] Repeater popup tap: id=$repeaterId, ' + 'fullHex=${fullHexId ?? "(none)"}, matchKey=$matchKey, ' + 'repeaters=${repeaters.length}, matches=$matchCount, ' + 'zone=${appState.zoneCode ?? "(none)"}'); + final Widget content; if (repeaters.isEmpty) { @@ -103,12 +114,6 @@ class RepeaterIdChip extends StatelessWidget { ), ); } else { - // DISC pings provide the full public key — match first 8 hex chars - // (4 bytes) against repeater hexId for exact identification. - // TX/RX pings only have 1-byte IDs so fall back to prefix matching. - final matchKey = fullHexId != null && fullHexId.length >= 8 - ? fullHexId.substring(0, 8) - : repeaterId; final matches = repeaters .where( (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) From 41b5c4ac9790bc2ff0806ff88c9be212837baf4d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 21 Apr 2026 22:47:53 -0400 Subject: [PATCH 081/360] =?UTF-8?q?-=20New=20Flood=20Traffic=20toggle=20at?= =?UTF-8?q?=20the=20top=20of=20Settings=20=E2=86=92=20Modes=20(default=20O?= =?UTF-8?q?FF).=20When=20OFF,=20Send=20Ping,=20Active,=20and=20Hybrid=20co?= =?UTF-8?q?ntrols=20are=20hidden=20across=20all=20three=20ping-control=20v?= =?UTF-8?q?ariants=20(standard,=20compact,=20landscape).=20Passive=20and?= =?UTF-8?q?=20Trace=20stay=20visible.=20-=20MeshMapper=20now=20parses=20`f?= =?UTF-8?q?lood=5Fdisabled`=20from=20the=20auth=20response=20and=20auto-sy?= =?UTF-8?q?ncs=20the=20toggle=20on=20connect=20and=20zone=20change.=20If?= =?UTF-8?q?=20the=20region=20permits=20flooding,=20the=20toggle=20flips=20?= =?UTF-8?q?ON.=20If=20the=20region=20forbids=20it,=20the=20toggle=20is=20f?= =?UTF-8?q?orced=20OFF,=20greyed=20out,=20with=20an=20amber=20"Set=20by=20?= =?UTF-8?q?Regional=20Admin"=20subtitle.=20-=20One-shot=20popup=20when=20a?= =?UTF-8?q?=20user-enabled=20flood=20toggle=20gets=20overridden=20by=20reg?= =?UTF-8?q?ional=20policy=20on=20auth=20or=20zone=20change,=20explaining?= =?UTF-8?q?=20that=20Active=20and=20Hybrid=20modes=20have=20been=20disable?= =?UTF-8?q?d=20for=20this=20session.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/user_preferences.dart | 12 +++++++ lib/providers/app_state_provider.dart | 47 +++++++++++++++++++++++++++ lib/screens/main_scaffold.dart | 36 ++++++++++++++++++++ lib/screens/settings_screen.dart | 18 ++++++++++ lib/services/api_service.dart | 17 ++++++++++ lib/services/meshcore/tx_tracker.dart | 27 +++++++++++++++ lib/widgets/ping_controls.dart | 12 +++++-- 7 files changed, 166 insertions(+), 3 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 84e4acc9..d26d4c23 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -73,6 +73,11 @@ class UserPreferences { /// Discovery drop: count failed discoveries as failed pings and report to API final bool discDropEnabled; + /// Flood traffic enabled — gates display of Send Ping + Active + Hybrid + /// controls. Default off; auto-flipped by the auth response's flood_disabled + /// flag when the region allows flooding. + final bool floodTrafficEnabled; + /// Delete wardriving channel from radio on disconnect final bool deleteChannelOnDisconnect; @@ -145,6 +150,7 @@ class UserPreferences { this.disableRssiFilter = false, this.anonymousMode = false, this.discDropEnabled = false, + this.floodTrafficEnabled = false, this.deleteChannelOnDisconnect = true, this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, @@ -190,6 +196,7 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + floodTrafficEnabled: (json['floodTrafficEnabled'] as bool?) ?? false, deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, @@ -247,6 +254,7 @@ class UserPreferences { 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, 'discDropEnabled': discDropEnabled, + 'floodTrafficEnabled': floodTrafficEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, @@ -291,6 +299,7 @@ class UserPreferences { bool? disableRssiFilter, bool? anonymousMode, bool? discDropEnabled, + bool? floodTrafficEnabled, bool? deleteChannelOnDisconnect, int? minPingDistanceMeters, bool? autoStopAfterIdle, @@ -334,6 +343,7 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, + floodTrafficEnabled: floodTrafficEnabled ?? this.floodTrafficEnabled, deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, minPingDistanceMeters: @@ -410,6 +420,7 @@ class UserPreferences { other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && other.discDropEnabled == discDropEnabled && + other.floodTrafficEnabled == floodTrafficEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && @@ -453,6 +464,7 @@ class UserPreferences { disableRssiFilter, anonymousMode, discDropEnabled, + floodTrafficEnabled, deleteChannelOnDisconnect, minPingDistanceMeters, autoStopAfterIdle, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index fe609f3f..f6af59dd 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -607,6 +607,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get enforceDiscDrop => _apiService.enforceDiscDrop; bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + + /// Whether the current region forbids flood traffic (region override). + bool get floodDisabled => _apiService.floodDisabled; + + /// Effective flood-traffic visibility: region veto wins over user pref. + bool get floodTrafficEnabled => + !_apiService.floodDisabled && _preferences.floodTrafficEnabled; + + /// One-shot flag: true when the user had flood traffic enabled and the + /// region forced it off on auth/zone-change. UI shows a dialog, then calls + /// [clearFloodDisabledAlert]. + bool _floodDisabledAlertPending = false; + bool get floodDisabledAlertPending => _floodDisabledAlertPending; + void clearFloodDisabledAlert() { + if (!_floodDisabledAlertPending) return; + _floodDisabledAlertPending = false; + notifyListeners(); + } int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; @@ -1469,6 +1487,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Discovery drop force-enabled by regional admin'); } + // Sync Flood Traffic preference with regional policy: + // - flood_disabled=true → force OFF (region forbids) + // - flood_disabled=false → force ON (region permits, user lands ready) + // Fire a one-shot alert only on user-on → region-off transition. + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[CONN] Flood traffic auto-enabled (region permits)' + : '[CONN] Flood traffic disabled by regional admin'); + } + if (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } + // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith( @@ -5303,6 +5338,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(discDropEnabled: true); debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[ZONE] Flood traffic auto-enabled (new zone permits)' + : '[ZONE] Flood traffic disabled by new zone admin'); + } + if (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith( autoPingInterval: _apiService.minModeInterval); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index a3cdfef1..375a6a95 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -27,6 +27,7 @@ class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; bool _hasShownLocationSettingsPrompt = false; + bool _floodDisabledDialogOpen = false; final List _screens = [ const HomeScreen(), @@ -133,6 +134,31 @@ class _MainScaffoldState extends State { } } + Future _showFloodDisabledDialog() async { + final appState = context.read(); + debugLog('[APP] Showing flood-traffic-disabled-by-region alert'); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Flood Traffic Unavailable'), + content: const Text( + 'Your regional MeshMapper admin has disabled flood traffic in this ' + 'area, so Active and Hybrid modes have been turned off for this ' + 'session. Passive Mode and Trace Mode remain available. Please ' + 'reach out to your regional admin if you have questions.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + appState.clearFloodDisabledAlert(); + _floodDisabledDialogOpen = false; + } + void _showLocationSettingsPrompt() { if (!mounted || _hasShownLocationSettingsPrompt) return; _hasShownLocationSettingsPrompt = true; @@ -188,6 +214,16 @@ class _MainScaffoldState extends State { }); } + // Listen for flood-traffic-disabled-by-region alert (user had it on, + // region forced it off on auth/zone-change) + if (appState.floodDisabledAlertPending && !_floodDisabledDialogOpen) { + _floodDisabledDialogOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showFloodDisabledDialog(); + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 76b9f510..12a3343e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -344,6 +344,24 @@ class _SettingsScreenState extends State { // Modes _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.waves), + title: const Text('Flood Traffic'), + subtitle: appState.floodDisabled + ? const Text( + 'Set by Regional Admin — flood traffic is disabled in this region. Active and Hybrid modes are unavailable here.', + style: TextStyle(color: Colors.amber), + ) + : const Text( + 'Show Active, Hybrid, and manual Send Ping controls'), + value: appState.floodDisabled ? false : prefs.floodTrafficEnabled, + onChanged: (isAutoMode || appState.floodDisabled) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(floodTrafficEnabled: value)); + }, + ), SwitchListTile( secondary: const Icon(Icons.compare_arrows), title: Row( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 2901016e..51753922 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -49,6 +49,7 @@ class ApiService { List _scopes = []; bool _enforceHybrid = false; bool _enforceDiscDrop = false; + bool _floodDisabled = false; int _minModeInterval = 15; int _apiHopBytes = 1; @@ -68,6 +69,9 @@ class ApiService { /// Whether discovery drop is enforced by regional admin bool get enforceDiscDrop => _enforceDiscDrop; + /// Whether flood traffic (Active/Hybrid modes) is disabled by regional admin + bool get floodDisabled => _floodDisabled; + /// Minimum auto-ping interval enforced by regional admin (seconds) int get minModeInterval => _minModeInterval; @@ -442,6 +446,18 @@ class ApiService { debugLog('[API] Regional admin enforces discovery drop'); } + // Parse flood_disabled flag from auth response + _floodDisabled = data['flood_disabled'] == true; + if (_floodDisabled) { + debugLog('[API] Regional admin has disabled flood traffic'); + } + + // Parse flood_disabled flag from auth response + _floodDisabled = data['flood_disabled'] == true; + if (_floodDisabled) { + debugLog('[API] Regional admin has disabled flood traffic'); + } + // Parse min_mode_interval from auth response final minInterval = data['min_mode_interval']; if (minInterval is int && minInterval > 0) { @@ -832,6 +848,7 @@ class ApiService { _scopes = []; _enforceHybrid = false; _enforceDiscDrop = false; + _floodDisabled = false; _minModeInterval = 15; _apiHopBytes = 1; _heartbeatTimer?.cancel(); diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index b4090e26..9092814e 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,9 +7,36 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; +/// Classification result from [TxTracker.handlePacket]. +/// +/// [ownPingNotTracked] is the key signal the unified RX handler needs to +/// suppress passive RX logging of our own ping's echo when it can't be +/// credited as a TX echo (multi-hop, RSSI failsafe, user-filtered, or +/// arrived after the 5s echo window closed). +enum TxTrackerResult { + /// Valid repeater echo — recorded as a TX success for this ping. + tracked, + + /// Confirmed echo of our own recently-sent ping, but not trackable as a + /// TX success. Caller MUST NOT route this to the passive RX logger — + /// doing so produces misleading blue RX markers and polluted API payloads + /// where we post our own ping back to MeshMapper as a passive observation. + ownPingNotTracked, + + /// Packet is not an echo of our ping (different channel, different + /// content, or no recent ping context). Caller may route to the passive + /// RX logger as usual. + notOurPing, +} + /// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { + /// Window during which we still identify our own ping's echoes, even after + /// [stopTracking] has fired. Covers echoes that arrive between the 5s echo + /// window closing and the RX listening window closing (BLE ack latency + + /// multi-hop propagation can easily push echoes past the 5s mark). + static const Duration _ownEchoAbsorbWindow = Duration(seconds: 10); bool isListening = false; DateTime? sentTimestamp; String? sentPayload; diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index f080ac02..fcfac93e 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -97,13 +97,15 @@ class PingControls extends StatelessWidget { } // Note: cooldown and tooClose are shown on button itself + final floodTrafficVisible = appState.floodTrafficEnabled; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Action buttons row Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // Send Ping button // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" // Manual pings use 15-second cooldown, no distance requirement @@ -1100,13 +1102,15 @@ class _CompactPingControlsState extends State { }, ); + final floodTrafficVisible = appState.floodTrafficEnabled; + // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally // - Grey non-expanded buttons are icon-only return Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -1404,6 +1408,8 @@ class LandscapePingControls extends StatelessWidget { prefs.powerLevelSet || appState.deviceModel != null; + final floodTrafficVisible = appState.floodTrafficEnabled; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1420,7 +1426,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( From c43ac046041143b3303335e2376231725a070161 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 25 Apr 2026 13:43:25 -0400 Subject: [PATCH 082/360] =?UTF-8?q?=20-=20Fixed=20Hive=20preferences=20bei?= =?UTF-8?q?ng=20wiped=20by=20transient=20open=20failures.=20The=20startup?= =?UTF-8?q?=20theme=20loader=20was=20treating=20a=20non-fatal=20open=20tim?= =?UTF-8?q?eout=20as=20corruption=20and=20deleting=20the=20=20entire=20use?= =?UTF-8?q?r=5Fpreferences=20box,=20taking=20every=20saved=20setting=20wit?= =?UTF-8?q?h=20it.=20The=20early-startup=20deletion=20is=20gone=20?= =?UTF-8?q?=E2=80=94=20real=20corruption=20is=20still=20caught=20later=20b?= =?UTF-8?q?y=20=20AppStateProvider.=5FattemptHiveRecovery=20with=20a=20use?= =?UTF-8?q?r-visible=20error.=20=20-=20Force=20a=20box.flush()=20after=20e?= =?UTF-8?q?very=20preference=20write=20(general=20preferences,=20device=20?= =?UTF-8?q?antenna=20preferences,=20device=20power=20overrides,=20audio=20?= =?UTF-8?q?settings)=20so=20settings=20are=20=20persisted=20to=20disk=20im?= =?UTF-8?q?mediately=20instead=20of=20relying=20on=20Hive's=20lazy=20flush?= =?UTF-8?q?=20=E2=80=94=20prevents=20loss=20on=20crash=20or=20abrupt=20ter?= =?UTF-8?q?mination.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 11 ++++------- lib/providers/app_state_provider.dart | 3 +++ lib/services/audio_service.dart | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 22d67ee0..37354b48 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,13 +91,10 @@ Future _loadInitialThemeMode() async { } } } catch (e) { - debugLog('[HIVE] Failed to load initial theme: $e - deleting corrupt box'); - // Delete corrupt box so AppStateProvider gets a clean start - try { - await Hive.deleteBoxFromDisk('user_preferences'); - } catch (e) { - debugLog('[INIT] Failed to delete corrupt preferences box: $e'); - } + debugLog('[HIVE] Initial theme load failed (non-fatal): $e'); + // Do NOT delete the box here. A transient open timeout would wipe every + // saved setting. AppStateProvider's _attemptHiveRecovery handles real + // corruption later with a user-visible logError() notification. } return 'dark'; // Default to dark mode } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f6af59dd..7c6d2729 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5945,6 +5945,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('preferences', _preferences.toJson()); + await box.flush(); debugLog('[APP] Saved preferences'); } catch (e) { debugLog('[APP] Failed to save preferences: $e'); @@ -5979,6 +5980,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('device_antenna_preferences', _deviceAntennaPreferences); + await box.flush(); } catch (e) { debugLog('[APP] Failed to save device antenna preferences: $e'); } @@ -6015,6 +6017,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('device_power_overrides', _devicePowerOverrides); + await box.flush(); } catch (e) { debugLog('[APP] Failed to save device power overrides: $e'); } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 27cc57b9..64f3707e 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -130,6 +130,7 @@ class AudioService { try { await box.put(_enabledKey, _enabled); + await box.flush(); debugLog('[AUDIO] Saved enabled state: $_enabled'); } catch (e) { debugError('[AUDIO] Failed to save enabled state: $e'); @@ -345,6 +346,7 @@ class AudioService { if (box == null) return; try { await box.put(key, value); + await box.flush(); } catch (e) { debugError('[AUDIO] Failed to save $key: $e'); } From dcb57a7e58eab6776d579d3f152069a000429009 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 26 Apr 2026 20:56:42 -0400 Subject: [PATCH 083/360] - Fixed an iOS crash where backgrounded sessions could be killed hours later by a MapLibre camera animation error. Camera animations now suspend while the app is in the background. --- Build.sh | 11 ++++ lib/widgets/map_widget.dart | 105 ++++++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 28 deletions(-) diff --git a/Build.sh b/Build.sh index 3cf94759..a62e4eaa 100755 --- a/Build.sh +++ b/Build.sh @@ -5,6 +5,17 @@ set -e # Exit on any error +# maplibre_gl 0.25.0 plugin requires JDK 21 to compile. +# Force the build to use Homebrew openjdk@21, regardless of the user's shell JAVA_HOME. +JDK21_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" +if [ ! -d "$JDK21_HOME" ]; then + echo "Error: JDK 21 not found at $JDK21_HOME" + echo "Install with: brew install openjdk@21" + exit 1 +fi +export JAVA_HOME="$JDK21_HOME" +export PATH="$JAVA_HOME/bin:$PATH" + # Semver comparison: returns 0 (true) if $1 >= $2 version_gte() { local IFS=. diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 213d30b1..a7cbd7e2 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -308,9 +308,20 @@ class MapWidget extends StatefulWidget { State createState() => _MapWidgetState(); } -class _MapWidgetState extends State { +class _MapWidgetState extends State with WidgetsBindingObserver { MapLibreMapController? _mapController; + // Tracks the app lifecycle so we can suppress every animateCamera() call + // while the app is not in the foreground. MapLibre's native flyTo throws + // an uncaught C++ exception (mbgl::LatLng domain_error → SIGABRT) when + // the underlying MLNMapView's GL context is degraded, which is what + // happens on iOS while the app is backgrounded. The Flutter frame + // pipeline keeps running in our background-mode app (bluetooth-central + + // location), so GPS-driven rebuilds would otherwise still fire animateCamera. + AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + bool get _canAnimateCamera => + _appLifecycleState == AppLifecycleState.resumed; + // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first bool _prefsApplied = false; // Guard to load saved prefs only once @@ -468,8 +479,22 @@ class _MapWidgetState extends State { static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _appLifecycleState = + WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appLifecycleState = state; + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -544,7 +569,12 @@ class _MapWidgetState extends State { /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { - if (_mapController == null || !_isMapReady || !mounted) return; + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } _mapController!.animateCamera( CameraUpdate.newLatLngZoom(target, targetZoom), duration: const Duration(milliseconds: 500), @@ -566,7 +596,12 @@ class _MapWidgetState extends State { required double bearing, int durationMs = 300, }) { - if (_mapController == null || !_isMapReady || !mounted) return; + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } _mapController!.animateCamera( CameraUpdate.newCameraPosition(CameraPosition( target: target, @@ -580,7 +615,12 @@ class _MapWidgetState extends State { /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds( LatLng pingLocation, List<_ResolvedRepeater> repeaters) { - if (_mapController == null || !_isMapReady || !mounted) return; + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } final points = [ pingLocation, @@ -613,7 +653,11 @@ class _MapWidgetState extends State { /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) { + if (_mapController == null || + !_isMapReady || + !mounted || + _alwaysNorth || + !_canAnimateCamera) { return; } @@ -933,7 +977,7 @@ class _MapWidgetState extends State { // Rotate map back to north (0 degrees) first final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; - if (currentBearing.abs() > 2) { + if (currentBearing.abs() > 2 && _canAnimateCamera) { _mapController!.animateCamera(CameraUpdate.bearingTo(0)); } @@ -1369,12 +1413,15 @@ class _MapWidgetState extends State { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } return; } @@ -1442,13 +1489,15 @@ class _MapWidgetState extends State { // Same explicit 200ms duration as the direct cluster path in // _handleFeatureTap so both tap routes feel identical. if (properties['cluster'] == true) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } return; } @@ -1588,7 +1637,7 @@ class _MapWidgetState extends State { _hasStyleLoadedOnce = true; // Center on GPS if available (initial centering) - if (appState.currentPosition != null) { + if (appState.currentPosition != null && _canAnimateCamera) { final center = LatLng( appState.currentPosition!.latitude, appState.currentPosition!.longitude, @@ -3235,7 +3284,7 @@ class _MapWidgetState extends State { if (_alwaysNorth && _isMapReady && _mapController != null) { _lastHeading = null; final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; - if (currentBearing.abs() > 2) { + if (currentBearing.abs() > 2 && _canAnimateCamera) { _mapController!.animateCamera( CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), @@ -3269,7 +3318,7 @@ class _MapWidgetState extends State { _alwaysNorth && _mapController != null) { final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; - if (currentBearing.abs() > 2) { + if (currentBearing.abs() > 2 && _canAnimateCamera) { _mapController!.animateCamera( CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), @@ -4272,7 +4321,7 @@ class _MapWidgetState extends State { if (!_alwaysNorth) { _alwaysNorth = true; // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null) { + if (_isMapReady && _mapController != null && _canAnimateCamera) { _mapController!.animateCamera( CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 1), @@ -4698,13 +4747,13 @@ class _MapWidgetState extends State { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.15), + color: PingColors.rx.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: - Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.rx.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, - color: Colors.blue, size: 24), + child: Icon(Icons.arrow_downward, + color: PingColors.rx, size: 24), ), const SizedBox(width: 12), Expanded( From 170e55f592f454b47cc6b2ef98d8d9b1c4f0594d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 26 Apr 2026 22:22:29 -0400 Subject: [PATCH 084/360] - **Repeater Marker Clustering:** Stacked repeater markers at the same (or near-same) GPS coordinates now collapse into a cluster bubble with a count instead of rendering as an overlapping pile with illegible labels. Clustering runs at every zoom level (was previously dropped past zoom 14), so a stack stays as a single tappable target even fully zoomed in. - **Spiderfy on Tap:** Tapping a cluster at max zoom fans the members out in a ring (or spiral past 20 markers) connected by thin leader lines, each independently tappable and opening the repeater detail sheet. Below max zoom, taps zoom in further first so you can separate the stack visually. - Spider auto-collapses when you tap an empty area, tap a different cluster, or zoom past a 0.25-level delta. Panning keeps the spread open since leader lines live in geographic coordinates and follow the map naturally. On iOS, spread markers covered by the GPS dot are now tappable as well. --- lib/widgets/map_widget.dart | 658 +++++++++++++++++++++++++++++++++--- 1 file changed, 612 insertions(+), 46 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index a7cbd7e2..8beb9efb 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -459,6 +459,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + // Spiderfy source/layer IDs — non-clustered shadow source rendering spread + // markers + leader lines for stacked repeaters that won't separate by zoom. + static const _spiderSourceId = 'spider-source'; + static const _spiderLineLayerId = 'spider-leader-lines'; + static const _spiderSymbolLayerId = 'spider-symbols'; + // Matches the main source's clusterRadius (50px). Reused by the spiderfy + // group-detection logic: pairs of repeaters within `clusterRadius × m/px + // at the user's max zoom` of each other will visually overlap even when + // fully zoomed in, so they're the candidates that won't be separated by + // additional zoom and need to be spread apart instead. + static const double _clusterRadiusPx = 50; + static const double _spiderInnerRadiusPx = 44; + static const double _spiderOuterRadiusPx = 80; + static const double _leaderLineEndShortenPx = 8; + // Camera-zoom delta past which an open spider must collapse (positions are + // pixel-radius derived and become wrong if the user zooms far enough). + static const double _spiderCollapseZoomDelta = 0.25; + // Regional boundary (from /border API — always visible) static const _regionBorderSourceId = 'region-border-source'; static const _regionBorderLineLayerId = 'region-border-line'; @@ -475,6 +493,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Last bearing seen by camera listener (for non-rotating GPS counter-rotation) double _lastBearing = 0; + // Spiderfy state — when non-null, a stack of stacked repeaters has been + // fanned out around _spiderCenter into the shadow `spider-source`. + // Lifecycle: set by _spiderfy(), cleared by _collapseSpider(). + LatLng? _spiderCenter; + List _spiderRepeaters = const []; + // Captured on _spiderfy() so _onCameraChanged can detect a zoom delta past + // _spiderCollapseZoomDelta and collapse (positions become invalid). + double? _spiderOpenedAtZoom; + // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving @@ -527,6 +554,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (!mounted || _mapController == null) return; final pos = _mapController!.cameraPosition; if (pos == null) return; + + // If a spider is open and the user has zoomed past the collapse-delta + // threshold, drop the spider — pixel-radius spread positions are now wrong + // for the new zoom. Pure pan never crosses this threshold (zoom doesn't + // change), so the spider follows pan naturally via its geo coordinates. + // Once `_spiderCenter` is null after collapse, this branch no-ops. + if (_spiderCenter != null && _spiderOpenedAtZoom != null) { + if ((pos.zoom - _spiderOpenedAtZoom!).abs() > _spiderCollapseZoomDelta) { + _collapseSpider(); + } + } + if ((pos.bearing - _lastBearing).abs() < 0.5) return; _lastBearing = pos.bearing; _updateGpsSymbolRotation(); @@ -1298,12 +1337,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { onStyleLoadedCallback: () => _onStyleLoaded(appState), onMapIdle: _onMapIdle, onCameraIdle: _onCameraIdle, - // NOTE: we do NOT pass onMapClick here. The iOS plugin's - // handleMapTap fires `feature#onTap` when a tap hits any - // interactive layer (including our cluster source layers) and - // does NOT fire `map#onMapClick` in that case. We register a - // listener on `controller.onFeatureTapped` in _onMapCreated - // instead — that fires for taps on custom layer features. + // onMapClick fires ONLY for taps that DON'T hit an interactive + // layer (the iOS/Android plugin routes feature hits to + // `feature#onTap` and doesn't dispatch onMapClick in that case). + // That's exactly what we need for the empty-area-collapse path — + // tapping the map background closes any open spider. + // Custom-layer feature taps still flow through + // `controller.onFeatureTapped` (registered in _onMapCreated). + onMapClick: _onMapEmptyTap, ), // No widget marker overlay — markers are now native MapLibre // annotations rendered by the platform view itself. @@ -1371,6 +1412,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Fires for taps on the map background that don't hit any interactive + /// custom-layer feature. Used to close an open spider when the user taps + /// somewhere outside the spread / cluster — the standard "click empty + /// area to dismiss" interaction. Wired via the MapLibreMap `onMapClick` + /// parameter. + void _onMapEmptyTap(math.Point point, LatLng coordinates) { + if (!mounted) return; + if (_spiderCenter != null) { + _collapseSpider(); + } + } + /// Handles taps on custom layer features (repeater cluster bubbles and /// individual repeaters). Wired in [_onMapCreated] via /// `controller.onFeatureTapped.add(_handleFeatureTap)`. @@ -1398,13 +1451,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ) { if (!mounted) return; - // Cluster tap: just zoom in. We accept hits on EITHER the bubble circle - // layer OR the count-text symbol layer that sits on top of it. The - // platform-side hit-test iterates layers top-down and returns the first - // feature it finds; for cluster taps, the centered count text usually - // gets hit before the underlying bubble, so we have to recognise both - // layer IDs as "user tapped a cluster". Either way the action is the - // same: animate-zoom in 2 levels around the tap point. + // Spider spread marker: open the detail sheet for the tapped repeater. + // Spider stays open — users frequently compare stacked repeaters back to + // back, so collapsing on every selection would be annoying. + if (layerId == _spiderSymbolLayerId) { + _showRepeaterDetailsById(id); + return; + } + + // Cluster tap: zoom in by default, but spiderfy first when the cluster + // contains markers stacked tightly enough that no further zoom would + // separate them. We accept hits on either the bubble circle layer or + // the count-text symbol layer (either may win the platform-side + // top-down hit-test depending on tap position). // // The explicit 200ms duration is important for perceived responsiveness. // Without it, iOS uses setCamera(animated: true) which has a slow ease-in @@ -1413,23 +1472,70 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - if (_canAnimateCamera) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + // Spiderfy is gated to max zoom. Below that, give the user a chance + // to separate the stack by zooming further before we resort to the + // spread UI. Note that `_spiderCenter` is always null at non-max + // zoom (the camera-change collapse fires when the user zooms out + // from max), so no collapse-handling is needed in this branch. + if (!_isAtMaxZoom()) { + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; } + + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + + // Re-tap on the open spider's own group → collapse instead of churn. + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + // Tapped a different cluster — close the existing spider before + // evaluating the new tap. + _collapseSpider(); + } + + if (group.length >= 2) { + _spiderfy(coordinates, group); + } + // Already at max zoom with a single-marker group: nothing useful to + // zoom into and no stack to spread. Silent no-op. return; } - // Individual repeater: look up by id (which is repeater.id) and open the - // existing detail sheet. We recompute isDuplicate and hopOverride from - // app state rather than carrying them in feature properties — the values - // are cheap to derive and always reflect the latest data. + // Individual repeater: open the detail sheet. At max zoom we ALSO check + // for stacked siblings within the spider stick threshold and spread them + // out (covers the rare case where clustering didn't pick them up — e.g. + // identical-coordinate markers that just slipped past clusterRadius + // due to a recent data update). Below max zoom, spiderfy is disabled — + // the user is expected to zoom further first. if (layerId == _repeaterIndividualLayerId) { + // If a spider is open and the user tapped a non-spider individual + // (originals are filtered out via `inSpider`, so this can only be a + // marker outside the spider's group), collapse the existing spider. + if (_spiderCenter != null) { + _collapseSpider(); + } + + if (_isAtMaxZoom()) { + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } + } + _showRepeaterDetailsById(id); return; } @@ -1468,9 +1574,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ) async { if (_mapController == null) return; try { + // Query the spider symbols FIRST so a tap on a spread marker hidden + // under the GPS overlay still routes to the spider's detail sheet + // (rather than the original repeater layer beneath it). final features = await _mapController!.queryRenderedFeatures( point, const [ + _spiderSymbolLayerId, _repeaterClusterCountLayerId, _repeaterClusterBubbleLayerId, _repeaterIndividualLayerId, @@ -1486,28 +1596,65 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final properties = (feature['properties'] as Map?) ?? {}; // Cluster (auto-tagged by MapLibre when cluster: true is set on source). - // Same explicit 200ms duration as the direct cluster path in - // _handleFeatureTap so both tap routes feel identical. + // Mirrors the cluster path in _handleFeatureTap, including the + // max-zoom gate on spiderfy. if (properties['cluster'] == true) { - if (_canAnimateCamera) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + if (!_isAtMaxZoom()) { + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; + } + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + _collapseSpider(); + } + if (group.length >= 2) { + _spiderfy(coordinates, group); } return; } - // Individual repeater. The feature `id` field is the repeater.id we set - // in _buildRepeaterFeatureCollection (or fall back to the property). + // Individual repeater (cluster or spider symbol). The feature `id` + // field is the repeater.id we set in our feature builders. Spider + // symbols never need spiderfy expansion — they ARE the spread; just + // open the detail sheet and leave the spider open. final repeaterId = (feature['id'] ?? properties['repeaterId'])?.toString(); - if (repeaterId != null) { - _showRepeaterDetailsById(repeaterId); + if (repeaterId == null) return; + + // For an individual layer hit (not a spider symbol), apply the same + // stacked-siblings test as the direct tap path — but ONLY at max zoom. + // queryRenderedFeatures doesn't expose layerId per result in 0.25, so + // we infer: if a spider is open, the original individuals are filtered + // out, so any individual hit must be a non-stacked marker → just open + // the detail sheet. If no spider is open AND we're at max zoom, run + // the spiderfy test. + if (_spiderCenter != null) { + // User tapped outside the open spider's group — collapse + show + // detail sheet for the tapped marker. + _collapseSpider(); + } else if (_isAtMaxZoom()) { + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } } + _showRepeaterDetailsById(repeaterId); } catch (e) { debugError('[MAP] queryRenderedFeatures fall-through failed: $e'); } @@ -1973,6 +2120,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; final focusActive = _focusedPingLocation != null; + // While a spider is open, repeaters in the spread set are tagged with + // `inSpider:true`. The individual symbol layer's filter excludes those + // features so the spread markers from `_spiderSourceId` render in their + // place. Cluster aggregation is not affected — the cluster bubble keeps + // its full point_count. + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); final features = >[]; for (final repeater in appState.repeaters) { @@ -2005,6 +2158,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { 'hex': hex, 'isDuplicate': isDuplicate, if (hopOverride != null) 'hopOverride': hopOverride, + if (spiderIds.contains(repeater.id)) 'inSpider': true, }, 'geometry': { 'type': 'Point', @@ -2024,8 +2178,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Future _setupRepeaterClusterLayers() async { if (_mapController == null) return; - // Idempotent: tear down any existing source/layers from a previous style load + // Idempotent: tear down any existing source/layers from a previous style load. + // Spider layers are torn down first because they reference `_spiderSourceId`. for (final layerId in [ + _spiderSymbolLayerId, + _spiderLineLayerId, _repeaterClusterCountLayerId, _repeaterClusterBubbleLayerId, _repeaterIndividualLayerId, @@ -2037,6 +2194,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeSource(_repeaterSourceId); } catch (_) {} + try { + await _mapController!.removeSource(_spiderSourceId); + } catch (_) {} // Empty source with cluster enabled. We'll push real data via setGeoJsonSource // from _syncRepeaterSymbols whenever the marker data version changes. @@ -2055,7 +2215,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }, cluster: true, clusterRadius: 50, - clusterMaxZoom: 14, + // Cluster at every reachable zoom (max user zoom is 17). Stacked + // markers — those within `clusterRadius` pixels at the current + // zoom — stay as a cluster bubble + count instead of degenerating + // into a pile of overlapping individual symbols when the user is + // zoomed all the way in. Tap a cluster → spiderfy. Markers far + // enough apart that they exceed `clusterRadius` pixels at higher + // zooms still separate into individuals naturally on zoom. + clusterMaxZoom: 17, ), ); @@ -2065,6 +2232,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Layer 1: individual repeater markers (when not part of a cluster). // Data-driven properties read from each feature's `properties` map. + // Filter excludes both clustered features AND any feature tagged + // `inSpider` (spiderfy hides the originals so the spread markers from + // _spiderSourceId render in their place). await _mapController!.addSymbolLayer( _repeaterSourceId, _repeaterIndividualLayerId, @@ -2084,8 +2254,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { textFont: _defaultFontStack, ), filter: [ - '!', - ['has', 'point_count'] + 'all', + [ + '!', + ['has', 'point_count'] + ], + [ + '!=', + ['get', 'inSpider'], + true + ], ], belowLayerId: belowLayer, ); @@ -2137,9 +2315,82 @@ class _MapWidgetState extends State with WidgetsBindingObserver { belowLayerId: belowLayer, ); - // All 3 layers + source created successfully — mark ready so the - // build()-triggered post-frame sync can run, and so _syncRepeaterSymbols - // is allowed to push data via setGeoJsonSource. + // Spider shadow source + layers — non-clustered. Carries spread Point + // features (one per spiderfied repeater) and LineString features for + // leader lines from the cluster centre to each spread position. + // Cluster on this source MUST stay false; we want every Point to render + // verbatim where _computeSpiderRing placed it. + await _mapController!.addSource( + _spiderSourceId, + const GeojsonSourceProperties( + data: { + 'type': 'FeatureCollection', + 'features': [] + }, + ), + ); + + // Layer 4: spider leader lines (LineString features). Inserted just + // below the individual repeater layer so lines render BENEATH every + // repeater layer — the cluster bubble (which sits above individuals) + // visually contains the lines' inner endpoints, and the spread markers + // (added next, above the annotation manager's siblings) hide the outer + // endpoints. Static filter selects LineString geometry only so Point + // features in the same source don't try to render as zero-length lines. + await _mapController!.addLineLayer( + _spiderSourceId, + _spiderLineLayerId, + const LineLayerProperties( + lineColor: '#888888', + lineWidth: 1.0, + lineOpacity: 0.7, + lineCap: 'round', + ), + filter: [ + '==', + ['geometry-type'], + 'LineString' + ], + belowLayerId: _repeaterIndividualLayerId, + ); + + // Layer 5: spider symbols (Point features). Same icon/text styling as + // the individual repeater layer so spread markers look identical to the + // originals. Inserted just below the symbol annotation manager — that + // puts it ABOVE the individual repeater layer (so spread markers win + // hit-tests against the now-hidden originals) but BELOW the GPS marker + // / coverage symbols on the annotation manager. iconAllowOverlap + + // iconIgnorePlacement are critical: without them MapLibre's collision + // detector hides adjacent spread markers and defeats the spread. + await _mapController!.addSymbolLayer( + _spiderSourceId, + _spiderSymbolLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: [ + '==', + ['geometry-type'], + 'Point' + ], + belowLayerId: belowLayer, + ); + + // All 3 layers + source + spider source/layers created successfully — + // mark ready so the build()-triggered post-frame sync can run, and so + // _syncRepeaterSymbols is allowed to push data via setGeoJsonSource. _clusterLayersReady = true; } catch (e) { debugError('[MAP] Failed to set up repeater cluster layers: $e'); @@ -2149,6 +2400,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Pushes the current repeater state into the cluster source. MapLibre /// re-clusters natively whenever the source data changes. Replaces the /// previous per-symbol addSymbol/updateSymbol/removeSymbol diff loop. + /// + /// When a spider is open, also schedules a post-frame push of the spider + /// shadow source. Deferring one frame avoids an iOS race where the main + /// source's reclustering and the spider source's symbol render arrive in + /// different frames, briefly showing originals + spread together. Future _syncRepeaterSymbols(AppStateProvider appState) async { if (_mapController == null || !_styleLoaded || @@ -2162,6 +2418,316 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (e) { debugError('[MAP] Failed to update repeater source: $e'); } + // Also push the spider source. Empty FeatureCollection if no spider open. + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _syncSpiderSymbols(appState, currentZoom); + }); + } + + // --------------------------------------------------------------------------- + // Spiderfy helpers — see plan: stacked repeaters fan out around the centroid + // when a tap can't be resolved by zooming further. + // --------------------------------------------------------------------------- + + /// Web-Mercator metres-per-pixel at the given latitude and zoom. + double _metersPerPxAtZoom(double latDeg, num zoom) { + return 156543.03392 * + math.cos(latDeg * math.pi / 180) / + math.pow(2, zoom); + } + + /// Great-circle distance between two LatLngs in metres (haversine). + /// Used for the spider candidate / connectivity tests — accurate at any + /// latitude, including the poles. + double _haversineMeters(LatLng a, LatLng b) { + const earthRadiusM = 6378137.0; + final lat1 = a.latitude * math.pi / 180; + final lat2 = b.latitude * math.pi / 180; + final dLat = (b.latitude - a.latitude) * math.pi / 180; + final dLon = (b.longitude - a.longitude) * math.pi / 180; + final h = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1) * + math.cos(lat2) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return 2 * + earthRadiusM * + math.asin(math.min(1.0, math.sqrt(h))); + } + + /// Maximum zoom the camera can reach (matches `minMaxZoomPreference`). + static const double _maxUserZoom = 17.0; + + /// True when the camera is at (or floating-point close to) the user's + /// hard zoom cap. Spider expansion is gated on this — at any lower zoom + /// taps just zoom in further so the user has a chance to separate the + /// stack visually before falling back to the spider UI. + bool _isAtMaxZoom() { + final z = _mapController?.cameraPosition?.zoom; + if (z == null) return false; + // Small epsilon: zoom can settle at e.g. 16.997 even when the user has + // pinched all the way in; treat that as max. + return z >= _maxUserZoom - 0.05; + } + + /// Find the connected component of repeaters around [anchor] that would + /// still visually overlap at [_maxUserZoom] (the "won't break apart by + /// more zoom" group). + /// + /// Two repeaters are linked when their geographic distance is ≤ the + /// "stick threshold" — `_clusterRadiusPx × metres-per-pixel at the max + /// zoom`. At lat 45° this is ~42 m. Markers within this distance of + /// each other are within the cluster radius even at the user's deepest + /// zoom, so zooming will not separate them visually. + /// + /// BFS seed: repeater closest to [anchor]. Returns at least the seed when + /// any repeater is within the broad search disc; caller should treat a + /// result of length < 2 as "no spiderfy needed". + List _findSpiderGroup(LatLng anchor, AppStateProvider appState) { + final mPerPxMaxZoom = + _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; + + // Broad initial radius: 10× the stick threshold so we don't miss an + // indirect CC member that's near another member but far from the anchor. + // Bounded fixed multiplier — keeps the candidate set small even when a + // user taps a continent-scale cluster at low zoom. + final broadRadiusM = stickThresholdM * 10; + final candidates = []; + for (final r in appState.repeaters) { + if (_haversineMeters(anchor, LatLng(r.lat, r.lon)) <= broadRadiusM) { + candidates.add(r); + } + } + if (candidates.isEmpty) return const []; + + // Pick the closest-to-anchor as the BFS seed. + Repeater seed = candidates.first; + var bestD = double.infinity; + for (final r in candidates) { + final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); + if (d < bestD) { + bestD = d; + seed = r; + } + } + + // Connected component (single-link clustering at stick threshold). + final visited = {seed.id}; + final queue = [seed]; + final result = [seed]; + while (queue.isNotEmpty) { + final cur = queue.removeAt(0); + final curPos = LatLng(cur.lat, cur.lon); + for (final r in candidates) { + if (visited.contains(r.id)) continue; + if (_haversineMeters(curPos, LatLng(r.lat, r.lon)) <= + stickThresholdM) { + visited.add(r.id); + result.add(r); + queue.add(r); + } + } + } + return result; + } + + /// Layout the [n] spread positions around [center]. Uses a single ring up to + /// 8 markers, two concentric rings up to 20, and a Fermat / golden-angle + /// spiral past 20. + List _computeSpiderRing( + LatLng center, int n, double currentZoom) { + final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); + final lat0 = center.latitude; + final lon0 = center.longitude; + final cosLat = math.cos(lat0 * math.pi / 180); + + // Convert (dx_px, dy_px) — screen-space offset from centre — back to a + // geographic LatLng. Screen y grows downward; flip dy so positive screen-y + // maps to a southward (lower-latitude) offset. + LatLng offset(double dxPx, double dyPx) { + final dxM = dxPx * mPerPx; + final dyM = dyPx * mPerPx; + final dLat = -dyM / 111320; + final dLon = dxM / (111320 * cosLat); + return LatLng(lat0 + dLat, lon0 + dLon); + } + + final positions = []; + if (n <= 8) { + // Single ring at the inner radius, evenly spaced from top (-π/2). + for (var i = 0; i < n; i++) { + final angle = -math.pi / 2 + 2 * math.pi * i / n; + positions.add(offset( + _spiderInnerRadiusPx * math.cos(angle), + _spiderInnerRadiusPx * math.sin(angle), + )); + } + } else if (n <= 20) { + // Two concentric rings: 7 inner + (n−7) outer with a half-angle stagger + // so outer markers don't sit directly behind inner ones. + const innerCount = 7; + final outerCount = n - innerCount; + for (var i = 0; i < innerCount; i++) { + final angle = -math.pi / 2 + 2 * math.pi * i / innerCount; + positions.add(offset( + _spiderInnerRadiusPx * math.cos(angle), + _spiderInnerRadiusPx * math.sin(angle), + )); + } + for (var i = 0; i < outerCount; i++) { + final angle = -math.pi / 2 + + math.pi / outerCount + + 2 * math.pi * i / outerCount; + positions.add(offset( + _spiderOuterRadiusPx * math.cos(angle), + _spiderOuterRadiusPx * math.sin(angle), + )); + } + } else { + // Golden-angle spiral — markers self-space without overlap. + const goldenAngle = 137.508 * math.pi / 180; + for (var i = 0; i < n; i++) { + final angle = i * goldenAngle - math.pi / 2; + final r = 30 + 9 * math.sqrt(i + 1); + positions.add(offset( + r * math.cos(angle), + r * math.sin(angle), + )); + } + } + return positions; + } + + /// Build the spider shadow source's FeatureCollection — Point features for + /// every spread marker plus LineString features for the leader lines. + /// Returns an empty collection when no spider is open. + Map _buildSpiderFeatureCollection( + AppStateProvider appState, double currentZoom) { + if (_spiderCenter == null || _spiderRepeaters.isEmpty) { + return { + 'type': 'FeatureCollection', + 'features': >[] + }; + } + final center = _spiderCenter!; + final positions = + _computeSpiderRing(center, _spiderRepeaters.length, currentZoom); + final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); + final cosLat = math.cos(center.latitude * math.pi / 180); + + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + final features = >[]; + for (var i = 0; i < _spiderRepeaters.length; i++) { + final repeater = _spiderRepeaters[i]; + final pos = positions[i]; + + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final effectiveBytes = hopOverride ?? repeater.hopBytes; + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final iconImage = _MapImages.repeater(statusKey, shapeBytes); + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + }, + 'geometry': { + 'type': 'Point', + 'coordinates': [pos.longitude, pos.latitude], + }, + }); + + // Leader line — shortened by `_leaderLineEndShortenPx` at the marker + // end so it doesn't punch through the icon / label halo. Compute the + // shortening in screen-pixel space, then convert back to lat/lon. + final dxM = + (pos.longitude - center.longitude) * 111320 * cosLat; + final dyM = (pos.latitude - center.latitude) * 111320; + final dxPx = dxM / mPerPx; + final dyPx = dyM / mPerPx; + final lenPx = math.sqrt(dxPx * dxPx + dyPx * dyPx); + if (lenPx <= _leaderLineEndShortenPx) continue; + final scale = (lenPx - _leaderLineEndShortenPx) / lenPx; + final endLon = + center.longitude + (pos.longitude - center.longitude) * scale; + final endLat = + center.latitude + (pos.latitude - center.latitude) * scale; + features.add({ + 'type': 'Feature', + 'properties': {'repeaterId': repeater.id}, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [center.longitude, center.latitude], + [endLon, endLat], + ], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Push the current spider state to the spider GeoJSON source. Called from + /// `_syncRepeaterSymbols` (post-frame) and after spider state mutations. + Future _syncSpiderSymbols( + AppStateProvider appState, double currentZoom) async { + if (_mapController == null || !_clusterLayersReady) return; + try { + final geojson = + _buildSpiderFeatureCollection(appState, currentZoom); + await _mapController!.setGeoJsonSource(_spiderSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update spider source: $e'); + } + } + + /// Open the spider — fan out [repeaters] around [center]. No-op for groups + /// of fewer than 2 (caller should fall through to detail-sheet/zoom paths). + void _spiderfy(LatLng center, List repeaters) { + if (repeaters.length < 2 || _mapController == null || !mounted) return; + setState(() { + _spiderCenter = center; + _spiderRepeaters = List.unmodifiable(repeaters); + _spiderOpenedAtZoom = _mapController!.cameraPosition?.zoom; + }); + debugLog( + '[MAP] Spider opened with ${repeaters.length} markers at ${center.latitude.toStringAsFixed(5)},${center.longitude.toStringAsFixed(5)}'); + // Resync the main source (so the inSpider tag hides originals on the + // individual layer) and the spider source (post-frame inside the sync). + _syncRepeaterSymbols(context.read()); + } + + /// Close the spider — clears state and resyncs both sources. + void _collapseSpider() { + if (_spiderCenter == null || !mounted) return; + setState(() { + _spiderCenter = null; + _spiderRepeaters = const []; + _spiderOpenedAtZoom = null; + }); + debugLog('[MAP] Spider collapsed'); + _syncRepeaterSymbols(context.read()); } /// Composite key for a coverage marker symbol — kind + timestamp ms + lat/lon. From 6995cdcdb5cb40e02403be23299d982c79625fd8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 26 Apr 2026 23:09:34 -0400 Subject: [PATCH 085/360] - Fixed the GPS marker drifting toward the top of the map (and off-screen entirely in heading mode) when the bottom controls panel is expanded with auto-follow enabled. The centring offset was using a 256px tile assumption while MapLibre projects onto a 512px tile grid, so every padding-aware shift was 2x too large. --- lib/widgets/map_widget.dart | 103 ++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 35 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 8beb9efb..3d161178 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -331,6 +331,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { false; // Track if we've done the one-time initial zoom to GPS bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _loggedMpxSanityCheck = + false; // One-time log comparing formula vs MapLibre m/px // Map rotation mode bool _alwaysNorth = @@ -575,8 +577,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void didUpdateWidget(MapWidget oldWidget) { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following - if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + final paddingChanged = + widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || + widget.rightPaddingPixels != oldWidget.rightPaddingPixels; + if (paddingChanged) { + debugLog('[MAP CENTER] didUpdateWidget padding change: ' + 'bottom ${oldWidget.bottomPaddingPixels}->${widget.bottomPaddingPixels} ' + 'right ${oldWidget.rightPaddingPixels}->${widget.rightPaddingPixels} ' + '_autoFollow=$_autoFollow _isMapReady=$_isMapReady ' + '_lastGpsPosition=${_lastGpsPosition != null}'); + } + if (paddingChanged && _autoFollow && _isMapReady && _lastGpsPosition != null) { @@ -596,6 +607,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { targetZoom, targetBearing, ); + final cam = _mapController?.cameraPosition; + debugLog('[MAP CENTER] re-center after padding change: ' + 'gps=(${_lastGpsPosition!.latitude.toStringAsFixed(6)},${_lastGpsPosition!.longitude.toStringAsFixed(6)}) ' + 'target=(${adjustedPosition.latitude.toStringAsFixed(6)},${adjustedPosition.longitude.toStringAsFixed(6)}) ' + 'deltaLat=${(adjustedPosition.latitude - _lastGpsPosition!.latitude).toStringAsFixed(6)} ' + 'deltaLon=${(adjustedPosition.longitude - _lastGpsPosition!.longitude).toStringAsFixed(6)} ' + 'zoom=${targetZoom.toStringAsFixed(2)} bearing=${targetBearing.toStringAsFixed(2)} ' + 'curZoom=${cam?.zoom.toStringAsFixed(2)} curBearing=${cam?.bearing.toStringAsFixed(2)} ' + 'alwaysNorth=$_alwaysNorth'); _animateAutoFollowCamera( target: adjustedPosition, zoom: targetZoom, @@ -790,48 +810,61 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (_mapController == null || !_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; - // Get meters per pixel at the target zoom (or current camera zoom). - // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) + // MapLibre's internal projection uses 512-logical-px tile units (see + // MapLibre style spec — vector and raster sources are reprojected onto a + // 512px grid regardless of source tile size). The previous formula here + // assumed 256-px tiles, which made every offset 2× too large and pushed + // the GPS marker far above the visible-area centre. final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / - (256 * math.pow(2, zoom)) * + (512 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); - // Start with the offset expressed as if the map were north-up - // (bearing = 0): bottom padding shifts the target geographic-south, - // right padding shifts the target geographic-west. - double latOffset = 0; - double lonOffset = 0; - if (bottomPadding > 0) { - final meterOffset = (bottomPadding / 2) * metersPerPixel; - latOffset = -(meterOffset / 111000); // ~111km per degree latitude - } - if (rightPadding > 0) { - final meterOffset = (rightPadding / 2) * metersPerPixel; - lonOffset = -(meterOffset / - (111000 * math.cos(position.latitude * math.pi / 180))); + // One-time sanity check: log MapLibre's authoritative m/px and compare + // to our formula. If they differ, the tile-size assumption is wrong. + if (!_loggedMpxSanityCheck) { + _loggedMpxSanityCheck = true; + _mapController! + .getMetersPerPixelAtLatitude(position.latitude) + .then((mapLibreMpx) { + debugLog('[MAP CENTER] m/px sanity: formula=${metersPerPixel.toStringAsFixed(4)} ' + 'maplibre=${mapLibreMpx.toStringAsFixed(4)} ' + 'ratio=${(metersPerPixel / mapLibreMpx).toStringAsFixed(3)} ' + '(zoom=${zoom.toStringAsFixed(2)} lat=${position.latitude.toStringAsFixed(4)})'); + }).catchError((e) { + debugLog('[MAP CENTER] m/px sanity check failed: $e'); + }); } - // When the map is rotated, "screen-down" no longer points geographic - // south — it points wherever bearing + 180° aims. Rotate the offset - // vector so the shift still lands in the correct screen direction. + // Compute the desired camera shift in WORLD METERS along the + // (north, east) axes. Working in metres up front avoids the previous + // unit-mixing bug, where lat-degrees and lon-degrees were rotated as if + // they were the same unit (1° lat ≠ 1° lon away from the equator). // - // MapLibre bearing is clockwise from north (heading east => bearing 90, - // screen-down => world-west). To send a south-pointing input vector to - // the world direction that corresponds to screen-down at the given - // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not - // -bearing as the previous implementation did. + // We want the marker (at `position`) to appear shifted "screen-up" by + // `bottomPadding/2` and "screen-left" by `rightPadding/2` relative to + // screen centre, so the camera target itself shifts in the opposite + // direction (screen-down + screen-right). final bearingDeg = atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; - if (bearingDeg.abs() > 0.1) { - final rotationRad = bearingDeg * math.pi / 180; - final cosR = math.cos(rotationRad); - final sinR = math.sin(rotationRad); - final rotatedLat = latOffset * cosR - lonOffset * sinR; - final rotatedLon = latOffset * sinR + lonOffset * cosR; - latOffset = rotatedLat; - lonOffset = rotatedLon; - } + final bearingRad = bearingDeg * math.pi / 180; + final cosB = math.cos(bearingRad); + final sinB = math.sin(bearingRad); + + // At bearing β (clockwise from north), world unit-vectors of the + // screen axes are: + // screen-down = (-cosβ, -sinβ) in (north, east) + // screen-right = (-sinβ, cosβ) + final downMetres = bottomPadding / 2 * metersPerPixel; + final rightMetres = rightPadding / 2 * metersPerPixel; + final northShift = downMetres * -cosB + rightMetres * -sinB; + final eastShift = downMetres * -sinB + rightMetres * cosB; + + // Convert metres → degrees. 1° latitude ≈ 111 km everywhere; 1° longitude + // shrinks by cos(latitude). + final latOffset = northShift / 111000; + final lonOffset = + eastShift / (111000 * math.cos(position.latitude * math.pi / 180)); return LatLng( position.latitude + latOffset, position.longitude + lonOffset); From 6d13f45c45785061d53b2b00d970b1ba9b2b7817 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Apr 2026 22:25:54 -0400 Subject: [PATCH 086/360] =?UTF-8?q?-=20**RX=20Hop=20Path:**=20Tapping=20an?= =?UTF-8?q?=20RX=20marker=20now=20shows=20the=20full=20mesh=20path=20the?= =?UTF-8?q?=20packet=20travelled=20(origin=20=E2=86=92=20...=20=E2=86=92?= =?UTF-8?q?=20us)=20as=20a=20tappable=20chain=20of=20repeater=20chips,=20w?= =?UTF-8?q?ith=20the=20receiving=20repeater=20tagged=20"(heard)".=20Each?= =?UTF-8?q?=20hop=20chip=20opens=20the=20standard=20repeater=20info=20popu?= =?UTF-8?q?p=20so=20you=20can=20resolve=20names=20and=20distances=20along?= =?UTF-8?q?=20the=20path=20without=20leaving=20the=20sheet.=20The=20same?= =?UTF-8?q?=20chain=20renders=20on=20every=20RX=20entry=20in=20the=20Log?= =?UTF-8?q?=20tab.=20If=20your=20own=20carpeater=20was=20on=20the=20tail,?= =?UTF-8?q?=20it's=20stripped=20from=20the=20chain=20just=20like=20it's=20?= =?UTF-8?q?stripped=20from=20the=20displayed=20last-hop=20ID.=20CSV=20expo?= =?UTF-8?q?rt=20gains=20a=20`path=5Fhops`=20column=20on=20RX=20rows.=20Pat?= =?UTF-8?q?h=20data=20is=20transient=20and=20lost=20on=20app=20restart,=20?= =?UTF-8?q?matching=20how=20TX=20heard-repeater=20data=20already=20works.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/log_entry.dart | 6 ++- lib/models/ping_data.dart | 5 ++ lib/providers/app_state_provider.dart | 4 ++ lib/screens/log_screen.dart | 61 ++++++++++++++++++--- lib/services/meshcore/rx_logger.dart | 22 ++++++++ lib/widgets/map_widget.dart | 35 ++++++++++++ lib/widgets/rx_path_chain.dart | 78 +++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/rx_path_chain.dart diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 2fe84af8..9afe5650 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -81,6 +81,8 @@ class RxLogEntry { final int header; // Packet header byte final double latitude; final double longitude; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List pathHops; RxLogEntry({ required this.timestamp, @@ -91,6 +93,7 @@ class RxLogEntry { required this.header, required this.latitude, required this.longitude, + this.pathHops = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -120,9 +123,10 @@ class RxLogEntry { /// Get CSV row String toCsv() { + final pathStr = pathHops.isEmpty ? '' : pathHops.join('|'); return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' - '$latitude,$longitude'; + '$latitude,$longitude,$pathStr'; } } diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 9d7e1051..9cd326ee 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -87,6 +87,10 @@ class RxPing { @HiveField(5) final int rssi; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + /// Transient (not persisted to Hive); empty when reloaded from disk. + final List pathHops; + const RxPing({ required this.latitude, required this.longitude, @@ -94,6 +98,7 @@ class RxPing { required this.timestamp, required this.snr, required this.rssi, + this.pathHops = const [], }); Map toApiJson() { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7c6d2729..21424b22 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2131,6 +2131,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { timestamp: DateTime.now(), snr: observation.snr ?? 0.0, rssi: observation.rssi ?? 0, + pathHops: observation.displayHops, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -2209,6 +2210,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, + pathHops: existingPin.pathHops, ); debugLog( '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' @@ -2227,6 +2229,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { timestamp: entry.timestamp, snr: entry.snr ?? 0.0, rssi: entry.rssi ?? 0, + pathHops: entry.displayHops, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -2251,6 +2254,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { header: entry.header, latitude: entry.lat, longitude: entry.lon, + pathHops: entry.displayHops, ); // Add to RX log entries diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 425a0a13..16a88b33 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -7,6 +7,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; +import '../widgets/rx_path_chain.dart'; /// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { @@ -175,7 +176,7 @@ class _LogScreenState extends State if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); buffer.writeln( - 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude,path_hops'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -676,6 +677,15 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- + /// Width of the "Node" column sized from the hex-ID length actually being + /// rendered. RX paths can carry longer IDs than the global TX hop-byte + /// setting, so we measure per-card instead of per-session. + double _nodeColumnWidthForLength(int idLength) { + if (idLength <= 2) return 60; + if (idLength <= 4) return 70; + return 80; + } + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); @@ -716,6 +726,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { } Widget _buildRepeaterTable(BuildContext context, List events) { + var maxIdLen = 0; + for (final e in events) { + if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; + } + final nodeWidth = _nodeColumnWidthForLength(maxIdLen); + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -730,20 +746,22 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), + SizedBox(width: nodeWidth, child: _tableHeader(context, 'Node')), Expanded(child: _tableHeader(context, 'SNR', center: true)), Expanded(child: _tableHeader(context, 'RSSI', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...events.map((event) => _buildTxRepeaterRow(context, event)), + ...events.map( + (event) => _buildTxRepeaterRow(context, event, nodeWidth)), ], ), ); } - Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + Widget _buildTxRepeaterRow( + BuildContext context, RxEvent event, double nodeWidth) { final snrColor = _snrColor(event.severity); final rssiColor = _rssiColor(event.rssi); return InkWell( @@ -753,7 +771,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Row( children: [ RepeaterIdChip( - repeaterId: event.repeaterId, fontSize: 14, width: 60), + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth), Expanded( child: Center( child: _buildChip( @@ -778,6 +798,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); + final nodeWidth = _nodeColumnWidthForLength(entry.repeaterId.length); return Card( margin: const EdgeInsets.only(bottom: 8), @@ -813,7 +834,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Row( children: [ SizedBox( - width: 60, child: _tableHeader(context, 'Node')), + width: nodeWidth, + child: _tableHeader(context, 'Node')), Expanded( child: _tableHeader(context, 'SNR', center: true)), @@ -835,7 +857,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: entry.repeaterId, fontSize: 14, - width: 60), + width: nodeWidth), Expanded( child: Center( child: _buildChip( @@ -855,6 +877,31 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), + if (entry.pathHops.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.route, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Expanded( + child: RxPathChain( + hops: entry.pathHops, + fromLatLng: + (lat: entry.latitude, lon: entry.longitude), + fontSize: 12, + ), + ), + ], + ), + ], ], ), ), diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index c78829a2..93074456 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -150,6 +150,15 @@ class RxLogger { debugLog('[RX LOG] ✅ Packet validated and passed filter'); + // Build display path: full hop chain, minus the last hop when it was a + // carpeater pass-through (so the user's own carpeater is not shown). + final displayHopCount = carpeaterStripped + ? metadata.pathHashCount - 1 + : metadata.pathHashCount; + final displayHops = [ + for (var i = 0; i < displayHopCount; i++) metadata.getHopHex(i)!, + ]; + // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, @@ -161,6 +170,7 @@ class RxLogger { lon: gpsLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); // Handle tracking for API (best SNR with distance trigger) @@ -173,6 +183,7 @@ class RxLogger { header: metadata.header, currentLocation: gpsLocation, metadata: metadata, + displayHops: displayHops, ); // Only fire immediate callback if this observation was actually kept @@ -211,6 +222,7 @@ class RxLogger { required int header, required ({double lat, double lon}) currentLocation, required PacketMetadata metadata, + required List displayHops, }) async { // Get or create buffer entry for this repeater RxBatch? buffer = _batchBuffer[repeaterId]; @@ -230,6 +242,7 @@ class RxLogger { lon: currentLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ), ); _batchBuffer[repeaterId] = buffer; @@ -266,6 +279,7 @@ class RxLogger { lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); wasKept = true; // Better SNR, observation is kept } else { @@ -371,6 +385,7 @@ class RxLogger { header: best.header, timestamp: best.timestamp, metadata: best.metadata, + displayHops: best.displayHops, ); debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' @@ -477,6 +492,9 @@ class RxObservation { final double lon; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped (the + /// user's own carpeater, when present, has been removed from the tail). + final List displayHops; RxObservation({ required this.repeaterId, @@ -488,6 +506,7 @@ class RxObservation { required this.lon, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } @@ -502,6 +521,8 @@ class RxApiEntry { final int header; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List displayHops; RxApiEntry({ required this.repeaterId, @@ -513,5 +534,6 @@ class RxApiEntry { required this.header, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3d161178..1990532d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -20,6 +20,7 @@ import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; +import 'rx_path_chain.dart'; /// Satellite style as inline MapLibre style JSON (ArcGIS raster source). /// The `glyphs` URL is required because our native symbol layers @@ -5532,6 +5533,40 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + + // Path section (origin → ... → us). Skipped when the path is + // unavailable, e.g. RxPings reloaded from Hive (transient field). + if (ping.pathHops.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Path', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: RxPathChain( + hops: ping.pathHops, + fromLatLng: (lat: ping.latitude, lon: ping.longitude), + fontSize: 13, + ), + ), + ], ], ), ), diff --git a/lib/widgets/rx_path_chain.dart b/lib/widgets/rx_path_chain.dart new file mode 100644 index 00000000..9962d2c8 --- /dev/null +++ b/lib/widgets/rx_path_chain.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'repeater_id_chip.dart'; + +/// Compact arrow-chain renderer for an RX packet's hop path. +/// +/// Renders `[hop] → [hop] → [hop] (heard)` with each hop as a tappable +/// [RepeaterIdChip]. Long paths wrap to multiple lines via [Wrap]. +class RxPathChain extends StatelessWidget { + /// Hop list ordered origin → ... → us. Already CARpeater-stripped. + final List hops; + + /// Source GPS coordinates passed through to the repeater info popup so + /// distances are measured from the ping location, not the user's current + /// position. + final ({double lat, double lon})? fromLatLng; + + /// Font size for hop chips (11 for log screens, 13 for map popups). + final double fontSize; + + const RxPathChain({ + super.key, + required this.hops, + this.fromLatLng, + this.fontSize = 12, + }); + + @override + Widget build(BuildContext context) { + if (hops.isEmpty) return const SizedBox.shrink(); + + final scheme = Theme.of(context).colorScheme; + final arrow = Icon( + Icons.arrow_forward, + size: fontSize, + color: scheme.onSurfaceVariant.withValues(alpha: 0.7), + ); + + final children = []; + for (var i = 0; i < hops.length; i++) { + if (i > 0) children.add(arrow); + children.add( + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, + hops[i], + fromLatLng: fromLatLng, + ), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: RepeaterIdChip(repeaterId: hops[i], fontSize: fontSize), + ), + ), + ); + } + children.add( + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '(heard)', + style: TextStyle( + fontSize: fontSize - 2, + fontStyle: FontStyle.italic, + color: scheme.onSurfaceVariant, + ), + ), + ), + ); + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2, + runSpacing: 4, + children: children, + ); + } +} From dba07f9f4128844e79037067426817f0c427d030 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Apr 2026 22:41:14 -0400 Subject: [PATCH 087/360] - Fixed spiderfy pulling in markers from neighbouring clusters when tapping a bubble at max zoom. The fan now matches the count shown on the bubble exactly. --- lib/widgets/map_widget.dart | 165 +++++++++++++++++++++++++++--------- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1990532d..e7202d9e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1506,44 +1506,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - // Spiderfy is gated to max zoom. Below that, give the user a chance - // to separate the stack by zooming further before we resort to the - // spread UI. Note that `_spiderCenter` is always null at non-max - // zoom (the camera-change collapse fires when the user zooms out - // from max), so no collapse-handling is needed in this branch. - if (!_isAtMaxZoom()) { - if (_canAnimateCamera) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, _maxUserZoom); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); - } - return; - } - - final appState = context.read(); - final group = _findSpiderGroup(coordinates, appState); - - // Re-tap on the open spider's own group → collapse instead of churn. - if (_spiderCenter != null) { - final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); - if (group.any((r) => spiderIds.contains(r.id))) { - _collapseSpider(); - return; - } - // Tapped a different cluster — close the existing spider before - // evaluating the new tap. - _collapseSpider(); - } - - if (group.length >= 2) { - _spiderfy(coordinates, group); - } - // Already at max zoom with a single-marker group: nothing useful to - // zoom into and no stack to spread. Silent no-op. + _handleClusterBubbleTap(point, coordinates); return; } @@ -1597,6 +1560,92 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Handle a tap on a cluster bubble (or its count label). Spiderfy is gated + /// to max zoom; below that we just zoom in. + /// + /// At max zoom, the spider group is computed from the actual MapLibre + /// cluster's `point_count` (queried back from the rendered feature) — we + /// take the N nearest repeaters to the tapped coordinate. This deliberately + /// avoids the transitive single-link clustering used by [_findSpiderGroup], + /// which could chain across separate visual clusters when markers form a + /// 42m-spaced trail and pull every chained marker into a single spider. + Future _handleClusterBubbleTap( + math.Point point, LatLng coordinates) async { + if (!mounted) return; + + // Below max zoom: zoom in further so the user has a chance to separate + // the stack visually before we resort to the spread UI. _spiderCenter is + // always null at non-max zoom (the camera-change collapse fires when the + // user zooms out), so no collapse-handling is needed here. + if (!_isAtMaxZoom()) { + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; + } + + // Read the tapped cluster's point_count from MapLibre. This is the + // authoritative count of leaves Supercluster grouped into this bubble — + // matching it ensures the spider expands exactly the markers represented + // by the tapped bubble, not a chained connected component. + int? pointCount; + try { + final features = await _mapController?.queryRenderedFeatures( + point, + const [ + _repeaterClusterBubbleLayerId, + _repeaterClusterCountLayerId, + ], + null, + ); + if (features != null) { + for (final f in features) { + final props = ((f as Map)['properties'] as Map?) ?? const {}; + if (props['cluster'] == true) { + final pc = props['point_count']; + if (pc is num) { + pointCount = pc.toInt(); + break; + } + } + } + } + } catch (e) { + debugError('[MAP] cluster point_count query failed: $e'); + } + + if (!mounted) return; + + final appState = context.read(); + // If we couldn't read point_count (race with style reload, etc.), fall + // back to the BFS-based group — better to spiderfy something than nothing. + final group = pointCount != null + ? _findSpiderGroupForCluster(coordinates, pointCount, appState) + : _findSpiderGroup(coordinates, appState); + + // Re-tap on the open spider's own group → collapse instead of churn. + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + _collapseSpider(); + } + + if (group.length >= 2) { + _spiderfy(coordinates, group); + } + // Already at max zoom with a single-marker group: nothing useful to + // zoom into and no stack to spread. Silent no-op. + } + /// When a tap hits the GPS marker (which has no detail sheet), try to find /// any repeater cluster or individual repeater under the same point and /// dispatch THAT instead. We use [queryRenderedFeatures] explicitly scoped @@ -1631,7 +1680,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Cluster (auto-tagged by MapLibre when cluster: true is set on source). // Mirrors the cluster path in _handleFeatureTap, including the - // max-zoom gate on spiderfy. + // max-zoom gate on spiderfy. We already have the feature in hand here, + // so read `point_count` directly instead of re-querying. if (properties['cluster'] == true) { if (!_isAtMaxZoom()) { if (_canAnimateCamera) { @@ -1646,7 +1696,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } final appState = context.read(); - final group = _findSpiderGroup(coordinates, appState); + final pcRaw = properties['point_count']; + final pointCount = pcRaw is num ? pcRaw.toInt() : null; + final group = pointCount != null + ? _findSpiderGroupForCluster(coordinates, pointCount, appState) + : _findSpiderGroup(coordinates, appState); if (_spiderCenter != null) { final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); if (group.any((r) => spiderIds.contains(r.id))) { @@ -2569,6 +2623,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return result; } + /// Find the [pointCount] repeaters nearest to [anchor], used when expanding + /// a tapped MapLibre cluster bubble. + /// + /// Unlike [_findSpiderGroup] (transitive BFS connected component), this is + /// a non-transitive proximity query — it cannot chain across separate + /// visual clusters. The count comes from Supercluster's `point_count` on + /// the tapped feature, so the result matches the bubble exactly in every + /// realistic case (centroid drift in extreme layouts may shuffle a 1–2 + /// marker boundary, but the spider count is always correct). + List _findSpiderGroupForCluster( + LatLng anchor, int pointCount, AppStateProvider appState) { + if (pointCount <= 0) return const []; + // Bound the candidate set with a generous radius. Supercluster's max + // cluster diameter at maxZoom is ~2× the cluster radius (centroid + // drift); pointCount × stickThreshold is a comfortable upper bound for + // any plausible cluster size. + final mPerPxMaxZoom = + _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; + final broadRadiusM = stickThresholdM * math.max(10, pointCount); + final candidates = >[]; + for (final r in appState.repeaters) { + final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); + if (d <= broadRadiusM) { + candidates.add(MapEntry(r, d)); + } + } + candidates.sort((a, b) => a.value.compareTo(b.value)); + return candidates.take(pointCount).map((e) => e.key).toList(); + } + /// Layout the [n] spread positions around [center]. Uses a single ring up to /// 8 markers, two concentric rings up to 20, and a Fermat / golden-angle /// spiral past 20. From c05cbeafc7cb3aca2a53ab6d3bd3af98e07fd412 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Apr 2026 22:51:54 -0400 Subject: [PATCH 088/360] - **Stale Repeater Filtering:** Repeaters not heard in the past 30 days are now hidden from the map. They don't render as markers, don't contribute to cluster bubble counts, and don't get pulled into spider expansions. The repeater picker (trace mode) and Log tab still list every repeater the server returns, so nothing is lost from the data view, it's a map-only declutter. --- lib/models/repeater.dart | 9 +++++++++ lib/widgets/map_widget.dart | 21 +++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index bbf76bd1..4d67139d 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -136,6 +136,15 @@ class Repeater { /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; + /// True if the repeater has been heard within the past 30 days. Used by + /// the map to hide long-stale repeaters. Returns false when [lastHeard] + /// is 0 (never heard). + bool get isHeardRecently { + if (lastHeard == 0) return false; + final heard = DateTime.fromMillisecondsSinceEpoch(lastHeard * 1000); + return DateTime.now().difference(heard).inDays < 30; + } + /// Get display hex ID based on hop bytes (or override). /// [overrideHopBytes] is used when regional admin enforces a byte size. String displayHexId({int? overrideHopBytes}) { diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e7202d9e..00444421 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1758,7 +1758,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.repeaters.where((r) => r.id == repeaterId).firstOrNull; if (repeater == null) return; - final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); final isDuplicate = duplicates.contains(repeater.id); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; @@ -2204,7 +2204,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// the marker data version changes — MapLibre handles re-clustering natively. Map _buildRepeaterFeatureCollection( AppStateProvider appState) { - final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final visible = _mapVisibleRepeaters(appState); + final duplicates = _getDuplicateRepeaterIds(visible); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; final focusActive = _focusedPingLocation != null; @@ -2216,7 +2217,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); final features = >[]; - for (final repeater in appState.repeaters) { + for (final repeater in visible) { final isDuplicate = duplicates.contains(repeater.id); final statusKey = _repeaterStatusKey(repeater, isDuplicate); final isConnected = focusActive && @@ -2585,7 +2586,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // user taps a continent-scale cluster at low zoom. final broadRadiusM = stickThresholdM * 10; final candidates = []; - for (final r in appState.repeaters) { + for (final r in _mapVisibleRepeaters(appState)) { if (_haversineMeters(anchor, LatLng(r.lat, r.lon)) <= broadRadiusM) { candidates.add(r); } @@ -2644,7 +2645,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; final broadRadiusM = stickThresholdM * math.max(10, pointCount); final candidates = >[]; - for (final r in appState.repeaters) { + for (final r in _mapVisibleRepeaters(appState)) { final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); if (d <= broadRadiusM) { candidates.add(MapEntry(r, d)); @@ -2738,7 +2739,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); final cosLat = math.cos(center.latitude * math.pi / 180); - final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; @@ -5096,6 +5097,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } + /// Repeaters eligible for map rendering — excludes anything not heard in + /// the past 30 days so long-stale entries don't appear, contribute to + /// clusters, or get pulled into spider expansions. All map-rendering + /// paths route through this; non-map consumers (log, picker) keep using + /// `appState.repeaters` directly. + List _mapVisibleRepeaters(AppStateProvider appState) => + appState.repeaters.where((r) => r.isHeardRecently).toList(); + /// Get marker color for a repeater based on status priority: /// 1. Duplicate → Red (always takes priority) /// 2. Dead → Grey (not heard in 24 hours) From db29741d8186e673fc7f14bde9abf58a68202d1c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 29 Apr 2026 22:50:15 -0400 Subject: [PATCH 089/360] Improvements - Auto-follow zoom is now one-shot: zooms to street level once, then only pans. Pinch-zooming while following no longer fights you. - Reduced per-GPS-tick map work for smoother performance Bug Fixes - Coverage overlay no longer flashes on tile refresh - Repeaters without GPS now show a location-off icon instead of dragging focus mode to (0, 0) - Fixed map labels flickering at max zoom during follow mode --- lib/models/repeater.dart | 7 + lib/providers/app_state_provider.dart | 74 +++- lib/widgets/map_widget.dart | 528 ++++++++++++++++++++++---- 3 files changed, 522 insertions(+), 87 deletions(-) diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index 4d67139d..fdfef513 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -112,6 +112,13 @@ class Repeater { /// Check if the repeater is enabled (any non-zero value) bool get isEnabled => enabled != 0; + /// True when the repeater has known GPS coordinates. The API uses + /// `(0, 0)` as a sentinel for "location not yet published" — those + /// repeaters are excluded from map focus geometry (no line, no + /// distance label, not part of the bounds-fit) but still appear in + /// heard-repeater listings with a `location_off` indicator. + bool get hasLocation => lat != 0.0 || lon != 0.0; + /// Check if the repeater was created within the past 7 days bool get isNew { if (createdAt == null) return false; diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 21424b22..fc64618f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5228,16 +5228,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[ZONE] Requesting auth for zone $newZoneCode'); - final result = await _apiService.requestAuth( + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: newZoneCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? - 'Unknown', + model: modelString, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -5267,10 +5268,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final message = result['message'] as String? ?? 'Auth failed'; debugError( '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', - severity: ErrorSeverity.error); - await disconnect(); - return; + + // Stage 2: unknown_device → register via signed contact URI (mirrors initial connect) + if (reason == 'unknown_device' && _meshCoreConnection != null) { + debugLog( + '[ZONE] Stage 2: Attempting registration via contact_uri for zone $newZoneCode'); + + String? contactUri; + try { + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[ZONE] Received contact URI: ${contactUri.substring(0, contactUri.length < 50 ? contactUri.length : 50)}...'); + } catch (e) { + debugError('[ZONE] Stage 2 failed: could not export contact: $e'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: modelString, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[ZONE] Stage 2 failed: network error'); + logError('Zone transfer failed: unable to register with server', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + if (registerResult['success'] != true) { + final regReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final regMessage = registerResult['message'] as String? ?? + 'Registration rejected by server'; + debugError('[ZONE] Stage 2 failed: $regReason - $regMessage'); + logError('Zone transfer failed: $regMessage', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + debugLog( + '[ZONE] Stage 2 succeeded: registered for zone $newZoneCode'); + result = registerResult; + } else { + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; + } } // 11. Auth succeeded — update session zone code diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 00444421..ba4889ee 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -395,6 +395,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // runs whose remove/add calls interleave and produce "Source already exists" // errors in the native log. bool _coverageRefreshScheduled = false; + + // Double-buffered coverage overlay: each refresh allocates fresh suffixed + // IDs so the new raster source/layer can be added on top of the previous + // one and rendered before the old layer is removed. This prevents the + // brief blank frame the user previously saw every cache-bust cycle. + String? _activeCoverageSourceId; + String? _activeCoverageLayerId; + int _coverageBufferCounter = 0; + // One-shot completer released by _onMapIdle (or the timeout fallback) to + // signal the swap that new tiles have rendered and the old layer is safe + // to remove. Null when no swap is in flight. + Completer? _coverageSwapIdleCompleter; + Timer? _coverageSwapTimeoutTimer; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -412,6 +425,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // advanced during the run and triggers a rebuild if so. bool _syncInFlight = false; + // GPS marker sync runs on its own gate, separate from _syncAllAnnotations. + // GPS position is camera/sensor state — it changes every tick during + // auto-follow but the marker data (repeaters, pings, focus) does not. + // Routing GPS through _syncAllAnnotations made setGeoJsonSource fire on + // every tick, which triggered MapLibre's global symbol-collision recalc + // and made base-style POI labels flicker. Splitting the gates keeps + // _syncGpsSymbol cheap and lets _syncAllAnnotations idle when nothing + // marker-related has changed. + int _lastGpsSyncVersion = -1; + bool _gpsSyncInFlight = false; + // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. bool _tileLoadFailed = false; @@ -436,6 +460,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // "Failed to update repeater source: sourceNotFound" error at startup). bool _clusterLayersReady = false; + // True while the focus-lines source + 1-2 line layers are installed in the + // current style. Lets _updateFocusLines short-circuit when there's nothing + // to remove and nothing to add — touching MapLibre's layer stack (even with + // no-op removes that hit try/catch) crosses the platform channel and can + // nudge the symbol-collision pass. + bool _focusLinesInstalled = false; + // Native annotation tracking — populated by sync methods. // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff // (add new, update existing, remove deleted) on each data version change. @@ -526,6 +557,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); _tileLoadTimeoutTimer?.cancel(); + _coverageSwapTimeoutTimer?.cancel(); + final swapWaiter = _coverageSwapIdleCompleter; + if (swapWaiter != null && !swapWaiter.isCompleted) { + swapWaiter.complete(); + } final controller = _mapController; if (controller != null) { controller.removeListener(_onCameraChanged); @@ -910,7 +946,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - _animateToPositionWithZoom(lastKnownCenter, 15.0); + _animateToPositionWithZoom(lastKnownCenter, 15.0 - _zoomEpsilon); debugLog('[MAP] Initial zoom to last known position'); } }); @@ -938,12 +974,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, - 16.0); - _animateToPositionWithZoom(adjustedPosition, 16.0); + 16.0 - _zoomEpsilon); + _animateToPositionWithZoom( + adjustedPosition, 16.0 - _zoomEpsilon); debugLog( '[MAP] Initial zoom to GPS position (with panel offset)'); } else { - _animateToPositionWithZoom(initialPosition, 16.0); + _animateToPositionWithZoom(initialPosition, 16.0 - _zoomEpsilon); debugLog('[MAP] Initial zoom to GPS position'); } } @@ -1055,7 +1092,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } // Animate to the exact target position (no offset) - _animateToPositionWithZoom(targetPosition, 18.0); + _animateToPositionWithZoom(targetPosition, 18.0 - _zoomEpsilon); } }); } @@ -1099,6 +1136,38 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // GPS marker has its own lightweight gate. Position/heading change every + // GPS tick during auto-follow, but updating the GPS symbol is one cheap + // updateSymbol call — it does not need the heavy _syncAllAnnotations + // pipeline, and routing it through there caused setGeoJsonSource on the + // repeater cluster source to fire every tick, which made MapLibre + // re-run its global symbol collision pass and flickered the base-style + // POI labels at high zoom. The gpsMarkerStyle pref is included so style + // changes (arrow → walk, etc.) re-render the marker's bitmap. + if (_isMapReady && _styleLoaded && _imagesRegistered) { + final gpsVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + if (gpsVersion != _lastGpsSyncVersion) { + _lastGpsSyncVersion = gpsVersion; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + if (_gpsSyncInFlight) return; + _gpsSyncInFlight = true; + try { + await _syncGpsSymbol(appState); + } catch (e) { + debugError('[MAP] _syncGpsSymbol failed: $e'); + } finally { + _gpsSyncInFlight = false; + } + }); + } + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape @@ -1353,7 +1422,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { target: center, zoom: _defaultZoom, ), - minMaxZoomPreference: const MinMaxZoomPreference(3, 17), + minMaxZoomPreference: const MinMaxZoomPreference(3, _maxUserZoom), rotateGesturesEnabled: !_rotationLocked, scrollGesturesEnabled: true, zoomGesturesEnabled: true, @@ -1809,6 +1878,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // syncs from racing ahead of source creation. _clusterLayersReady = false; + // Style reload wipes the native layer stack, so any tracked coverage + // overlay IDs now reference layers that no longer exist. Reset them so + // the next _swapCoverageOverlay treats this as a fresh add (no old + // buffer to retire) instead of attempting a doomed removal. + _activeCoverageSourceId = null; + _activeCoverageLayerId = null; + // Same reasoning for the focus-lines source/layers — gone with the + // style. Clear the flag so _updateFocusLines won't try to remove + // already-gone layers next time it's called. + _focusLinesInstalled = false; + // Disable symbol decluttering on the annotation manager. By default, // MapLibre symbol layers hide overlapping icons/labels at lower zoom to // reduce visual clutter — but for wardriving we want every coverage @@ -1898,6 +1978,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // — that double-sync was racing the first sync's symbol refs and // throwing "you can only set existing annotations" errors twice. _lastMarkerDataVersion = _computeMarkerDataVersion(appState); + // Same idea for the GPS-only sync gate: _syncAllAnnotations already + // ran _syncGpsSymbol, so capture the current GPS version to keep the + // next build from scheduling a redundant updateSymbol call. + _lastGpsSyncVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); if (mounted) setState(() {}); } } finally { @@ -1907,30 +1996,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Fires when the map finishes loading visible tiles and the camera is idle. /// We use this as the "tiles loaded successfully" signal — clears the failure - /// timer and hides any tile-load warning banner. + /// timer and hides any tile-load warning banner. Also releases any pending + /// coverage-overlay swap waiter so the previous buffer can be retired now + /// that the new tiles have rendered. void _onMapIdle() { _tileLoadTimeoutTimer?.cancel(); if (_tileLoadFailed && mounted) { debugLog('[MAP] Tiles recovered after earlier load failure'); setState(() => _tileLoadFailed = false); } + final waiter = _coverageSwapIdleCompleter; + if (waiter != null && !waiter.isCompleted) { + waiter.complete(); + } } /// Fires when the camera stops moving — after both gestures and - /// programmatic animations. While auto-follow is on, we use this as the - /// point to sync our tracked target zoom with whatever zoom the camera - /// actually settled at (e.g. after the user pinch-zoomed). That keeps the - /// next auto-follow GPS tick from snapping the camera back to a stale - /// target zoom. + /// programmatic animations. Auto-follow uses [_autoFollowDesiredZoom] as + /// a *one-shot* zoom target: tap-to-follow sets it to [_maxUserZoom], + /// the resulting animateCamera lands at that zoom, and this idle handler + /// then clears it. Subsequent GPS ticks fall through to the camera's + /// current zoom (line ~1005), so the user can pinch-zoom freely while + /// auto-follow is on without each tick snapping the zoom back. void _onCameraIdle() { if (!_autoFollow || _mapController == null) return; - final currentZoom = _mapController!.cameraPosition?.zoom; - if (currentZoom != null) { - _autoFollowDesiredZoom = currentZoom; - } + _autoFollowDesiredZoom = null; } - /// Add MeshMapper coverage raster overlay as a MapLibre source+layer + /// Add MeshMapper coverage raster overlay as a MapLibre source+layer. + /// Allocates fresh suffixed IDs each call so a previous layer can remain + /// in place (and continue rendering its tiles) while the new one's tiles + /// load on top — see [_swapCoverageOverlay] for the double-buffer flow. Future _addCoverageOverlay(AppStateProvider appState) async { if (_mapController == null || !_showMeshMapperOverlay) return; if (!appState.preferences.mapTilesEnabled) return; @@ -1942,9 +2038,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final url = 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + final sourceId = _nextCoverageSourceId(); + final layerId = _coverageLayerIdFor(sourceId); + try { await _mapController!.addSource( - 'meshmapper-overlay', + sourceId, RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), ); // Target the bottom of the repeater cluster stack when it exists, so the @@ -1956,6 +2055,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Fallback to the symbol annotation layer only if cluster layers haven't // been created yet (shouldn't happen in practice, but keeps the raster // underneath markers either way). + // + // Using the same belowLayerId for the new layer as the previous overlay + // intentionally places this insertion directly under the marker stack + // and ABOVE the previous raster layer — so as the new tiles render + // they paint over the old ones rather than the old being torn down + // first. _swapCoverageOverlay removes the old layer once the new tiles + // have settled. final belowLayer = _clusterLayersReady ? _repeaterIndividualLayerId : _symbolAnnotationLayerId(); @@ -1969,26 +2075,35 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ? 0.0 : appState.preferences.coverageOverlayOpacity; await _mapController!.addRasterLayer( - 'meshmapper-overlay', - 'meshmapper-overlay-layer', + sourceId, + layerId, RasterLayerProperties(rasterOpacity: opacity), belowLayerId: belowLayer, ); + _activeCoverageSourceId = sourceId; + _activeCoverageLayerId = layerId; _lastAppliedCoverageOpacity = opacity; debugLog( - '[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + '[MAP] Coverage overlay added as $layerId (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); } catch (e) { debugLog('[MAP] Failed to add coverage overlay: $e'); } } + String _nextCoverageSourceId() => + 'meshmapper-overlay-${++_coverageBufferCounter}'; + + String _coverageLayerIdFor(String sourceId) => '$sourceId-layer'; + /// Apply a new coverage overlay opacity to the live raster layer without /// removing/re-adding it. No-op if the layer doesn't exist yet. Future _applyCoverageOverlayOpacity(double opacity) async { if (_mapController == null) return; + final layerId = _activeCoverageLayerId; + if (layerId == null) return; try { await _mapController!.setLayerProperties( - 'meshmapper-overlay-layer', + layerId, RasterLayerProperties(rasterOpacity: opacity), ); _lastAppliedCoverageOpacity = opacity; @@ -2029,19 +2144,105 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - /// Remove coverage overlay source and layer + /// Remove the active coverage overlay source and layer (if any) and clear + /// the tracked IDs. Called by the mapTilesEnabled-toggle teardown and on + /// style reload — it does NOT participate in the double-buffer swap path. Future _removeCoverageOverlay() async { + final layerId = _activeCoverageLayerId; + final sourceId = _activeCoverageSourceId; + _activeCoverageLayerId = null; + _activeCoverageSourceId = null; + if (layerId != null && sourceId != null) { + await _removeCoverageLayerById(layerId, sourceId); + } + } + + /// Remove a specific coverage source+layer pair without touching the + /// active-ID tracking. Used by [_swapCoverageOverlay] to retire the + /// previous buffer once new tiles have rendered. + Future _removeCoverageLayerById( + String layerId, String sourceId) async { if (_mapController == null) return; try { - await _mapController!.removeLayer('meshmapper-overlay-layer'); - await _mapController!.removeSource('meshmapper-overlay'); + await _mapController!.removeLayer(layerId); + } catch (_) {} + try { + await _mapController!.removeSource(sourceId); } catch (_) {} } - /// Refresh coverage overlay (remove and re-add with new URL) - Future _refreshCoverageOverlay(AppStateProvider appState) async { - await _removeCoverageOverlay(); + /// Refresh coverage overlay using a double-buffered swap so the current + /// tiles stay visible until the new ones have rendered on top. + Future _refreshCoverageOverlay(AppStateProvider appState) => + _swapCoverageOverlay(appState); + + /// Double-buffered overlay refresh: + /// 1. Capture the currently-active source/layer IDs (the "old" buffer). + /// 2. Add the new source+layer — [_addCoverageOverlay] uses the same + /// belowLayerId so the new layer lands directly above the old one, + /// and updates the active-ID fields to point at the new buffer. + /// 3. Wait for [_onMapIdle] (or a short timeout) so the new tiles have + /// a chance to paint over the old. + /// 4. Remove the old source+layer. + /// + /// If the add was skipped (overlay disabled, no zone, etc.) the old + /// buffer is dropped immediately — there's nothing to buffer against. + Future _swapCoverageOverlay(AppStateProvider appState) async { + final oldSourceId = _activeCoverageSourceId; + final oldLayerId = _activeCoverageLayerId; + await _addCoverageOverlay(appState); + + final addedNewBuffer = _activeCoverageSourceId != oldSourceId && + _activeCoverageSourceId != null; + + if (!addedNewBuffer) { + // Add was a no-op (preconditions failed). Drop the previous buffer if + // the overlay should no longer be visible. _addCoverageOverlay's + // preconditions match the conditions under which we want the overlay + // gone, so this is the correct place to retire it. + if (oldSourceId != null && oldLayerId != null) { + _activeCoverageSourceId = null; + _activeCoverageLayerId = null; + await _removeCoverageLayerById(oldLayerId, oldSourceId); + } + return; + } + + if (oldSourceId == null || oldLayerId == null) { + // No previous buffer to retire (first add since style load or after a + // teardown). Nothing more to do. + return; + } + + await _waitForCoverageSwapIdle(timeout: const Duration(seconds: 3)); + if (!mounted) return; + await _removeCoverageLayerById(oldLayerId, oldSourceId); + } + + /// Block until [_onMapIdle] completes the swap completer, or [timeout] + /// elapses (whichever happens first). Replaces any prior in-flight waiter + /// so a new swap starting mid-flight doesn't strand the old waiter. + Future _waitForCoverageSwapIdle({required Duration timeout}) async { + final prior = _coverageSwapIdleCompleter; + if (prior != null && !prior.isCompleted) { + prior.complete(); + } + final completer = Completer(); + _coverageSwapIdleCompleter = completer; + _coverageSwapTimeoutTimer?.cancel(); + _coverageSwapTimeoutTimer = Timer(timeout, () { + if (!completer.isCompleted) completer.complete(); + }); + try { + await completer.future; + } finally { + if (identical(_coverageSwapIdleCompleter, completer)) { + _coverageSwapIdleCompleter = null; + } + _coverageSwapTimeoutTimer?.cancel(); + _coverageSwapTimeoutTimer = null; + } } /// Returns the fill color for a repeater status keyword. @@ -2547,8 +2748,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { math.asin(math.min(1.0, math.sqrt(h))); } + /// MapLibre Native (Android SDK 12.3.1 / iOS 6.19.1, both bound by + /// maplibre_gl 0.25.0) blinks symbol-layer labels for one frame when an + /// `animateCamera` call ends on an exact integer zoom level (15.0, 16.0, + /// 17.0, …). Tracking issue: + /// https://github.com/maplibre/maplibre-native/issues/2477 + /// + /// Workaround until upstream fix: nudge every programmatic zoom value + /// in this file by this epsilon so the camera never settles on an + /// integer. Visually identical (~0.001 zoom is sub-pixel at any scale) + /// but avoids the bug. When it's resolved upstream, set this to 0.0 + /// (or remove the subtractions). + static const double _zoomEpsilon = 0.001; + /// Maximum zoom the camera can reach (matches `minMaxZoomPreference`). - static const double _maxUserZoom = 17.0; + /// Subtracted by [_zoomEpsilon] to dodge the integer-zoom label-blink + /// bug described above. + static const double _maxUserZoom = 17.0 - _zoomEpsilon; /// True when the camera is at (or floating-point close to) the user's /// hard zoom cap. Spider expansion is gated on this — at any lower zoom @@ -3104,19 +3320,41 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Future _updateFocusLines() async { if (_mapController == null || !_styleLoaded) return; - // Always remove existing layers/source first (silently ignore if absent). - // Order matters: remove the layers BEFORE the source they reference. - try { - await _mapController!.removeLayer(_focusLinesLayerId); - } catch (_) {} - try { - await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); - } catch (_) {} - try { - await _mapController!.removeSource(_focusLinesSourceId); - } catch (_) {} + final hasFocus = + _focusedPingLocation != null && _focusedRepeaters.isNotEmpty; + + // No focus → no install. Skip the platform calls entirely when there's + // nothing on the map to begin with; only run the remove block when a + // previous activation actually installed the layers. + if (!hasFocus) { + if (!_focusLinesInstalled) return; + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + _focusLinesInstalled = false; + return; + } - if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) return; + // Focus is active — remove any prior install before re-adding with the + // current focus state. Order matters: layers BEFORE their source. + if (_focusLinesInstalled) { + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + _focusLinesInstalled = false; + } // Build a FeatureCollection with one LineString per connected repeater. // Per-feature properties carry the line color (data-driven styling) and @@ -3195,6 +3433,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), belowLayerId: belowLayer, ); + _focusLinesInstalled = true; } catch (e) { debugError('[MAP] Failed to add focus lines: $e'); } @@ -3599,9 +3838,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusedRepeaters.length, appState.preferences.gpsMarkerStyle, appState.preferences.markerStyle, - appState.currentPosition?.latitude, - appState.currentPosition?.longitude, - _computedHeading, txEchoTotal, discNodeTotal, ); @@ -3923,7 +4159,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.currentPosition!.latitude, appState.currentPosition!.longitude, ); - const targetZoom = 17.0; // Street level zoom when enabling follow + const targetZoom = + _maxUserZoom; // Street-level zoom when enabling follow (already nudged off integer) setState(() { _autoFollow = true; _lastGpsPosition = targetPosition; @@ -4802,8 +5039,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (entry.success) ...[ const SizedBox(height: 12), - // Table with headers - Container( + // Table with headers — reserve a sliver of node-column + // width for the inline `location_off` indicator when the + // target repeater has no GPS on file. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final lacksLocation = + _hexIdLacksLocation(entry.targetRepeaterId); + final iconReserve = lacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -4822,7 +5067,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -4900,10 +5145,26 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip( - repeaterId: entry.targetRepeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // Repeater ID + optional no-location icon, + // pinned to the node column width so the + // SNR/RSSI/TX columns stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), // RX SNR Expanded( child: Center( @@ -4938,7 +5199,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }), ], ), - ), + ); + }), ], ], ), @@ -4990,9 +5252,53 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return resolved; } + /// Look up the first matching [Repeater] by hex-ID prefix (case-insensitive). + /// Used by focus bottom-sheet rows to decide whether to surface the + /// `location_off` indicator. Returns null when no match is found — callers + /// treat that as "no location" too, since we have no coordinates. + Repeater? _lookupRepeaterByHexId(String hexId) { + if (hexId.isEmpty) return null; + final all = context.read().repeaters; + final key = hexId.toLowerCase(); + for (final r in all) { + if (r.hexId.toLowerCase().startsWith(key)) return r; + } + return null; + } + + /// True when we should show a "no location" hint for the given hex ID, + /// either because the matched repeater is at `(0, 0)` or because there is + /// no match at all. + bool _hexIdLacksLocation(String hexId) { + final r = _lookupRepeaterByHexId(hexId); + return r == null || !r.hasLocation; + } + + /// Small grey [Icons.location_off] used inline next to repeater chips in + /// the focus bottom sheets to signal "we heard this repeater but don't + /// know where it is". Tooltip explains on long-press. + Widget _noLocationIndicator() { + return Tooltip( + message: 'No location on file', + child: Icon( + Icons.location_off, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { + // Drop repeaters lacking GPS — they would draw lines off to (0, 0). + // The bottom-sheet row builder still surfaces them with a no-location + // icon. If nothing is left to focus on, skip activation entirely so + // the user's current map view (zoom, autofollow, rotation) is kept. + final located = + repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); + if (located.isEmpty) return; + final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; @@ -5018,7 +5324,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { setState(() { _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; - _focusedRepeaters = repeaters; + _focusedRepeaters = located; }); // Hide the MeshMapper coverage raster overlay for a clean focus view. @@ -5028,7 +5334,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { - _zoomToFocusBounds(pingLocation, repeaters); + _zoomToFocusBounds(pingLocation, located); } }); @@ -5275,8 +5581,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (heardRepeaters.isNotEmpty) ...[ const SizedBox(height: 12), - // Repeaters table - Container( + // If any heard repeater is missing GPS, reserve a sliver of + // node-column width for the inline `location_off` indicator + // so SNR/RSSI columns stay aligned row-to-row. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = heardRepeaters + .any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -5295,7 +5609,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -5346,6 +5660,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final lacksLocation = + _hexIdLacksLocation(repeater.repeaterId); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup( @@ -5358,11 +5674,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - // Repeater ID - RepeaterIdChip( - repeaterId: repeater.repeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // Repeater ID + optional no-location icon, + // pinned to the node column width. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), // SNR Expanded( child: Center( @@ -5392,7 +5722,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }), ], ), - ), + ); + }), ], ], ), @@ -5525,8 +5856,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), const SizedBox(height: 12), - // Repeater table (single row) - Container( + // Repeater table (single row). When the repeater has no GPS on + // file, surface a small grey location_off icon next to the chip + // so the user knows the focus map deliberately skipped it. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final lacksLocation = _hexIdLacksLocation(ping.repeaterId); + final iconReserve = lacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -5545,7 +5883,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -5597,11 +5935,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - // Repeater ID - RepeaterIdChip( - repeaterId: ping.repeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // Repeater ID + optional no-location icon, pinned + // to the node column width so SNR/RSSI stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), // SNR Expanded( child: Center( @@ -5626,7 +5977,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), - ), + ); + }), // Path section (origin → ... → us). Skipped when the path is // unavailable, e.g. RxPings reloaded from Hive (transient field). @@ -5807,8 +6159,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 12), - // Table with headers - Container( + // Table with headers — reserve a sliver of node-column + // width for the inline `location_off` indicator when any + // discovered node has no GPS on file, so the RX/RSSI/TX + // columns stay aligned row-to-row. + Builder(builder: (context) { + final anyLacksLocation = entry.discoveredNodes + .any((n) => _hexIdLacksLocation(n.repeaterId)); + final nodeExtra = 20.0 + (anyLacksLocation ? 18.0 : 0.0); + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -5827,7 +6186,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(extraPadding: 20), + width: + _nodeColumnWidth(extraPadding: nodeExtra), child: Text( 'Node', style: TextStyle( @@ -5890,6 +6250,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { PingColors.rssiColor(node.localRssi); final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final lacksLocation = + _hexIdLacksLocation(node.repeaterId); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup( @@ -5904,9 +6266,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - // Node ID with type + // Node ID with type (+ optional no-loc icon) SizedBox( - width: _nodeColumnWidth(extraPadding: 20), + width: + _nodeColumnWidth(extraPadding: nodeExtra), child: Row( children: [ RepeaterIdChip( @@ -5920,6 +6283,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: _discMarkerColor, ), ), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), ], ), ), @@ -5958,7 +6327,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }), ], ), - ), + ); + }), ], ], ), From c96dd4225eba0e3c2d0b0fbe43d526e98f78b29a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 30 Apr 2026 21:38:33 -0400 Subject: [PATCH 090/360] - **Per-Region Stale Repeater Threshold:** The stale repeater cutoff is no longer hardcoded. The app now reads `stale_repeater_hours` from the zone status API response so that it matches the web client. --- lib/models/repeater.dart | 10 +++++++--- lib/providers/app_state_provider.dart | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index fdfef513..8fafe936 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -3,6 +3,11 @@ import 'package:intl/intl.dart'; /// Represents a repeater from the MeshMapper API. /// Used to display repeater markers on the map. class Repeater { + /// Zone-level fallback for stale threshold, updated from the status API's + /// `stale_repeater_hours` field. Used only when a repeater lacks a + /// per-repeater [staleTime]. + static int staleHoursFallback = 24; + /// Unique ID (e.g., "01", "92") final String id; @@ -128,16 +133,15 @@ class Repeater { /// Check if the repeater is active. /// Uses server-provided [staleTime] when available, otherwise falls back - /// to a 24-hour threshold from [lastHeard]. + /// to [staleHoursFallback] (set from the zone's `stale_repeater_hours`). bool get isActive { if (staleTime != null) { final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; return nowSeconds < staleTime!; } - // Fallback: 24-hour threshold from lastHeard if (lastHeard == 0) return false; final heard = DateTime.fromMillisecondsSinceEpoch(lastHeard * 1000); - return DateTime.now().difference(heard).inHours < 24; + return DateTime.now().difference(heard).inHours < staleHoursFallback; } /// Check if the repeater has not been heard in the past 24 hours diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index fc64618f..7bd96c06 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4839,6 +4839,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = newZone; _nearestZone = null; + + final staleHours = result['stale_repeater_hours']; + if (staleHours is int && staleHours > 0) { + Repeater.staleHoursFallback = staleHours; + debugLog('[GEOFENCE] Zone stale repeater threshold: ${staleHours}h'); + } + debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { From 12ae67baaa80c309c8aa636bd7930ca3dab1c351 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 30 Apr 2026 22:45:16 -0400 Subject: [PATCH 091/360] - **Battery & Performance:** Countdown timers (cooldown, auto-ping, RX window, discovery window) no longer trigger a full widget-tree rebuild on every 500ms tick. They now self-notify via `ChangeNotifier` so only the ping control buttons redraw. During auto-ping this eliminates 4-6 unnecessary rebuilds per second across 11+ widgets. Noise floor and battery streams also skip redundant notifications when values haven't changed, removing ~840 unnecessary full-tree rebuilds per hour during stable conditions. --- lib/providers/app_state_provider.dart | 40 +++++++++----- lib/services/countdown_timer_service.dart | 64 +++++++++++------------ lib/widgets/ping_controls.dart | 22 +++++++- 3 files changed, 78 insertions(+), 48 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7bd96c06..30a8b0ca 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -109,6 +109,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final RxWindowTimer _rxWindowTimer; late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final Listenable _timerListenable; MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -660,6 +661,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { RxWindowTimer get rxWindowTimer => _rxWindowTimer; DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + Listenable get timerListenable => _timerListenable; // ============================================ // Initialization @@ -705,13 +707,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineSessionService = OfflineSessionService(); _deviceModelService = DeviceModelService(); - // Initialize countdown timers with notifyListeners callback for smooth UI updates - _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = - ManualPingCooldownTimer(onUpdate: notifyListeners); - _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); - _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); - _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); + // Initialize countdown timers. They self-notify via ChangeNotifier so only + // widgets listening to the timers directly rebuild on each 500ms tick. + _cooldownTimer = CooldownTimer(); + _manualPingCooldownTimer = ManualPingCooldownTimer(); + _autoPingTimer = AutoPingTimer(); + _rxWindowTimer = RxWindowTimer(); + _discoveryWindowTimer = DiscoveryWindowTimer(); + _timerListenable = Listenable.merge([ + _cooldownTimer, + _manualPingCooldownTimer, + _autoPingTimer, + _rxWindowTimer, + _discoveryWindowTimer, + ]); // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -1383,20 +1392,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }); - // Listen for noise floor updates + // Listen for noise floor updates — only rebuild UI when value changes _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { - _currentNoiseFloor = noiseFloor; - // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); - notifyListeners(); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } }); - // Listen for battery updates + // Listen for battery updates — only rebuild UI when value changes _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { - _currentBatteryPercent = batteryPercent; - notifyListeners(); + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } }); // Execute connection workflow diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index 412bd3fb..c7e61def 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -1,22 +1,20 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show VoidCallback; +import 'package:flutter/foundation.dart'; import '../utils/debug_logger_io.dart'; -/// Countdown timer for cooldown, auto-ping, and RX window +/// Countdown timer for cooldown, auto-ping, and RX window. /// Reference: createCountdownTimer() in wardrive.js /// -/// Features: -/// - 500ms update interval for responsive countdown display -/// - Auto-stops when countdown reaches zero -class CountdownTimerService { +/// Extends ChangeNotifier so that only widgets listening to this specific timer +/// rebuild on each tick. Previously, every tick called +/// AppStateProvider.notifyListeners(), which rebuilt the entire widget tree +/// (including the expensive MapWidget) 2× per second per active timer. +class CountdownTimerService extends ChangeNotifier { Timer? _timer; DateTime? _endTime; - int? _durationMs; // Original duration for progress calculation - final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick - - CountdownTimerService({this.onUpdate}); + int? _durationMs; /// Check if timer is running bool get isRunning => _timer != null; @@ -41,8 +39,8 @@ class CountdownTimerService { /// Start countdown timer /// @param durationMs - Duration in milliseconds void start(int durationMs) { - stop(); - _durationMs = durationMs; // Track original duration for progress + _cancelTimer(); + _durationMs = durationMs; _endTime = DateTime.now().add(Duration(milliseconds: durationMs)); // Start 500ms update timer for responsive countdown @@ -50,7 +48,7 @@ class CountdownTimerService { Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update - _update(); + notifyListeners(); } /// Update countdown display @@ -61,35 +59,43 @@ class CountdownTimerService { // Stop when countdown reaches zero if (remainingMs == 0) { - stop(); - // Trigger UI refresh when timer ends so UI updates immediately - onUpdate?.call(); + _cancelTimer(); + _endTime = null; + _durationMs = null; + notifyListeners(); return; } - // Trigger UI refresh callback after each update - onUpdate?.call(); + notifyListeners(); } /// Stop countdown timer void stop() { - _timer?.cancel(); - _timer = null; + if (_timer == null) return; + _cancelTimer(); _endTime = null; _durationMs = null; + notifyListeners(); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; } /// Dispose resources + @override void dispose() { - stop(); + _cancelTimer(); + _endTime = null; + _durationMs = null; + super.dispose(); } } /// Specialized countdown timer for ping cooldown /// Reference: wardrive.js state.cooldownEndTime and cooldownUpdateTimer class CooldownTimer extends CountdownTimerService { - CooldownTimer({super.onUpdate}); - @override void stop() { final wasRunning = isRunning; @@ -106,8 +112,6 @@ class AutoPingTimer extends CountdownTimerService { /// Skip reason (e.g., "too close", "gps too old") String? skipReason; - AutoPingTimer({super.onUpdate}); - /// Start countdown with optional skip reason /// Reference: startAutoCountdown() in wardrive.js with state.skipReason void startWithSkipReason(int durationMs, String? reason) { @@ -118,19 +122,13 @@ class AutoPingTimer extends CountdownTimerService { /// Specialized countdown timer for RX listening window /// Reference: wardrive.js state.rxListeningEndTime -class RxWindowTimer extends CountdownTimerService { - RxWindowTimer({super.onUpdate}); -} +class RxWindowTimer extends CountdownTimerService {} /// Specialized countdown timer for discovery listening window (Passive Mode) -class DiscoveryWindowTimer extends CountdownTimerService { - DiscoveryWindowTimer({super.onUpdate}); -} +class DiscoveryWindowTimer extends CountdownTimerService {} /// Specialized countdown timer for manual ping cooldown (15 seconds) class ManualPingCooldownTimer extends CountdownTimerService { - ManualPingCooldownTimer({super.onUpdate}); - @override void stop() { final wasRunning = isRunning; diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index fcfac93e..b79c981c 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -14,6 +14,9 @@ class PingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final validation = appState.pingValidation; final manualValidation = appState .manualPingValidation; // Manual ping validation (no distance check) @@ -315,6 +318,8 @@ class PingControls extends StatelessWidget { ), ], ); + }, + ); } Future _sendPing( @@ -594,9 +599,12 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { @override Widget build(BuildContext context) { final appState = context.watch(); + final colorScheme = Theme.of(context).colorScheme; + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final isTargetedRunning = appState.isTargetedModeRunning; final maxLen = appState.traceHopBytes * 2; - final colorScheme = Theme.of(context).colorScheme; // Sync controller when provider clears target (e.g. trace bytes changed) if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { @@ -769,6 +777,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { ], ), ); + }, + ); } } @@ -806,6 +816,9 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final manualValidation = appState .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; @@ -1150,6 +1163,8 @@ class _CompactPingControlsState extends State { ], ], ); + }, + ); } /// Get label for Send Ping button @@ -1367,6 +1382,9 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final manualValidation = appState .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; @@ -1553,6 +1571,8 @@ class LandscapePingControls extends StatelessWidget { ), ], ); + }, + ); } Future _sendPing( From e0a9f5f79366f2ca144a275bb29597eac57c5244 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 3 May 2026 08:14:34 -0400 Subject: [PATCH 092/360] - **(iOS) Launch & Resume Crash:** Fixed a crash on app launch and resume from background. MapLibre's internal projection math can produce NaN coordinates when the GL surface hasn't fully initialized (cold start before the first frame, or iOS GPU context restore after suspension), triggering a C++ `std::domain_error` and SIGABRT. Camera animations are now suppressed for one frame after style load and after resume, letting the surface reach a valid state before any camera move fires. --- lib/widgets/map_widget.dart | 49 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index ba4889ee..1876c8fa 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -313,15 +313,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { MapLibreMapController? _mapController; // Tracks the app lifecycle so we can suppress every animateCamera() call - // while the app is not in the foreground. MapLibre's native flyTo throws - // an uncaught C++ exception (mbgl::LatLng domain_error → SIGABRT) when - // the underlying MLNMapView's GL context is degraded, which is what - // happens on iOS while the app is backgrounded. The Flutter frame - // pipeline keeps running in our background-mode app (bluetooth-central + - // location), so GPS-driven rebuilds would otherwise still fire animateCamera. + // while the app is not in the foreground OR while the GL surface is + // settling after a state transition. MapLibre Native's + // constrainCameraAndZoomToBounds (PR #2475) calls Projection::unproject + // internally — when the GL surface is degenerate (zero-sized on first + // frame, or not yet restored after iOS background suspension), unproject + // produces NaN and the LatLng constructor throws std::domain_error → + // SIGABRT. Suppressing animations for one frame after style-load and + // after resume lets the surface reach a valid state first. AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + bool _cameraAnimationReady = false; bool get _canAnimateCamera => - _appLifecycleState == AppLifecycleState.resumed; + _appLifecycleState == AppLifecycleState.resumed && + _cameraAnimationReady; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -550,7 +554,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { + final wasBackground = _appLifecycleState != AppLifecycleState.resumed; _appLifecycleState = state; + + if (state != AppLifecycleState.resumed) { + // Going to background — block camera animations immediately. + _cameraAnimationReady = false; + } else if (wasBackground && _isMapReady) { + // Resuming from background — GL surface needs a frame to restore + // before constrainCameraAndZoomToBounds can project without NaN. + _cameraAnimationReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cameraAnimationReady = true; + } + }); + } } @override @@ -938,7 +957,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (appState.currentPosition == null && appState.lastKnownPosition != null && !_hasZoomedToLastKnown && - _isMapReady) { + _isMapReady && + _canAnimateCamera) { _hasZoomedToLastKnown = true; final lastKnownCenter = LatLng( appState.lastKnownPosition!.lat, @@ -961,7 +981,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // One-time initial zoom to GPS when we first get a position // This happens even with auto-follow disabled so user sees their location // Don't apply panel offset - center directly on GPS so pin is in middle of screen - if (!_hasInitialZoomed && _isMapReady) { + if (!_hasInitialZoomed && _isMapReady && _canAnimateCamera) { _hasInitialZoomed = true; final initialPosition = center; _lastGpsPosition = initialPosition; @@ -1855,6 +1875,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _styleLoaded = true; _isMapReady = true; + // Let the GL surface render one frame before allowing camera animations. + // Without this, constrainCameraAndZoomToBounds can produce NaN on the + // very first flyTo/easeTo after style load (the viewport may be + // zero-sized or the projection matrix degenerate). + _cameraAnimationReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cameraAnimationReady = true; + } + }); + // CRITICAL: clear stale Symbol references from any previous style load. // Style reloads cause maplibre_gl to construct a brand-new SymbolManager // with empty internal _idToAnnotation maps. Our _gpsSymbol / From bc35531c3c10bdb433481f637538bc100bf79e5e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 12:59:42 -0400 Subject: [PATCH 093/360] Fixed Anonymous mode getting permanently stuck after an unexpected Bluetooth disconnect --- lib/providers/app_state_provider.dart | 132 ++++++++++++++++--- lib/services/api_service.dart | 6 - lib/services/meshcore/tx_tracker.dart | 27 ---- lib/widgets/map_widget.dart | 178 +++++++++++++++++++++++--- lib/widgets/repeater_id_chip.dart | 8 +- 5 files changed, 280 insertions(+), 71 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 30a8b0ca..913f5ae0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -213,6 +213,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _originalDeviceName; // Real name stored before rename bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + /// Per-device real name persistence: maps device public key → real device name. + /// Survives unexpected BLE disconnects where setAdvertName restore can't run. + Map _deviceRealNames = {}; + /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -800,6 +804,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _loadPreferences(); await _loadDeviceAntennaPreferences(); await _loadDevicePowerOverrides(); + await _loadDeviceRealNames(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -1161,13 +1166,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_preferences.anonymousMode && !_isAnonymousRenamed) { final realName = _meshCoreConnection!.selfInfo?.name; if (realName != null && realName.isNotEmpty) { - _originalDeviceName = realName; + // Cascade guard: if firmware is stuck as "Anonymous" from a previous + // unclean disconnect, recover the real name from Hive + if (realName == 'Anonymous') { + final persisted = _deviceRealNames[publicKey]; + _originalDeviceName = + persisted ?? realName; // fall back if nothing saved + if (persisted != null) { + debugLog( + '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); + } + } else { + _originalDeviceName = realName; + } try { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; + // Persist real name keyed by public key (only if not "Anonymous") + if (_originalDeviceName != 'Anonymous') { + _deviceRealNames[publicKey] = _originalDeviceName!; + _saveDeviceRealNames(); + } debugLog( - '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1178,10 +1200,35 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name - final deviceName = _isAnonymousRenamed - ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); + String? deviceName; + if (_isAnonymousRenamed) { + deviceName = 'Anonymous'; + } else { + final selfInfoName = _meshCoreConnection!.selfInfo?.name; + // Detect stuck anonymous name: firmware still has "Anonymous" but mode is OFF + if (selfInfoName == 'Anonymous') { + final persistedName = _deviceRealNames[publicKey]; + if (persistedName != null) { + debugLog( + '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); + try { + await _meshCoreConnection!.setAdvertName(persistedName); + debugLog('[CONN] Restored firmware name to "$persistedName"'); + _clearPersistedRealName(publicKey); + } catch (e) { + debugError('[CONN] Failed to restore firmware name: $e'); + } + deviceName = persistedName; + } else { + debugWarn( + '[CONN] Firmware name is "Anonymous" but no persisted real name found'); + deviceName = selfInfoName; + } + } else { + deviceName = selfInfoName ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); + } + } if (deviceName == null || deviceName.isEmpty) { debugError( '[APP] Cannot request auth: could not retrieve device name'); @@ -1366,17 +1413,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification - var deviceName = _isAnonymousRenamed + var lastDeviceName = _isAnonymousRenamed ? _originalDeviceName : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); - if (deviceName != null) { - // Always strip MeshCore- prefix if present - deviceName = deviceName.replaceFirst('MeshCore-', ''); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && - deviceName.isNotEmpty && + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && _devicePublicKey != null) { - _saveLastConnectedDevice(deviceName, _devicePublicKey!); + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } // In offline mode, fetch signed contact URI for later registration during upload @@ -1945,9 +1996,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed - ? (_originalDeviceName ?? selfInfoName) - : selfInfoName; + String? realName; + if (_isAnonymousRenamed) { + realName = _originalDeviceName ?? selfInfoName; + } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { + realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; + } else { + realName = selfInfoName; + } if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { @@ -2944,6 +3000,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); debugLog( '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + if (_devicePublicKey != null) { + _clearPersistedRealName(_devicePublicKey!); + } } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); logError( @@ -6104,6 +6163,46 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // Device Real Name Persistence (Anonymous Mode Recovery) + // ============================================ + + Future _loadDeviceRealNames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_real_names'); + if (raw != null) { + _deviceRealNames = Map.from(raw as Map); + debugLog( + '[APP] Loaded real names for ${_deviceRealNames.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device real names: $e'); + } + } + + Future _saveDeviceRealNames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_real_names', _deviceRealNames); + await box.flush(); + } catch (e) { + debugLog('[APP] Failed to save device real names: $e'); + } + } + + Future _clearPersistedRealName(String publicKey) async { + if (_deviceRealNames.remove(publicKey) != null) { + await _saveDeviceRealNames(); + debugLog( + '[APP] Cleared persisted real name for device ${publicKey.substring(0, 16)}...'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ @@ -6427,6 +6526,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _bluetoothService.dispose(); _audioService.dispose(); _cooldownTimer.dispose(); + _manualPingCooldownTimer.dispose(); _autoPingTimer.dispose(); _rxWindowTimer.dispose(); _discoveryWindowTimer.dispose(); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 51753922..ae11188a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -452,12 +452,6 @@ class ApiService { debugLog('[API] Regional admin has disabled flood traffic'); } - // Parse flood_disabled flag from auth response - _floodDisabled = data['flood_disabled'] == true; - if (_floodDisabled) { - debugLog('[API] Regional admin has disabled flood traffic'); - } - // Parse min_mode_interval from auth response final minInterval = data['min_mode_interval']; if (minInterval is int && minInterval > 0) { diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 9092814e..b4090e26 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,36 +7,9 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; -/// Classification result from [TxTracker.handlePacket]. -/// -/// [ownPingNotTracked] is the key signal the unified RX handler needs to -/// suppress passive RX logging of our own ping's echo when it can't be -/// credited as a TX echo (multi-hop, RSSI failsafe, user-filtered, or -/// arrived after the 5s echo window closed). -enum TxTrackerResult { - /// Valid repeater echo — recorded as a TX success for this ping. - tracked, - - /// Confirmed echo of our own recently-sent ping, but not trackable as a - /// TX success. Caller MUST NOT route this to the passive RX logger — - /// doing so produces misleading blue RX markers and polluted API payloads - /// where we post our own ping back to MeshMapper as a passive observation. - ownPingNotTracked, - - /// Packet is not an echo of our ping (different channel, different - /// content, or no recent ping context). Caller may route to the passive - /// RX logger as usual. - notOurPing, -} - /// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { - /// Window during which we still identify our own ping's echoes, even after - /// [stopTracking] has fired. Covers echoes that arrive between the 5s echo - /// window closing and the RX listening window closing (BLE ack latency + - /// multi-hop propagation can easily push echoes past the 5s mark). - static const Duration _ownEchoAbsorbWindow = Duration(seconds: 10); bool isListening = false; DateTime? sentTimestamp; String? sentPayload; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1876c8fa..0ba29f6c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -155,7 +155,6 @@ Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( ); } -/// most modern phones (typical DPR is 2.0–3.5). Future _renderPainterToPng( CustomPainter painter, Size size, { @@ -3136,6 +3135,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { required DateTime ts, required bool success, required int idForMetadata, + String? iconImageOverride, }) async { final key = _coverageKey(type, ts, lat, lon); final isFocused = _isFocusedPing(lat, lon, ts); @@ -3147,7 +3147,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final options = SymbolOptions( geometry: LatLng(lat, lon), - iconImage: _MapImages.coverage(type, success), + iconImage: iconImageOverride ?? _MapImages.coverage(type, success), iconSize: isFocused ? 1.2 : 1.0, ); @@ -3198,16 +3198,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // DISC entries (success = received node responses; drop = treat as TX fail) for (final entry in appState.discLogEntries) { final received = entry.nodeCount > 0; - // When discDropEnabled, "no response" should look like a TX fail color. - // We model that by using the 'tx' image variant for failed DISCs: - final type = (!received && appState.discDropEnabled) ? 'tx' : 'disc'; + final renderAsTxFail = !received && appState.discDropEnabled; await syncOne( - type: type, + type: 'disc', lat: entry.latitude, lon: entry.longitude, ts: entry.timestamp, success: received, idForMetadata: entry.timestamp.millisecondsSinceEpoch, + iconImageOverride: + renderAsTxFail ? _MapImages.coverage('tx', false) : null, ); } @@ -3337,6 +3337,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const _focusLinesSourceId = 'focus-lines-source'; static const _focusLinesLayerId = 'focus-lines-layer'; static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; + static const _focusLinesAmbiguousLabelId = 'focus-lines-ambiguous-label'; /// Builds and applies the focus-mode dotted polylines that visually connect /// a focused ping to each repeater that heard it. Color-coded by SNR; @@ -3362,6 +3363,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeLayer(_focusLinesLayerId); } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLabelId); + } catch (_) {} try { await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); } catch (_) {} @@ -3378,6 +3382,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeLayer(_focusLinesLayerId); } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLabelId); + } catch (_) {} try { await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); } catch (_) {} @@ -3431,14 +3438,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // the repeater box. const belowLayer = _repeaterIndividualLayerId; - // Border line (white, wider, only for ambiguous matches) — added FIRST + // Border line (amber, wider, only for ambiguous matches) — added FIRST // so it renders BENEATH the colored line on top. await _mapController!.addLineLayer( _focusLinesSourceId, _focusLinesAmbiguousLayerId, const LineLayerProperties( - lineColor: '#FFFFFF', - lineOpacity: 0.6, + lineColor: '#F59E0B', + lineOpacity: 0.8, lineWidth: 6.5, lineDasharray: [2, 4], lineCap: 'round', @@ -3451,6 +3458,32 @@ class _MapWidgetState extends State with WidgetsBindingObserver { belowLayerId: belowLayer, ); + // "DUPLICATE ID" text along ambiguous lines — only renders when the + // line is long enough for the label to fit without collision. + await _mapController!.addSymbolLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLabelId, + const SymbolLayerProperties( + symbolPlacement: 'line', + textField: 'DUPLICATE ID', + textSize: 11, + textColor: '#F59E0B', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textKeepUpright: true, + textFont: _defaultFontStack, + symbolSpacing: 200, + textAllowOverlap: false, + textOptional: true, + ), + filter: [ + '==', + ['get', 'ambiguous'], + true + ], + belowLayerId: belowLayer, + ); + // Main colored line (color from feature property via data-driven expression) await _mapController!.addLineLayer( _focusLinesSourceId, @@ -5471,21 +5504,97 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Show all ambiguous (duplicate-ID) repeaters in a dialog. + void _showDuplicateRepeaterPopup( + List<_ResolvedRepeater> resolved, { + ({double lat, double lon})? fromLatLng, + }) { + final ambiguous = resolved.where((r) => r.ambiguous).toList(); + if (ambiguous.isEmpty) return; + + final appState = context.read(); + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + showDialog( + context: context, + builder: (dialogContext) => Dialog( + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, + size: 18, color: Color(0xFFF59E0B)), + const SizedBox(width: 8), + Text( + 'Duplicate Repeater IDs', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + Divider(height: 1, color: Theme.of(context).dividerColor), + const SizedBox(height: 8), + Text( + 'Multiple repeaters share the same short ID. ' + 'We can\'t determine which one heard your ping.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ...ambiguous.map((r) => RepeaterIdChip.buildRepeaterRow( + context, + r.repeater, + refLat: fromLatLng?.lat, + refLon: fromLatLng?.lon, + regionHopBytesOverride: regionOverride, + )), + ], + ), + ), + ), + ), + ); + } + /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; + // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) + final resolved = heardRepeaters.isNotEmpty + ? _resolveRepeatersByHexIds( + heardRepeaters.map((r) => r.repeaterId).toList(), + snrValues: heardRepeaters.map((r) => r.snr).toList(), + ) + : <_ResolvedRepeater>[]; + final hasAmbiguous = resolved.any((r) => r.ambiguous); + // Activate focus mode if the ping was heard by known repeaters - if (heardRepeaters.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - heardRepeaters.map((r) => r.repeaterId).toList(), - snrValues: heardRepeaters.map((r) => r.snr).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } + if (resolved.isNotEmpty) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -5610,6 +5719,39 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), + if (hasAmbiguous) + GestureDetector( + onTap: () => _showDuplicateRepeaterPopup( + resolved, + fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude, + ), + ), + child: const Padding( + padding: EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 14, color: Color(0xFFF59E0B)), + SizedBox(width: 4), + Flexible( + child: Text( + 'Duplicate repeater ID — lines shown to all possible matches', + style: TextStyle( + fontSize: 11, + color: Color(0xFFF59E0B), + ), + ), + ), + SizedBox(width: 4), + Icon(Icons.info_outline, + size: 14, color: Color(0xFFF59E0B)), + ], + ), + ), + ), + if (heardRepeaters.isNotEmpty) ...[ const SizedBox(height: 12), // If any heard repeater is missing GPS, reserve a sliver of diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index e93db01d..533990a5 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -153,7 +153,7 @@ class RepeaterIdChip extends StatelessWidget { content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, + .map((r) => buildRepeaterRow(context, r, refLat: refLat, refLon: refLon, regionHopBytesOverride: regionOverride)) @@ -213,7 +213,7 @@ class RepeaterIdChip extends StatelessWidget { ); } - static Widget _buildRepeaterRow( + static Widget buildRepeaterRow( BuildContext context, Repeater repeater, { double? refLat, @@ -254,7 +254,7 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge( + buildHexBadge( repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), const SizedBox(width: 12), @@ -330,7 +330,7 @@ class RepeaterIdChip extends StatelessWidget { } /// Build a hex ID badge — circle for 2-char, pill for longer IDs - static Widget _buildHexBadge(String displayId, Color color) { + static Widget buildHexBadge(String displayId, Color color) { final isLong = displayId.length > 2; return Container( From 474436049f16d34f10f4bc63f0c1e335e84c4dc6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:09:17 -0400 Subject: [PATCH 094/360] Minor fix to the last commit --- lib/providers/app_state_provider.dart | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 913f5ae0..2ac75fa7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1204,7 +1204,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed) { deviceName = 'Anonymous'; } else { - final selfInfoName = _meshCoreConnection!.selfInfo?.name; + var selfInfoName = _meshCoreConnection!.selfInfo?.name; // Detect stuck anonymous name: firmware still has "Anonymous" but mode is OFF if (selfInfoName == 'Anonymous') { final persistedName = _deviceRealNames[publicKey]; @@ -1213,21 +1213,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); try { await _meshCoreConnection!.setAdvertName(persistedName); - debugLog('[CONN] Restored firmware name to "$persistedName"'); + await Future.delayed(const Duration(milliseconds: 300)); + final refreshed = await _meshCoreConnection!.getSelfInfo(); + selfInfoName = refreshed.name; + debugLog( + '[CONN] Confirmed firmware name restored to "$selfInfoName"'); _clearPersistedRealName(publicKey); } catch (e) { debugError('[CONN] Failed to restore firmware name: $e'); + selfInfoName = persistedName; } - deviceName = persistedName; } else { debugWarn( '[CONN] Firmware name is "Anonymous" but no persisted real name found'); - deviceName = selfInfoName; } - } else { - deviceName = selfInfoName ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); } + deviceName = selfInfoName ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); } if (deviceName == null || deviceName.isEmpty) { debugError( From d818c0f9eb69dc03e8985e87167b805a5ed2629e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:24:00 -0400 Subject: [PATCH 095/360] =?UTF-8?q?-=20**(Android)=20Fixed=20a=20crash=20w?= =?UTF-8?q?hen=20returning=20to=20the=20app=20after=20extended=20backgroun?= =?UTF-8?q?d=20time.**=20Same=20root=20cause=20as=20the=20iOS=20fix=20?= =?UTF-8?q?=E2=80=94=20MapLibre's=20GL=20surface=20can=20be=20temporarily?= =?UTF-8?q?=20invalid=20on=20resume.=20Map=20interactions=20now=20wait=20o?= =?UTF-8?q?ne=20frame=20after=20resume.=20Also=20fixed=20`flutter=5Flocal?= =?UTF-8?q?=5Fnotifications`=20TypeToken=20errors=20on=20every=20startup.?= =?UTF-8?q?=20-=20Changed=20way=20that=20anonymous=20mode=20is=20enabled,?= =?UTF-8?q?=20it=20now=20shows=20a=20popup=20with=20progress=20instead=20o?= =?UTF-8?q?f=20switching=20to=20connect=20tab=20to=20show=20the=20reconnec?= =?UTF-8?q?tion=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 1 + android/app/proguard-rules.pro | 7 ++ lib/providers/app_state_provider.dart | 29 ++++--- lib/screens/main_scaffold.dart | 106 ++++++++++++++++++++++---- lib/widgets/map_widget.dart | 7 +- 5 files changed, 121 insertions(+), 29 deletions(-) create mode 100644 android/app/proguard-rules.pro diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f06511a..38066d36 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -60,6 +60,7 @@ android { buildTypes { release { signingConfig = signingConfigs.getByName("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..e89a275a --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# flutter_local_notifications uses Gson to serialize/deserialize scheduled +# notification data. R8 strips the generic signature from TypeToken +# subclasses, causing "TypeToken must be created with a type argument" at +# runtime when cancel() tries to load the notification cache. +-keep class com.google.gson.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken +-keepattributes Signature diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2ac75fa7..14d7eb16 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -323,7 +323,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab - bool _requestConnectionTabSwitch = false; // Request switch to connection tab + bool _isAnonymousReconnectInProgress = false; + bool _anonymousReconnectEnabling = true; // Repeater markers state List _repeaters = []; @@ -508,7 +509,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; - bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; + bool get isAnonymousReconnectInProgress => _isAnonymousReconnectInProgress; + bool get anonymousReconnectEnabling => _anonymousReconnectEnabling; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -572,6 +574,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? get zoneTransferFrom => _zoneTransferFrom; String? get zoneTransferTo => _zoneTransferTo; + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); @@ -4261,12 +4264,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { - _requestConnectionTabSwitch = true; + _isAnonymousReconnectInProgress = true; + _anonymousReconnectEnabling = enabled; + _connectionStep = ConnectionStep.disconnected; notifyListeners(); - await disconnect(); // Full cleanup (restores name if previously anonymous) - // Short delay for BLE cleanup - await Future.delayed(const Duration(milliseconds: 500)); - await connectToDevice(deviceToReconnect); + try { + await disconnect(); + await Future.delayed(const Duration(milliseconds: 500)); + await connectToDevice(deviceToReconnect); + } catch (e) { + debugError('[APP] Anonymous mode reconnect error: $e'); + } finally { + _isAnonymousReconnectInProgress = false; + notifyListeners(); + } } } } @@ -4452,10 +4463,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } - /// Clear the connection tab switch request (called by main scaffold after switching) - void clearConnectionTabSwitchRequest() { - _requestConnectionTabSwitch = false; - } // ============================================ // API Error Handling diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 375a6a95..b080be78 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../services/permission_disclosure_service.dart'; import '../utils/debug_logger_io.dart'; @@ -202,18 +203,6 @@ class _MainScaffoldState extends State { }); } - // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) - if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _selectedIndex = 3; // Switch to Connect tab - }); - appState.clearConnectionTabSwitchRequest(); - } - }); - } - // Listen for flood-traffic-disabled-by-region alert (user had it on, // region forced it off on auth/zone-change) if (appState.floodDisabledAlertPending && !_floodDisabledDialogOpen) { @@ -228,9 +217,22 @@ class _MainScaffoldState extends State { MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( - body: IndexedStack( - index: _selectedIndex, - children: _screens, + body: Stack( + children: [ + IndexedStack( + index: _selectedIndex, + children: _screens, + ), + if (appState.isAnonymousReconnectInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildAnonymousReconnectOverlay(appState), + ), + ), + ), + ], ), bottomNavigationBar: isLandscape ? _buildCompactNavBar(appState) @@ -238,6 +240,80 @@ class _MainScaffoldState extends State { ); } + Widget _buildAnonymousReconnectOverlay(AppStateProvider appState) { + final enabling = appState.anonymousReconnectEnabling; + final step = appState.connectionStep; + final totalSteps = ConnectionStepExtension.totalSteps; + final progress = step.stepNumber > 0 ? step.stepNumber / totalSteps : 0.0; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + enabling + ? 'Enabling Anonymous Mode...' + : 'Disabling Anonymous Mode...', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade800, + color: Colors.orange.shade400, + minHeight: 4, + ), + ), + const SizedBox(height: 10), + Text( + step.description, + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 13, + ), + ), + if (step.stepNumber > 0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Step ${step.stepNumber} of $totalSteps', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ); + } + /// Compact navigation bar for landscape mode (icon-only, shorter height) Widget _buildCompactNavBar(AppStateProvider appState) { return Container( diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 0ba29f6c..b8ff36c3 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1012,7 +1012,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // zoom the camera is animating toward — using it instead of the // (potentially interpolated) current zoom prevents drift during the // initial zoom animation after tapping center-on-position. - if (_autoFollow && _isMapReady) { + if (_autoFollow && _isMapReady && _cameraAnimationReady) { final newPosition = center; if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || @@ -1131,7 +1131,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (_isMapReady && _styleLoaded && _imagesRegistered && - _clusterLayersReady) { + _clusterLayersReady && + _cameraAnimationReady) { final dataVersion = _computeMarkerDataVersion(appState); if (dataVersion != _lastMarkerDataVersion) { _lastMarkerDataVersion = dataVersion; @@ -1163,7 +1164,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // re-run its global symbol collision pass and flickered the base-style // POI labels at high zoom. The gpsMarkerStyle pref is included so style // changes (arrow → walk, etc.) re-render the marker's bitmap. - if (_isMapReady && _styleLoaded && _imagesRegistered) { + if (_isMapReady && _styleLoaded && _imagesRegistered && _cameraAnimationReady) { final gpsVersion = Object.hash( appState.currentPosition?.latitude, appState.currentPosition?.longitude, From 5ec79e87a607fd7db0a51300fe71796d44341d12 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:27:23 -0400 Subject: [PATCH 096/360] - Fixed being unable to switch from offline back to online mode while connected --- lib/providers/app_state_provider.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 14d7eb16..79a5fb5f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3606,6 +3606,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Hot-switching to online mode while connected'); _isSwitchingMode = true; _modeSwitchError = null; + var switchSucceeded = false; notifyListeners(); try { @@ -3642,7 +3643,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + // Clear offline mode before zone check so checkZoneStatus() doesn't skip the API call + _preferences = _preferences.copyWith(offlineMode: false); + _apiQueueService.offlineMode = false; + debugLog('[APP] Re-checking zone status before auth...'); await checkZoneStatus(); @@ -3777,8 +3781,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // 5. Auth successful - update state - _preferences = _preferences.copyWith(offlineMode: false); - _apiQueueService.offlineMode = false; + switchSucceeded = true; // 6. Update regional channels from auth response final channels = result['channels']; @@ -3800,6 +3803,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _modeSwitchError = 'Failed to switch to online mode: $e'; return (success: false, error: _modeSwitchError); } finally { + if (!switchSucceeded) { + _preferences = _preferences.copyWith(offlineMode: true); + _apiQueueService.offlineMode = true; + } _isSwitchingMode = false; notifyListeners(); } From 4b1214c3efb295675b5bce7a596c4a63147157f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:40:40 -0400 Subject: [PATCH 097/360] - Fixed misleading "No empty channel slots" error when BLE disconnects during connection --- lib/services/meshcore/channel_service.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/services/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index d92573c9..56f04c0a 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -177,7 +177,6 @@ class ChannelService { // Full scan prevents duplicates from orphaned channels after unexpected disconnects int? firstEmptySlot; var channelIdx = 0; - while (true) { try { // Retry mechanism for first channel (sometimes gets spurious OK responses) @@ -222,6 +221,13 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { + if (channelIdx == 0) { + // Couldn't read any channels — BLE dropped, not a channel issue + debugError('[CHANNEL] BLE connection lost during channel scan'); + throw Exception( + 'BLE connection lost during channel setup. Please try connecting again.', + ); + } debugError( '[CHANNEL] No empty channel slots found in first $channelIdx channels'); throw Exception( From 225064daa90e0f9f3b67f0577b4c7b1a0736c6aa Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:44:08 -0400 Subject: [PATCH 098/360] - Fixed offline sessions getting stuck at "partial upload" and failing to upload --- lib/providers/app_state_provider.dart | 79 ++++++++++++++++++----- lib/screens/settings_screen.dart | 2 +- lib/services/api_service.dart | 21 ++++-- lib/services/offline_session_service.dart | 49 ++++++++++++-- 4 files changed, 125 insertions(+), 26 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 79a5fb5f..98ab3f1d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4134,28 +4134,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); - // Delay after auth before posting - await Future.delayed(const Duration(seconds: 1)); + // Server needs ~3-5s to propagate session after auth + await Future.delayed(const Duration(seconds: 4)); - // 4. Upload pings in batches of 50 using isolated session + // 4. Upload pings in batches of 50 with retry on session timing errors const batchSize = 50; var uploadedCount = 0; - var failedBatches = 0; + var discardedCount = 0; + var sessionFailed = false; final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { + if (sessionFailed) break; + final batchNum = (i ~/ batchSize) + 1; onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = + var result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); - if (result == UploadResult.success) { - uploadedCount += batch.length; - debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); - } else { - failedBatches++; - debugError('[OFFLINE] Failed to upload batch $batchNum'); + + // Retry logic for session timing errors (server propagation delay) + if (result == UploadResult.sessionError) { + const maxRetries = 3; + for (var retry = 1; retry <= maxRetries; retry++) { + final delay = 2 * retry; + debugLog( + '[OFFLINE] Batch $batchNum session error, retry $retry/$maxRetries after ${delay}s'); + onProgress?.call('Batch $batchNum/$totalBatches (retry $retry)'); + await Future.delayed(Duration(seconds: delay)); + result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId); + if (result != UploadResult.sessionError) break; + } + } else if (result == UploadResult.retryable) { + debugLog('[OFFLINE] Batch $batchNum retryable error, retrying after 2s'); + await Future.delayed(const Duration(seconds: 2)); + result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId); + } + + // Process final result + switch (result) { + case UploadResult.success: + uploadedCount += batch.length; + debugLog( + '[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + break; + case UploadResult.nonRetryable: + discardedCount += batch.length; + debugWarn('[OFFLINE] Batch $batchNum discarded (data error)'); + break; + case UploadResult.sessionError: + debugError( + '[OFFLINE] Batch $batchNum session error persists after retries, aborting'); + sessionFailed = true; + break; + case UploadResult.retryable: + debugError( + '[OFFLINE] Batch $batchNum still failing after retry, aborting'); + sessionFailed = true; + break; } } @@ -4171,15 +4210,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[OFFLINE] Isolated upload session released'); - // 6. Mark session as uploaded (don't delete) if all batches succeeded - if (failedBatches == 0) { + // 6. Clean up session based on results + final totalProcessed = uploadedCount + discardedCount; + final remainingPings = pings.length - totalProcessed; + + if (remainingPings <= 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); + debugLog( + '[OFFLINE] Session complete: $uploadedCount uploaded, $discardedCount discarded from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { + if (totalProcessed > 0) { + await _offlineSessionService.removeProcessedPings( + filename, totalProcessed); + debugLog( + '[OFFLINE] Removed $totalProcessed processed pings, $remainingPings remain in $filename'); + } debugWarn( - '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + '[OFFLINE] Partial upload: $uploadedCount uploaded, $discardedCount discarded, $remainingPings remaining from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 12a3343e..b0f16273 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2208,7 +2208,7 @@ class _SettingsScreenState extends State { backgroundColor = Colors.red; break; case OfflineUploadResult.partialFailure: - message = 'Partial upload - some pings failed'; + message = 'Partial upload - tap again to retry remaining pings'; backgroundColor = Colors.orange; break; case OfflineUploadResult.uploadInProgress: diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index ae11188a..d6ba1e2b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -8,7 +8,7 @@ import '../models/repeater.dart'; import '../utils/debug_logger_io.dart'; /// Result of a batch upload attempt -enum UploadResult { success, retryable, nonRetryable } +enum UploadResult { success, retryable, sessionError, nonRetryable } /// MeshMapper API service /// Handles communication with the MeshMapper backend @@ -1085,12 +1085,21 @@ class ApiService { final reason = result['reason'] as String?; - // For offline uploads, session/auth errors are non-retryable but do NOT cascade - const criticalErrors = { + // Session timing errors: session not yet propagated or expired. + // The pings are fine — retrying with a delay may succeed. + const sessionTimingErrors = { + 'bad_session', 'session_expired', 'session_invalid', 'session_revoked', - 'bad_session', + }; + if (sessionTimingErrors.contains(reason)) { + debugError('[API] Offline upload batch session timing error: $reason'); + return UploadResult.sessionError; + } + + // Permanent auth/zone errors: session will never work, abort upload. + const permanentSessionErrors = { 'invalid_key', 'unauthorized', 'bad_key', @@ -1098,8 +1107,8 @@ class ApiService { 'zone_full', 'zone_disabled', }; - if (criticalErrors.contains(reason)) { - debugError('[API] Offline upload batch session error: $reason'); + if (permanentSessionErrors.contains(reason)) { + debugError('[API] Offline upload batch permanent error: $reason'); return UploadResult.nonRetryable; } diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 761a315f..f2958595 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -54,13 +54,17 @@ class OfflineSession { }; } - /// Create a copy with uploaded status changed - OfflineSession copyWith({bool? uploaded}) { + /// Create a copy with updated fields + OfflineSession copyWith({ + bool? uploaded, + Map? data, + int? pingCount, + }) { return OfflineSession( filename: filename, createdAt: createdAt, - pingCount: pingCount, - data: data, + pingCount: pingCount ?? this.pingCount, + data: data ?? this.data, devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, @@ -274,6 +278,43 @@ class OfflineSessionService { debugLog('[OFFLINE] Marked session as uploaded: $filename'); } + /// Remove the first [processedCount] pings from a session. + /// Called after partial upload so retries don't re-send already-processed data. + /// Returns the number of remaining pings, or null if session not found. + Future removeProcessedPings( + String filename, int processedCount) async { + final index = _sessions.indexWhere((s) => s.filename == filename); + if (index == -1) { + debugWarn('[OFFLINE] Session not found for ping removal: $filename'); + return null; + } + + final session = _sessions[index]; + final pings = (session.data['pings'] as List?) ?? []; + + if (processedCount >= pings.length) { + _sessions[index] = session.copyWith(uploaded: true); + await _saveSessions(); + debugLog( + '[OFFLINE] All pings processed, marking session complete: $filename'); + return 0; + } + + final remaining = pings.sublist(processedCount); + final updatedData = Map.from(session.data); + updatedData['pings'] = remaining; + updatedData['ping_count'] = remaining.length; + + _sessions[index] = session.copyWith( + data: updatedData, + pingCount: remaining.length, + ); + await _saveSessions(); + debugLog( + '[OFFLINE] Removed $processedCount processed pings from $filename, ${remaining.length} remain'); + return remaining.length; + } + /// Get a session by filename OfflineSession? getSession(String filename) { try { From a66debd91f8f52b214be44ec71d4068d0c7c2815 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:44:16 -0400 Subject: [PATCH 099/360] - Fixed offline sessions getting stuck at "partial upload" and failing to upload --- assets/device-models.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/device-models.json b/assets/device-models.json index 49cb9e60..22f5a6b2 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -299,6 +299,14 @@ "txPower": 22, "notes": "Nano G2 Ultra" }, + { + "manufacturer": "pyMC-Repeater-Companion", + "shortName": "pyMC Repeater Companion", + "power": 1.0, + "platform": "PyMC", + "txPower": 30, + "notes": "pyMC Repeater Companion" + }, { "manufacturer": "Station G2", "shortName": "Station G2", From e4034daefde117f5d93998cdc7ba16f7e8254676 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 10 May 2026 07:49:01 -0400 Subject: [PATCH 100/360] - Fixed auto-ping mode buttons disappearing after reconnect timeout and zone transitions --- lib/providers/app_state_provider.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 98ab3f1d..ab3f28b0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2763,6 +2763,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); + // Timeout or cancel fired while connection was in-flight. + // Disconnect the orphaned connection — abandon already cleaned up state. + if (!_isAutoReconnecting) { + if (_connectionStep == ConnectionStep.connected) { + debugLog( + '[CONN] Auto-reconnect completed after timeout — disconnecting orphaned connection'); + disconnect(); + } + return; + } + // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { debugLog( @@ -5324,6 +5335,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { _autoPingEnabled = false; _idleAutoStopReference = null; + await _pingService?.forceDisableAutoPing(); debugLog('[ZONE] Auto-ping paused for zone transfer'); } @@ -5608,6 +5620,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { + _cooldownTimer.stop(); toggleAutoPing(previousMode); debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); } From d24cd30514546e8d0869343d9b07c62e7fbcf852 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 13 May 2026 17:37:14 -0400 Subject: [PATCH 101/360] TCP/USB transport support --- DEVELOPMENT.md | 20 +- README.md | 1 + THIRD_PARTY_LICENSES | 29 + android/app/src/main/AndroidManifest.xml | 3 + .../kotlin/net/meshmapper/app/MainActivity.kt | 10 + .../meshmapper/app/MeshMapperUsbService.kt | 557 +++++ lib/models/connection_state.dart | 18 +- lib/models/remembered_device.dart | 32 +- lib/providers/app_state_provider.dart | 2086 ++++++++++------- lib/screens/connection_screen.dart | 505 +++- lib/services/bluetooth/bluetooth_service.dart | 10 +- lib/services/meshcore/connection.dart | 36 +- lib/services/meshcore/protocol_constants.dart | 2 +- .../transport/android_serial_service.dart | 131 ++ .../transport/companion_transport.dart | 26 + .../transport/stream_frame_codec.dart | 128 + .../transport/stream_transport_base.dart | 121 + lib/services/transport/tcp_service.dart | 167 ++ .../transport/web_serial_factory.dart | 2 + .../transport/web_serial_factory_stub.dart | 4 + .../transport/web_serial_factory_web.dart | 8 + .../transport/web_serial_service.dart | 128 + pubspec.yaml | 2 +- .../transport/stream_frame_codec_test.dart | 282 +++ 24 files changed, 3382 insertions(+), 926 deletions(-) create mode 100644 THIRD_PARTY_LICENSES create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt create mode 100644 lib/services/transport/android_serial_service.dart create mode 100644 lib/services/transport/companion_transport.dart create mode 100644 lib/services/transport/stream_frame_codec.dart create mode 100644 lib/services/transport/stream_transport_base.dart create mode 100644 lib/services/transport/tcp_service.dart create mode 100644 lib/services/transport/web_serial_factory.dart create mode 100644 lib/services/transport/web_serial_factory_stub.dart create mode 100644 lib/services/transport/web_serial_factory_web.dart create mode 100644 lib/services/transport/web_serial_service.dart create mode 100644 test/services/transport/stream_frame_codec_test.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 50aab685..d7b96bbb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -55,11 +55,20 @@ MESHMAPPER_API_KEY= ./Build.sh The app uses a layered service architecture with clear separation of concerns: **Bluetooth Abstraction Layer** (`lib/services/bluetooth/`): -- `BluetoothService`: Abstract interface for BLE operations +- `BluetoothService`: Abstract interface for BLE operations, implements `CompanionTransport` - `MobileBluetoothService`: Android/iOS implementation using `flutter_blue_plus` - `WebBluetoothService`: Web implementation using `flutter_web_bluetooth` - Platform selection happens at runtime in `main.dart` using `kIsWeb` +**Transport Layer** (`lib/services/transport/`): +- `CompanionTransport`: Transport-agnostic interface for MeshCore companion connections (BLE, TCP, USB Serial) +- `StreamFrameCodec`: Framing codec for TCP/USB Serial (`[0x3C][len_lo][len_hi][payload]` out, `[0x3E][len_lo][len_hi][payload]` in) +- `StreamTransportBase`: Abstract base for TCP and USB Serial transports, owns codec and connection lifecycle +- `TcpService`: TCP socket transport with saved connections persistence (Android/iOS) +- `AndroidSerialService`: USB Serial via USB OTG on Android using `usb_serial` package +- `WebSerialService`: USB Serial via Web Serial API (Chrome/Edge) using `dart:js_interop` +- Platform matrix: BLE (all platforms), TCP (Android/iOS), USB Serial (Android/Web) + **MeshCore Protocol Layer** (`lib/services/meshcore/`): - `MeshCoreConnection`: Implements the 9-step connection workflow and MeshCore companion protocol - `PacketParser`: Binary packet parsing with BufferReader/Writer utilities @@ -85,7 +94,7 @@ The app uses a layered service architecture with clear separation of concerns: Critical safety: The connection sequence MUST complete in order. -1. **BLE GATT Connect**: Platform-specific BLE connection +1. **Transport Connect**: Platform-specific transport connection (BLE GATT, TCP socket, or USB Serial port) 2. **Protocol Handshake**: `deviceQuery()` with protocol version 3. **Device Info**: `deviceQuery()` returns manufacturer string, then `getSelfInfo()` acquires device public key (required for geo-auth API authentication). If `getSelfInfo()` fails, the entire connection fails. 4. **Device Identification**: Parse manufacturer string, match against `device-models.json` (does NOT modify radio settings) @@ -342,6 +351,7 @@ Key packages used in this project: - `provider`: State management - `http`: API requests - `pointycastle`: Encryption (AES-ECB, SHA-256) +- `usb_serial`: USB Serial communication on Android (USB OTG) ## Development Workflow Requirements @@ -506,6 +516,12 @@ All API endpoints may return maintenance mode: - `lib/services/meshcore/tx_tracker.dart` - Repeater echo detection (7s window) - `lib/services/meshcore/disc_tracker.dart` - Discovery response tracking (7s window) - `lib/services/meshcore/rx_logger.dart` - Passive observation logging +- `lib/services/transport/companion_transport.dart` - Transport-agnostic interface for companion connections +- `lib/services/transport/stream_frame_codec.dart` - TCP/USB Serial framing codec +- `lib/services/transport/stream_transport_base.dart` - Shared base for TCP/USB Serial transports +- `lib/services/transport/tcp_service.dart` - TCP socket transport with saved connections +- `lib/services/transport/android_serial_service.dart` - USB Serial transport for Android (USB OTG) +- `lib/services/transport/web_serial_service.dart` - USB Serial transport for Web (Web Serial API) - `lib/services/ping_service.dart` - TX/RX/Discovery ping orchestration - `lib/services/gps_service.dart` - GPS tracking and geofencing - `lib/services/api_queue_service.dart` - Persistent upload queue diff --git a/README.md b/README.md index dc09548a..5f46dd08 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,5 @@ This project is licensed under the MIT License — see [LICENSE](LICENSE) for de - Original [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient) - [MeshCore](https://github.com/meshcore-dev/MeshCore) firmware project +- [meshcore-open](https://github.com/zjs81/meshcore-open) by zjs81 — Android USB Serial support is based heavily on its native USB host implementation (MIT License, see [THIRD_PARTY_LICENSES](THIRD_PARTY_LICENSES)) - [The Greater Ottawa Mesh Radio Enthusiasts community](https://ottawamesh.ca/) diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES new file mode 100644 index 00000000..a542052e --- /dev/null +++ b/THIRD_PARTY_LICENSES @@ -0,0 +1,29 @@ +================================================================================ +meshcore-open +https://github.com/zjs81/meshcore-open +================================================================================ + +The Android USB Serial implementation (MeshMapperUsbService.kt) is based on +the USB host code from meshcore-open. + +MIT License + +Copyright (c) 2025 zjs81 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 688df15e..bd38937d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + + diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index d529fc65..c96604d9 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -9,9 +9,14 @@ import org.maplibre.android.offline.OfflineRegionStatus import java.io.File class MainActivity : FlutterActivity() { + private var usbService: MeshMapperUsbService? = null + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + usbService = MeshMapperUsbService(this) + usbService!!.configureFlutterEngine(flutterEngine) + // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS // implementation. Called from Dart's TileCacheService by the Offline // Maps screen's Tile Cache card. @@ -90,4 +95,9 @@ class MainActivity : FlutterActivity() { } ) } + + override fun onDestroy() { + usbService?.dispose() + super.onDestroy() + } } diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt new file mode 100644 index 00000000..c2279c47 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt @@ -0,0 +1,557 @@ +// Android USB Serial implementation based on meshcore-open +// https://github.com/zjs81/meshcore-open +// Copyright (c) zjs81, MIT License — see THIRD_PARTY_LICENSES + +package net.meshmapper.app + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +class MeshMapperUsbService( + private val activity: FlutterActivity, +) { + private companion object { + const val USB_RECIPIENT_INTERFACE = 0x01 + const val METHOD_CHANNEL = "net.meshmapper.app/usb_serial" + const val EVENT_CHANNEL = "net.meshmapper.app/usb_serial/data" + const val USB_PERMISSION_ACTION = "net.meshmapper.app.USB_PERMISSION" + } + + private val usbManager by lazy { + activity.getSystemService(Context.USB_SERVICE) as UsbManager + } + private val mainHandler = Handler(Looper.getMainLooper()) + private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + @Volatile private var eventSink: EventChannel.EventSink? = null + @Volatile private var usbConnection: UsbDeviceConnection? = null + @Volatile private var usbInEndpoint: UsbEndpoint? = null + @Volatile private var usbOutEndpoint: UsbEndpoint? = null + @Volatile private var controlInterface: UsbInterface? = null + @Volatile private var dataInterface: UsbInterface? = null + private var readThread: Thread? = null + @Volatile private var isReading = false + @Volatile private var connectedDeviceName: String? = null + + private val readAttempts = AtomicLong(0) + private val bytesReceived = AtomicLong(0) + private val eventsPosted = AtomicLong(0) + private val sinkNullCount = AtomicLong(0) + + private var pendingConnectResult: MethodChannel.Result? = null + private var pendingConnectPortName: String? = null + private var pendingConnectBaudRate: Int = 115200 + + private data class PortConfig( + val controlInterface: UsbInterface?, + val dataInterface: UsbInterface, + val inEndpoint: UsbEndpoint, + val outEndpoint: UsbEndpoint, + ) + + private val permissionReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + handleUsbDetached(intent) + return + } + USB_PERMISSION_ACTION -> Unit + else -> return + } + + val result = pendingConnectResult + val portName = pendingConnectPortName + pendingConnectResult = null + pendingConnectPortName = null + + if (result == null || portName == null) return + + val device = findUsbDevice(portName) + if (device == null) { + result.error("usb_device_missing", "USB device not found after permission grant", null) + return + } + + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (!granted || !usbManager.hasPermission(device)) { + result.error("usb_permission_denied", "USB permission was denied", null) + return + } + + openUsbDevice(device, pendingConnectBaudRate, result) + } + } + + fun configureFlutterEngine(flutterEngine: FlutterEngine) { + registerUsbPermissionReceiver() + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "listDevices" -> result.success(listUsbDevices()) + "connect" -> handleConnect(call, result) + "write" -> handleWrite(call, result) + "readDiagnostics" -> result.success(mapOf( + "readAttempts" to readAttempts.get(), + "bytesReceived" to bytesReceived.get(), + "eventsPosted" to eventsPosted.get(), + "sinkNullCount" to sinkNullCount.get(), + "isReading" to isReading, + "threadAlive" to (readThread?.isAlive == true), + "sinkSet" to (eventSink != null), + )) + "disconnect" -> { + scheduleCloseUsbConnection { + result.success(null) + } + } + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + override fun onCancel(arguments: Any?) { + eventSink = null + } + }, + ) + } + + fun dispose() { + closeUsbConnection() + usbIoExecutor.shutdownNow() + try { + activity.unregisterReceiver(permissionReceiver) + } catch (_: IllegalArgumentException) {} + } + + private fun registerUsbPermissionReceiver() { + val filter = IntentFilter().apply { + addAction(USB_PERMISSION_ACTION) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @Suppress("DEPRECATION") + activity.registerReceiver(permissionReceiver, filter) + } + } + + private fun listUsbDevices(): List> { + return usbManager.deviceList.values.map { device -> + val serial = try { device.serialNumber } catch (_: SecurityException) { null } + mapOf( + "deviceName" to device.deviceName, + "productName" to (device.productName ?: "USB Serial Device"), + "vid" to device.vendorId, + "pid" to device.productId, + "serial" to serial, + ) + } + } + + private fun handleConnect(call: MethodCall, result: MethodChannel.Result) { + val deviceName = call.argument("deviceName") + val baudRate = call.argument("baudRate") ?: 115200 + if (deviceName.isNullOrBlank()) { + result.error("usb_invalid_port", "Device name is required", null) + return + } + + val device = findUsbDevice(deviceName) + if (device == null) { + result.error("usb_device_missing", "USB device '$deviceName' not found", null) + return + } + + if (usbManager.hasPermission(device)) { + openUsbDevice(device, baudRate, result) + return + } + + if (pendingConnectResult != null) { + result.error("usb_busy", "Another USB permission request is pending", null) + return + } + + pendingConnectResult = result + pendingConnectPortName = deviceName + pendingConnectBaudRate = baudRate + + val permissionIntent = PendingIntent.getBroadcast( + activity, + 0, + Intent(USB_PERMISSION_ACTION).setPackage(activity.packageName), + pendingIntentFlags(), + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun handleWrite(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument("data") + val connection = usbConnection + val endpoint = usbOutEndpoint + if (data == null) { + result.error("usb_invalid_data", "Write data is required", null) + return + } + if (connection == null || endpoint == null) { + result.error("usb_not_connected", "No USB device connected", null) + return + } + + usbIoExecutor.execute { + try { + writeToDevice(data) + mainHandler.post { result.success(null) } + } catch (error: Exception) { + mainHandler.post { + result.error("usb_write_failed", error.message, null) + } + } + } + } + + private fun findUsbDevice(deviceName: String): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { it.deviceName == deviceName } + } + + private fun openUsbDevice( + device: UsbDevice, + baudRate: Int, + result: MethodChannel.Result, + ) { + usbIoExecutor.execute { + try { + closeUsbConnection() + + val config = resolvePortConfig(device) + if (config == null) { + mainHandler.post { + result.error("usb_no_endpoints", "No compatible USB endpoints found on device", null) + } + return@execute + } + + val connection = usbManager.openDevice(device) + if (connection == null) { + mainHandler.post { + result.error("usb_open_failed", "Failed to open USB device connection", null) + } + return@execute + } + + if (!connection.claimInterface(config.dataInterface, true)) { + connection.close() + mainHandler.post { + result.error("usb_open_failed", "Failed to claim USB data interface", null) + } + return@execute + } + + if (config.controlInterface != null && + config.controlInterface.id != config.dataInterface.id && + !connection.claimInterface(config.controlInterface, true) + ) { + connection.releaseInterface(config.dataInterface) + connection.close() + mainHandler.post { + result.error("usb_open_failed", "Failed to claim USB control interface", null) + } + return@execute + } + + usbConnection = connection + usbInEndpoint = config.inEndpoint + usbOutEndpoint = config.outEndpoint + controlInterface = config.controlInterface + dataInterface = config.dataInterface + + configureDevice(connection, config, baudRate) + + connectedDeviceName = device.deviceName + startReadLoop() + + mainHandler.post { + result.success(mapOf( + "interfaceCount" to device.interfaceCount, + "controlFound" to (config.controlInterface != null), + "controlId" to config.controlInterface?.id, + "controlClass" to config.controlInterface?.interfaceClass, + "dataId" to config.dataInterface.id, + "dataClass" to config.dataInterface.interfaceClass, + "inMaxPacket" to config.inEndpoint.maxPacketSize, + "outMaxPacket" to config.outEndpoint.maxPacketSize, + )) + } + } catch (error: Exception) { + closeUsbConnection() + mainHandler.post { + result.error("usb_connect_failed", error.message, null) + } + } + } + } + + private fun resolvePortConfig(device: UsbDevice): PortConfig? { + var preferredDataInterface: UsbInterface? = null + var preferredInEndpoint: UsbEndpoint? = null + var preferredOutEndpoint: UsbEndpoint? = null + var fallbackDataInterface: UsbInterface? = null + var fallbackInEndpoint: UsbEndpoint? = null + var fallbackOutEndpoint: UsbEndpoint? = null + var preferredControlInterface: UsbInterface? = null + + for (i in 0 until device.interfaceCount) { + val usbInterface = device.getInterface(i) + var inEp: UsbEndpoint? = null + var outEp: UsbEndpoint? = null + + for (j in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(j) + if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) continue + when (endpoint.direction) { + UsbConstants.USB_DIR_IN -> if (inEp == null) inEp = endpoint + UsbConstants.USB_DIR_OUT -> if (outEp == null) outEp = endpoint + } + } + + val hasDataPair = inEp != null && outEp != null + when { + usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM && + preferredControlInterface == null -> { + preferredControlInterface = usbInterface + } + hasDataPair && + usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> { + preferredDataInterface = usbInterface + preferredInEndpoint = inEp + preferredOutEndpoint = outEp + } + hasDataPair && fallbackDataInterface == null -> { + fallbackDataInterface = usbInterface + fallbackInEndpoint = inEp + fallbackOutEndpoint = outEp + } + } + } + + val dataIface = preferredDataInterface ?: fallbackDataInterface ?: return null + val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null + val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null + return PortConfig(preferredControlInterface, dataIface, inEndpoint, outEndpoint) + } + + private fun configureDevice( + connection: UsbDeviceConnection, + config: PortConfig, + baudRate: Int, + ) { + val control = config.controlInterface ?: return + + val lineCoding = byteArrayOf( + (baudRate and 0xFF).toByte(), + ((baudRate shr 8) and 0xFF).toByte(), + ((baudRate shr 16) and 0xFF).toByte(), + ((baudRate shr 24) and 0xFF).toByte(), + 0, // stop bits: 1 + 0, // parity: none + 8, // data bits + ) + + val lineCodingResult = connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or USB_RECIPIENT_INTERFACE, + 0x20, // SET_LINE_CODING + 0, + control.id, + lineCoding, + lineCoding.size, + 1000, + ) + if (lineCodingResult < 0) { + throw IllegalStateException("Failed to configure USB line coding") + } + + val controlLineResult = connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or USB_RECIPIENT_INTERFACE, + 0x22, // SET_CONTROL_LINE_STATE + 0x0001, // DTR on, RTS off + control.id, + null, + 0, + 1000, + ) + if (controlLineResult < 0) { + throw IllegalStateException("Failed to configure USB control line state") + } + } + + private fun startReadLoop() { + val connection = usbConnection ?: return + val endpoint = usbInEndpoint ?: return + + isReading = true + readAttempts.set(0) + bytesReceived.set(0) + eventsPosted.set(0) + sinkNullCount.set(0) + + readThread = Thread({ + val packetSize = endpoint.maxPacketSize.coerceAtLeast(64) + val buffer = ByteArray(packetSize * 4) + try { + while (isReading) { + readAttempts.incrementAndGet() + val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250) + if (!isReading) break + if (bytesRead <= 0) continue + bytesReceived.addAndGet(bytesRead.toLong()) + val packet = buffer.copyOf(bytesRead) + mainHandler.post { + val sink = eventSink + if (sink != null) { + sink.success(packet) + eventsPosted.incrementAndGet() + } else { + sinkNullCount.incrementAndGet() + } + } + } + } catch (error: Exception) { + if (isReading) { + mainHandler.post { + eventSink?.error("usb_io_error", error.message ?: "USB serial I/O error", null) + } + scheduleCloseUsbConnection() + } + } + }, "MeshMapperUsbRead").also { thread -> + thread.isDaemon = true + thread.start() + } + } + + private fun writeToDevice(data: ByteArray) { + val connection = usbConnection ?: throw IllegalStateException("USB connection missing") + val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing") + var offset = 0 + val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64) + while (offset < data.size) { + val chunkSize = minOf(maxPacketSize, data.size - offset) + val chunk = data.copyOfRange(offset, offset + chunkSize) + val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000) + if (bytesWritten < 0) { + throw IllegalStateException("USB write error: bulkTransfer returned $bytesWritten") + } + offset += bytesWritten + } + } + + private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) { + usbIoExecutor.execute { + closeUsbConnection() + if (onComplete != null) { + mainHandler.post(onComplete) + } + } + } + + @Synchronized + private fun closeUsbConnection() { + isReading = false + readThread?.interrupt() + if (readThread != null && readThread !== Thread.currentThread()) { + try { + readThread?.join(300) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + readThread = null + + val connection = usbConnection + val claimedControl = controlInterface + val claimedData = dataInterface + + usbInEndpoint = null + usbOutEndpoint = null + controlInterface = null + dataInterface = null + usbConnection = null + + if (connection != null) { + if (claimedControl != null) { + try { connection.releaseInterface(claimedControl) } catch (_: Exception) {} + } + if (claimedData != null && claimedData.id != claimedControl?.id) { + try { connection.releaseInterface(claimedData) } catch (_: Exception) {} + } + try { connection.close() } catch (_: Exception) {} + } + connectedDeviceName = null + } + + private fun handleUsbDetached(intent: Intent) { + val detachedDevice = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + val detachedName = detachedDevice?.deviceName ?: return + + if (pendingConnectPortName == detachedName) { + pendingConnectResult?.error( + "usb_device_detached", + "USB device was removed before the connection completed", + null, + ) + pendingConnectResult = null + pendingConnectPortName = null + } + + if (connectedDeviceName == detachedName) { + scheduleCloseUsbConnection { + eventSink?.error( + "usb_device_detached", + "USB device was disconnected", + null, + ) + } + } + } + + private fun pendingIntentFlags(): Int { + return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } +} diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index e8072954..4b3a9659 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -1,3 +1,6 @@ +/// Transport type for MeshCore companion connections. +enum TransportType { ble, tcp, usbSerial } + /// Connection status for Bluetooth devices enum ConnectionStatus { /// Not connected to any device @@ -25,8 +28,8 @@ enum ConnectionStep { /// Auto-reconnecting after unexpected BLE disconnect reconnecting, - /// Step 1: BLE GATT connect - bleConnecting, + /// Step 1: Transport-level connect (BLE/TCP/USB) + transportConnecting, /// Step 2: Protocol handshake protocolHandshake, @@ -52,6 +55,9 @@ enum ConnectionStep { /// Step 9: Fully connected and ready connected, + /// Disconnecting (cleanup in progress) + disconnecting, + /// Error state error, } @@ -85,7 +91,7 @@ extension ConnectionStepExtension on ConnectionStep { return 'Disconnected'; case ConnectionStep.reconnecting: return 'Reconnecting...'; - case ConnectionStep.bleConnecting: + case ConnectionStep.transportConnecting: return 'Connecting to device...'; case ConnectionStep.protocolHandshake: return 'Protocol handshake...'; @@ -103,6 +109,8 @@ extension ConnectionStepExtension on ConnectionStep { return 'Initializing GPS...'; case ConnectionStep.connected: return 'Connected'; + case ConnectionStep.disconnecting: + return 'Disconnecting...'; case ConnectionStep.error: return 'Connection error'; } @@ -115,7 +123,7 @@ extension ConnectionStepExtension on ConnectionStep { return 0; case ConnectionStep.reconnecting: return 0; - case ConnectionStep.bleConnecting: + case ConnectionStep.transportConnecting: return 1; case ConnectionStep.protocolHandshake: return 2; @@ -133,6 +141,8 @@ extension ConnectionStepExtension on ConnectionStep { return 8; case ConnectionStep.connected: return 9; + case ConnectionStep.disconnecting: + return 0; case ConnectionStep.error: return -1; } diff --git a/lib/models/remembered_device.dart b/lib/models/remembered_device.dart index 2bf9236a..426502c2 100644 --- a/lib/models/remembered_device.dart +++ b/lib/models/remembered_device.dart @@ -1,33 +1,57 @@ -/// Remembered BLE device for quick reconnection +import 'connection_state.dart'; + +/// Remembered device for quick reconnection. +/// Supports BLE, TCP, and USB Serial transports. class RememberedDevice { final String id; final String name; final DateTime lastConnected; + final TransportType transportType; + final String? tcpHost; + final int? tcpPort; + final String? serialPortPath; const RememberedDevice({ required this.id, required this.name, required this.lastConnected, + this.transportType = TransportType.ble, + this.tcpHost, + this.tcpPort, + this.serialPortPath, }); - /// Create from JSON (for persistence) factory RememberedDevice.fromJson(Map json) { return RememberedDevice( id: json['id'] as String, name: json['name'] as String, lastConnected: DateTime.parse(json['lastConnected'] as String), + transportType: _parseTransportType(json['transportType'] as String?), + tcpHost: json['tcpHost'] as String?, + tcpPort: json['tcpPort'] as int?, + serialPortPath: json['serialPortPath'] as String?, ); } - /// Convert to JSON (for persistence) Map toJson() { return { 'id': id, 'name': name, 'lastConnected': lastConnected.toIso8601String(), + 'transportType': transportType.name, + if (tcpHost != null) 'tcpHost': tcpHost, + if (tcpPort != null) 'tcpPort': tcpPort, + if (serialPortPath != null) 'serialPortPath': serialPortPath, }; } - /// Get display name (stripped of MeshCore- prefix) String get displayName => name.replaceFirst('MeshCore-', ''); + + static TransportType _parseTransportType(String? value) { + if (value == null) return TransportType.ble; + return TransportType.values.firstWhere( + (e) => e.name == value, + orElse: () => TransportType.ble, + ); + } } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ab3f28b0..20fba856 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -25,6 +25,9 @@ import '../services/background_service.dart'; import '../services/debug_file_logger.dart'; import '../services/offline_session_service.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../services/transport/android_serial_service.dart'; +import '../services/transport/companion_transport.dart'; +import '../services/transport/tcp_service.dart'; import '../services/device_model_service.dart'; import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; @@ -119,6 +122,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { StreamSubscription? _noiseFloorSubscription; StreamSubscription? _batterySubscription; + // Transport selection + TransportType _selectedTransport = TransportType.ble; + CompanionTransport? _activeTransport; + StreamSubscription? _transportConnectionSubscription; + // Device identity String _deviceId = ''; @@ -152,8 +160,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _devicePublicKey; String? _offlineContactUri; - /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") - String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; + /// Connected device name (e.g., "MeshCore-MrAlders0n_Elecrow" for BLE, "TCP 10.0.0.1:5000" for TCP) + String? get connectedDeviceName => + (_activeTransport ?? _bluetoothService).connectedDevice?.name; /// Display name from SelfInfo (reflects user's chosen name in MeshCore) /// BLE advertisement name may be cached/stale after device rename @@ -277,6 +286,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isSwitchingMode = false; String? _modeSwitchError; // Error message if mode switch fails + // Connection guard — prevents concurrent connect attempts and provides instant UI feedback + bool _isConnecting = false; + // Auto-reconnect state bool _userRequestedDisconnect = false; bool _isAutoReconnecting = false; @@ -388,6 +400,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String get deviceId => _deviceId; bool get preferencesLoaded => _preferencesLoaded; + TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; @@ -558,6 +571,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Anonymous mode getter bool get isAnonymousRenamed => _isAnonymousRenamed; + // Connection guard getter + bool get isConnecting => _isConnecting; + // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; @@ -1129,13 +1145,313 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Connection // ============================================ - /// Connect to a discovered device - Future connectToDevice(DiscoveredDevice device) async { + /// Creates the two-stage auth callback for MeshCoreConnection Step 6. + /// Shared by all transport types (BLE, TCP, USB Serial). + Future?> Function() _createAuthCallback() { + return () async { + final publicKey = _meshCoreConnection!.devicePublicKey; + if (publicKey == null) { + debugError('[APP] Cannot request auth: no public key'); + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; + } + + // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" + if (_preferences.anonymousMode && !_isAnonymousRenamed) { + final realName = _meshCoreConnection!.selfInfo?.name; + if (realName != null && realName.isNotEmpty) { + if (realName == 'Anonymous') { + final persisted = _deviceRealNames[publicKey]; + _originalDeviceName = persisted ?? realName; + if (persisted != null) { + debugLog( + '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); + } + } else { + _originalDeviceName = realName; + } + try { + await _meshCoreConnection!.setAdvertName('Anonymous'); + _isAnonymousRenamed = true; + _displayDeviceName = 'Anonymous'; + if (_originalDeviceName != 'Anonymous') { + _deviceRealNames[publicKey] = _originalDeviceName!; + _saveDeviceRealNames(); + } + debugLog( + '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugError('[CONN] Anonymous mode: rename failed: $e'); + } + } + } + + // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name + String? deviceName; + if (_isAnonymousRenamed) { + deviceName = 'Anonymous'; + } else { + var selfInfoName = _meshCoreConnection!.selfInfo?.name; + if (selfInfoName == 'Anonymous') { + final persistedName = _deviceRealNames[publicKey]; + if (persistedName != null) { + debugLog( + '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); + try { + await _meshCoreConnection!.setAdvertName(persistedName); + await Future.delayed(const Duration(milliseconds: 300)); + final refreshed = await _meshCoreConnection!.getSelfInfo(); + selfInfoName = refreshed.name; + debugLog( + '[CONN] Confirmed firmware name restored to "$selfInfoName"'); + _clearPersistedRealName(publicKey); + } catch (e) { + debugError('[CONN] Failed to restore firmware name: $e'); + selfInfoName = persistedName; + } + } else { + debugWarn( + '[CONN] Firmware name is "Anonymous" but no persisted real name found'); + } + } + deviceName = selfInfoName ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); + } + if (deviceName == null || deviceName.isEmpty) { + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; + } + + // Stage 1: Try existing public_key authentication + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + + final result = await _apiService.requestAuth( + reason: 'connect', + publicKey: publicKey, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + ); + + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + return { + 'success': false, + 'reason': 'maintenance', + 'message': _maintenanceMessage ?? 'Service is under maintenance', + }; + } + + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + return result; + } + + if (result == null) { + debugError('[APP] API unreachable - network error'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } + + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + return { + 'success': false, + 'reason': stage1Reason, + 'message': result['message'] as String?, + }; + } + + // Stage 2: Auth failed, attempt registration via signed contact_uri + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + return { + 'success': false, + 'reason': 'registration_failed', + 'message': + 'Companion not found in backend and failed to register via API' + }; + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } + + if (registerResult['success'] != true) { + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + return { + 'success': false, + 'reason': serverReason, + 'message': serverMessage ?? 'Registration rejected by server', + }; + } + + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + return registerResult; + }; + } + + /// Handle connection errors — shared by all transport connection methods. + Future _handleConnectionError(Object e) async { + debugError('[APP] Connection failed: $e'); + try { - _connectionError = null; + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } catch (channelError) { + debugError('[APP] Cleanup channel delete failed: $channelError'); + } + + try { + if (_meshCoreConnection != null) { + await _meshCoreConnection!.disconnect(); + } + } catch (disconnectError) { + debugError('[APP] Cleanup disconnect failed: $disconnectError'); + } + + final errorStr = e.toString(); + if (errorStr.contains('AUTH_FAILED:')) { + _isAuthError = true; + final parts = errorStr.split('AUTH_FAILED:'); + if (parts.length > 1) { + final errorParts = parts[1].split(':'); + final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; + _connectionError = _getErrorMessage(reason, serverMessage); + } else { + _connectionError = 'Authentication failed'; + } + } else { _isAuthError = false; _isNetworkError = false; + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { + _connectionError = 'Connection timed out'; + } else { + _connectionError = errorStr.replaceFirst('Exception: ', ''); + } + } + _isConnecting = false; + _connectionStep = ConnectionStep.error; + notifyListeners(); + } + /// Set up disconnect listener for non-BLE transports (TCP, USB Serial). + void _setupTransportDisconnectListener(CompanionTransport transport) { + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = + transport.connectionStream.listen((status) async { + if (status == ConnectionStatus.disconnected) { + final wasConnected = _connectionStep == ConnectionStep.connected; + final hasRemembered = _rememberedDevice != null; + final isUnexpected = + !_userRequestedDisconnect && !_isAutoReconnecting; + final canAutoReconnect = hasRemembered && + !kIsWeb && + _rememberedDevice!.transportType != TransportType.usbSerial; + if (wasConnected && isUnexpected && canAutoReconnect) { + debugLog( + '[CONN] Unexpected transport disconnect - starting auto-reconnect'); + await _startAutoReconnect(); + } else if (!_isAutoReconnecting) { + await _fullDisconnectCleanup(); + } + notifyListeners(); + } + }); + } + + /// Connect to a discovered device + Future connectToDevice(DiscoveredDevice device) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { // Clean up any previous connection first if (_meshCoreConnection != null) { debugLog('[APP] Disposing previous MeshCoreConnection'); @@ -1146,269 +1462,273 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // ALWAYS START FRESH - clear any stale pings before connecting await _apiQueueService.clearBeforeConnect(); - // Create MeshCore connection + debugLog('[APP] Connecting BLE transport to ${device.id}'); + await _bluetoothService.connect(device.id); + _activeTransport = _bluetoothService; debugLog('[APP] Creating new MeshCoreConnection'); - _meshCoreConnection = MeshCoreConnection(bluetooth: _bluetoothService); + _meshCoreConnection = MeshCoreConnection(transport: _bluetoothService); - // Set auth callback for Step 6 (called during connect, after public key is acquired) - // Implements two-stage auth flow with registration fallback - // Skip auth when offline mode is enabled if (!_preferences.offlineMode) { - _meshCoreConnection!.onRequestAuth = () async { - final publicKey = _meshCoreConnection!.devicePublicKey; - if (publicKey == null) { - debugError('[APP] Cannot request auth: no public key'); - return { - 'success': false, - 'reason': 'no_public_key', - 'message': 'Device public key not available' - }; - } + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" - if (_preferences.anonymousMode && !_isAnonymousRenamed) { - final realName = _meshCoreConnection!.selfInfo?.name; - if (realName != null && realName.isNotEmpty) { - // Cascade guard: if firmware is stuck as "Anonymous" from a previous - // unclean disconnect, recover the real name from Hive - if (realName == 'Anonymous') { - final persisted = _deviceRealNames[publicKey]; - _originalDeviceName = - persisted ?? realName; // fall back if nothing saved - if (persisted != null) { - debugLog( - '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); - } - } else { - _originalDeviceName = realName; - } - try { - await _meshCoreConnection!.setAdvertName('Anonymous'); - _isAnonymousRenamed = true; - _displayDeviceName = 'Anonymous'; - // Persist real name keyed by public key (only if not "Anonymous") - if (_originalDeviceName != 'Anonymous') { - _deviceRealNames[publicKey] = _originalDeviceName!; - _saveDeviceRealNames(); - } - debugLog( - '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); - // Short delay for firmware to process - await Future.delayed(const Duration(milliseconds: 300)); - } catch (e) { - debugError('[CONN] Anonymous mode: rename failed: $e'); - // Continue with real name if rename fails - } - } - } + // Listen for step changes + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + // Update device info + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name - String? deviceName; - if (_isAnonymousRenamed) { - deviceName = 'Anonymous'; - } else { - var selfInfoName = _meshCoreConnection!.selfInfo?.name; - // Detect stuck anonymous name: firmware still has "Anonymous" but mode is OFF - if (selfInfoName == 'Anonymous') { - final persistedName = _deviceRealNames[publicKey]; - if (persistedName != null) { - debugLog( - '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); - try { - await _meshCoreConnection!.setAdvertName(persistedName); - await Future.delayed(const Duration(milliseconds: 300)); - final refreshed = await _meshCoreConnection!.getSelfInfo(); - selfInfoName = refreshed.name; - debugLog( - '[CONN] Confirmed firmware name restored to "$selfInfoName"'); - _clearPersistedRealName(publicKey); - } catch (e) { - debugError('[CONN] Failed to restore firmware name: $e'); - selfInfoName = persistedName; - } - } else { - debugWarn( - '[CONN] Firmware name is "Anonymous" but no persisted real name found'); - } - } - deviceName = selfInfoName ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Persist device info for bug reports when disconnected + // Use original name (not "Anonymous") for bug report identification + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; } - if (deviceName == null || deviceName.isEmpty) { - debugError( - '[APP] Cannot request auth: could not retrieve device name'); - return { - 'success': false, - 'reason': 'no_device_name', - 'message': 'Could not retrieve device name' - }; + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - // ============================================================ - // STAGE 1: Try existing public_key authentication - // ============================================================ - debugLog( - '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } + } + notifyListeners(); + }); - final result = await _apiService.requestAuth( - reason: 'connect', - publicKey: publicKey, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? - _meshCoreConnection!.deviceInfo?.manufacturer ?? - 'Unknown', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + // Listen for noise floor updates — only rebuild UI when value changes + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); - // Check for maintenance mode - if (result != null && result['maintenance'] == true) { - _maintenanceMode = true; - _maintenanceMessage = result['maintenance_message'] as String?; - _maintenanceUrl = result['maintenance_url'] as String?; - debugLog( - '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); - _startMaintenancePolling(); - notifyListeners(); - return { - 'success': false, - 'reason': 'maintenance', - 'message': _maintenanceMessage ?? 'Service is under maintenance', - }; - } + // Listen for battery updates — only rebuild UI when value changes + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); - // Check if Stage 1 succeeded - if (result != null && result['success'] == true) { - debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + // Execute connection workflow (transport already connected above) + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); - // Store the auth type from response - if (result['type'] != null) { - _authType = result['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + await _postConnectionSetup(connectionResult, device); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + } + } - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(result); + /// Set the selected transport type for the connection screen. + void setSelectedTransport(TransportType type) { + _selectedTransport = type; + notifyListeners(); + } - return result; - } + /// Connect to a MeshCore device via TCP. + Future connectViaTcp(String host, int port) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } - // API unreachable (null = network/timeout error, not an auth rejection) - if (result == null) { - debugError('[APP] API unreachable - network error'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; - } + await _apiQueueService.clearBeforeConnect(); - debugLog( - '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); - - // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data - final stage1Reason = result['reason'] as String?; - if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError( - '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); - return { - 'success': false, - 'reason': stage1Reason, - 'message': result['message'] as String?, - }; - } + final tcpService = TcpService(host: host, port: port); + debugLog('[APP] Connecting TCP transport to $host:$port'); + await tcpService.openConnection(); + _activeTransport = tcpService; + _setupTransportDisconnectListener(tcpService); - // ============================================================ - // STAGE 2: Auth failed, attempt registration via signed contact_uri - // ============================================================ - debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + debugLog('[APP] Creating new MeshCoreConnection (TCP)'); + _meshCoreConnection = MeshCoreConnection(transport: tcpService); - String? contactUri; - try { - debugLog('[APP] Requesting signed contact URI from device...'); - contactUri = await _meshCoreConnection!.exportContact(); - debugLog( - '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); - } catch (e) { - debugError('[APP] Failed to get contact URI from device: $e'); - return { - 'success': false, - 'reason': 'registration_failed', - 'message': - 'Companion not found in backend and failed to register via API' - }; - } + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - // Call API with contact_uri for registration - final registerResult = await _apiService.requestAuth( - reason: 'register', - contactUri: contactUri, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? - _meshCoreConnection!.deviceInfo?.manufacturer ?? - 'Unknown', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + _manufacturerString = + _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - if (registerResult == null) { - debugError('[APP] Stage 2 failed: network error (API unreachable)'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - if (registerResult['success'] != true) { - final serverReason = - registerResult['reason'] as String? ?? 'registration_failed'; - final serverMessage = registerResult['message'] as String?; - debugError( - '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); - return { - 'success': false, - 'reason': serverReason, - 'message': serverMessage ?? 'Registration rejected by server', - }; + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); } + } + notifyListeners(); + }); - // Registration successful - response contains full auth data directly - debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); - // Store the auth type from response - if (registerResult['type'] != null) { - _authType = registerResult['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(registerResult); + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); - return registerResult; - }; + final device = DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + ); + await _postConnectionSetup(connectionResult, device, + tcpHost: host, tcpPort: port); + + await TcpService.saveConnection(host, port, displayDeviceName ?? ''); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } + + /// Connect to a MeshCore device via Android USB Serial (OTG). + Future connectViaUsb(Map usbDevice) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } + + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + + await _apiQueueService.clearBeforeConnect(); + + final usbProductName = + usbDevice['productName'] as String? ?? 'USB Serial'; + final usbDeviceName = + usbDevice['deviceName'] as String? ?? 'USB Serial'; + final serialService = AndroidSerialService( + deviceName: usbDeviceName, + productName: usbProductName, + ); + debugLog('[APP] Connecting USB Serial transport to $usbProductName'); + await serialService.openConnection(); + _activeTransport = serialService; + _setupTransportDisconnectListener(serialService); + + debugLog('[APP] Creating new MeshCoreConnection (USB Serial)'); + _meshCoreConnection = MeshCoreConnection(transport: serialService); + + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); } else { - // Offline mode: skip API auth _meshCoreConnection!.onRequestAuth = null; debugLog('[APP] Offline mode: skipping API auth'); } - // Listen for step changes _meshCoreConnection!.stepStream.listen((step) { _connectionStep = step; if (step == ConnectionStep.connected) { - // Update device info - _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _manufacturerString = + _meshCoreConnection!.deviceInfo?.manufacturer; _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; @@ -1416,15 +1736,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Persist device info for bug reports when disconnected - // Use original name (not "Anonymous") for bug report identification var lastDeviceName = _isAnonymousRenamed ? _originalDeviceName : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); if (lastDeviceName != null) { lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); } - // Cascade guard: never persist "Anonymous" as the last connected device if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { lastDeviceName = _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; @@ -1435,7 +1752,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - // In offline mode, fetch signed contact URI for later registration during upload if (_preferences.offlineMode && _meshCoreConnection != null) { _meshCoreConnection!.exportContact().then((uri) { _offlineContactUri = uri; @@ -1448,7 +1764,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }); - // Listen for noise floor updates — only rebuild UI when value changes _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _recordNoiseFloorSample(noiseFloor); @@ -1458,7 +1773,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }); - // Listen for battery updates — only rebuild UI when value changes _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { if (batteryPercent != _currentBatteryPercent) { @@ -1467,689 +1781,693 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }); - // Execute connection workflow final connectionResult = await _meshCoreConnection!.connect( - device.id, _deviceModelService.models, ); - // Update preferences if device model was recognized (for display/API reporting) - // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && - connectionResult.deviceModel != null) { - final device = connectionResult.deviceModel!; - _preferences = _preferences.copyWith( - powerLevel: device.power, - txPower: device.txPower, - autoPowerSet: - true, // Indicates power was auto-detected from device model - powerLevelSet: false, // Clear stale manual flag from previous session - ); - notifyListeners(); - debugLog( - '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + final vid = usbDevice['vid'] as int? ?? 0; + final pid = usbDevice['pid'] as int? ?? 0; + final serial = usbDevice['serial'] as String? ?? ''; + final deviceId = '$vid:$pid:$serial'; + final device = DiscoveredDevice( + id: deviceId, + name: usbProductName, + ); + await _postConnectionSetup(connectionResult, device, + serialPortPath: deviceId); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } - // Note: API session acquisition is now handled by the auth callback - // during connection workflow Step 6 (onRequestAuth) + /// Connect using a pre-opened transport (for platform-specific transports + /// like Web Serial that can't be imported cross-platform). + Future connectWithTransport( + CompanionTransport transport, { + required String deviceId, + required String deviceName, + String? serialPortPath, + }) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } - // Create unified RX handler - await _createUnifiedRxHandler(); + await _apiQueueService.clearBeforeConnect(); - // Set regional channels from API response and update validator - final apiChannels = _apiService.channels; - await ChannelService.setRegionalChannels(apiChannels); - _regionalChannels = ChannelService.getRegionalChannelNames(); - debugLog('[APP] Regional channels configured: $_regionalChannels'); + _activeTransport = transport; + _setupTransportDisconnectListener(transport); - // Update unified RX handler's validator with new channel configuration - if (_unifiedRxHandler != null) { - final allowedChannelsData = - ChannelService.getAllowedChannelsForValidator(); - final allowedChannels = {}; - for (final entry in allowedChannelsData.entries) { - allowedChannels[entry.key] = ChannelInfo( - channelName: entry.value.channelName, - key: entry.value.key, - hash: entry.value.hash, - ); - } - final newValidator = PacketValidator( - allowedChannels: allowedChannels, - disableRssiFilter: _preferences.disableRssiFilter, - ); - _unifiedRxHandler!.updateValidator(newValidator); - debugLog( - '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' - '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); - } + debugLog('[APP] Creating new MeshCoreConnection (generic transport)'); + _meshCoreConnection = MeshCoreConnection(transport: transport); - // Set flood scope from API response (regional TX filtering) - // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) - // Any other value (e.g., "ottawa") → derive TransportKey and set scope - final apiScopes = _apiService.scopes; - final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = - firstScope == null || firstScope == '*' || firstScope == '#*'; - if (!isWildcard) { - final scopeName = firstScope; - _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; - final scopeKey = CryptoService.deriveScopeKey(scopeName); - debugLog('[CONN] Setting flood scope: $scopeName'); - await _meshCoreConnection!.setFloodScope(scopeKey); - debugLog('[CONN] Flood scope set successfully'); + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); } else { - _scope = null; - debugLog('[CONN] No regional scope — using unscoped flood'); + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); } - // Enforce hybrid mode if required by regional admin - if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { - _preferences = _preferences.copyWith(hybridModeEnabled: true); - debugLog('[CONN] Hybrid mode force-enabled by regional admin'); - } + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + _manufacturerString = + _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Enforce discovery drop if required by regional admin - if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { - _preferences = _preferences.copyWith(discDropEnabled: true); - debugLog('[CONN] Discovery drop force-enabled by regional admin'); - } + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); + } - // Sync Flood Traffic preference with regional policy: - // - flood_disabled=true → force OFF (region forbids) - // - flood_disabled=false → force ON (region permits, user lands ready) - // Fire a one-shot alert only on user-on → region-off transition. - final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; - final shouldEnableFlood = !_apiService.floodDisabled; - if (_preferences.floodTrafficEnabled != shouldEnableFlood) { - _preferences = - _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); - debugLog(shouldEnableFlood - ? '[CONN] Flood traffic auto-enabled (region permits)' - : '[CONN] Flood traffic disabled by regional admin'); - } - if (wasFloodEnabledByUser && _apiService.floodDisabled) { - _floodDisabledAlertPending = true; + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } + } + notifyListeners(); + }); + + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); + + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); + + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); + + final device = DiscoveredDevice(id: deviceId, name: deviceName); + await _postConnectionSetup(connectionResult, device, + serialPortPath: serialPortPath); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } + + /// Post-connection setup shared by all transport types. + /// Called after MeshCoreConnection.connect() completes successfully. + Future _postConnectionSetup( + ({DeviceModel? deviceModel, bool deviceModelMatched}) connectionResult, + DiscoveredDevice device, { + String? tcpHost, + int? tcpPort, + String? serialPortPath, + }) async { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { + final matchedDevice = connectionResult.deviceModel!; + _preferences = _preferences.copyWith( + powerLevel: matchedDevice.power, + txPower: matchedDevice.txPower, + autoPowerSet: true, + powerLevelSet: false, + ); + notifyListeners(); + debugLog( + '[MODEL] Device recognized: ${matchedDevice.shortName} - reporting ${matchedDevice.power}W in API calls'); + } - // Enforce minimum auto-ping interval if required by regional admin - if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith( - autoPingInterval: _apiService.minModeInterval); - debugLog( - '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); - } + await _createUnifiedRxHandler(); - // Configure multi-byte path hash mode on radio - await _configurePathHashMode(); + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[APP] Regional channels configured: $_regionalChannels'); - // Create ping service with wakelock (create new instance per connection) - _pingService = PingService( - gpsService: _gpsService, - connection: _meshCoreConnection!, - apiQueue: _apiQueueService, - wakelockService: WakelockService(), - cooldownTimer: _cooldownTimer, - manualPingCooldownTimer: _manualPingCooldownTimer, - rxWindowTimer: _rxWindowTimer, - discoveryWindowTimer: _discoveryWindowTimer, - deviceId: _deviceId, - txTracker: _txTracker, - audioService: _audioService, + if (_unifiedRxHandler != null) { + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, + ); + } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, disableRssiFilter: _preferences.disableRssiFilter, - hopBytes: effectiveHopBytes, - traceHopBytes: _traceHopBytes, - shouldIgnoreRepeater: (String repeaterId) { - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch( - repeaterId, prefs.ignoreRepeaterId!); - } - return false; - }, ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); + } + + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[CONN] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + debugLog('[CONN] Flood scope set successfully'); + } else { + _scope = null; + debugLog('[CONN] No regional scope — using unscoped flood'); + } - // Wire UnifiedRxHandler so trace payloads route to TraceTracker - _pingService!.unifiedRxHandler = _unifiedRxHandler; + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } - // Set validation callbacks - _pingService!.checkExternalAntennaConfigured = () { - // External antenna must be explicitly set (yes or no) before pinging - return _preferences.externalAntennaSet; - }; + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } - _pingService!.checkPowerLevelConfigured = () { - // Power is configured if: - // - Auto-detected from device model, OR - // - Manually selected by user, OR - // - Device model is known (has default power) - return _preferences.autoPowerSet || - _preferences.powerLevelSet || - _deviceModel != null; - }; + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[CONN] Flood traffic auto-enabled (region permits)' + : '[CONN] Flood traffic disabled by regional admin'); + } + if (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } + + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + } + + await _configurePathHashMode(); + + _pingService = PingService( + gpsService: _gpsService, + connection: _meshCoreConnection!, + apiQueue: _apiQueueService, + wakelockService: WakelockService(), + cooldownTimer: _cooldownTimer, + manualPingCooldownTimer: _manualPingCooldownTimer, + rxWindowTimer: _rxWindowTimer, + discoveryWindowTimer: _discoveryWindowTimer, + deviceId: _deviceId, + txTracker: _txTracker, + audioService: _audioService, + disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, + shouldIgnoreRepeater: (String repeaterId) { + final prefs = _preferences; + if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); + } + return false; + }, + ); - // Get external antenna value for API payloads - _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + _pingService!.unifiedRxHandler = _unifiedRxHandler; - // Get power level from preferences (includes per-device overrides and manual selection) - _pingService!.getPowerLevel = () => _preferences.powerLevel; + _pingService!.checkExternalAntennaConfigured = () { + return _preferences.externalAntennaSet; + }; - // Check if TX is allowed by API (zone capacity) - _pingService!.checkTxAllowed = () => txAllowed; + _pingService!.checkPowerLevelConfigured = () { + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; + }; - // Check if discovery drop is enabled - _pingService!.getDiscDropEnabled = () => discDropEnabled; + _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + _pingService!.getPowerLevel = () => _preferences.powerLevel; + _pingService!.checkTxAllowed = () => txAllowed; + _pingService!.getDiscDropEnabled = () => discDropEnabled; - _pingService!.onTxPing = (ping) { - _txPings.add(ping); - if (_txPings.length > _maxMapPins) _txPings.removeAt(0); + _pingService!.onTxPing = (ping) { + _txPings.add(ping); + if (_txPings.length > _maxMapPins) _txPings.removeAt(0); - // Add TX log entry (power in watts from preferences) - _txLogEntries.add(TxLogEntry( - timestamp: ping.timestamp, - latitude: ping.latitude, - longitude: ping.longitude, - power: _preferences.powerLevel, // Watts (0.3, 0.6, 1.0, 2.0) - events: [], // Will be updated when RX responses come in - )); - if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); + _txLogEntries.add(TxLogEntry( + timestamp: ping.timestamp, + latitude: ping.latitude, + longitude: ping.longitude, + power: _preferences.powerLevel, + events: [], + )); + if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); - notifyListeners(); - }; + notifyListeners(); + }; - _pingService!.onRxPing = (ping) { - _rxPings.add(ping); - if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - - // Add RX log entry - _rxLogEntries.add(RxLogEntry( - timestamp: ping.timestamp, - repeaterId: ping.repeaterId, - snr: ping.snr, - rssi: ping.rssi, - pathLength: 0, // TODO: Extract from packet metadata - header: 0, // TODO: Extract from packet metadata - latitude: ping.latitude, - longitude: ping.longitude, - )); - if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); - - // Update RX overlay slot with this RX observation - _updateRxOverlaySlot(ping.repeaterId, ping.snr); + _pingService!.onRxPing = (ping) { + _rxPings.add(ping); + if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); + + _rxLogEntries.add(RxLogEntry( + timestamp: ping.timestamp, + repeaterId: ping.repeaterId, + snr: ping.snr, + rssi: ping.rssi, + pathLength: 0, + header: 0, + latitude: ping.latitude, + longitude: ping.longitude, + )); + if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); - notifyListeners(); - }; + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + notifyListeners(); + }; + + _pingService!.onStatsUpdated = (stats) { + _pingStats = stats.copyWith( + rxCount: _pingStats.rxCount, + successfulUploads: _pingStats.successfulUploads, + ); + notifyListeners(); - _pingService!.onStatsUpdated = (stats) { - // Preserve rxCount and successfulUploads while updating TX-related stats from PingService - // PingService sends stats with rxCount=0 and successfulUploads=0 (it doesn't track these), - // so we must preserve the values that other handlers increment - _pingStats = stats.copyWith( + if (_autoPingEnabled) { + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; + BackgroundServiceManager.updateNotification( + mode: modeName, + txCount: _pingStats.txCount, rxCount: _pingStats.rxCount, - successfulUploads: _pingStats.successfulUploads, + queueSize: _queueSize, ); - notifyListeners(); + } + }; - // Update background service notification with current stats - if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive - ? 'Passive Mode' - : _autoMode == AutoMode.hybrid - ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted - ? 'Trace Mode' - : 'Active Mode'; - BackgroundServiceManager.updateNotification( - mode: modeName, - txCount: _pingStats.txCount, - rxCount: _pingStats.rxCount, - queueSize: _queueSize, + _pingService!.onEchoReceived = (txPing, repeater, isNew) { + debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); + + if (_txLogEntries.isNotEmpty) { + final lastEntry = _txLogEntries.last; + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + if (timeDiff <= 10) { + final existingEvents = List.from(lastEntry.events); + final newEvent = RxEvent( + repeaterId: repeater.repeaterId, + snr: repeater.snr, + rssi: repeater.rssi, ); - } - }; - - // Handle real-time echo updates - update TxLogEntry as echoes are received - _pingService!.onEchoReceived = (txPing, repeater, isNew) { - debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog( - '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); - debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); - - // Find the matching TxLogEntry and update its events - if (_txLogEntries.isNotEmpty) { - final lastEntry = _txLogEntries.last; - // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = - lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); - if (timeDiff <= 10) { - // Build updated events list - final existingEvents = List.from(lastEntry.events); - final newEvent = RxEvent( - repeaterId: repeater.repeaterId, - snr: repeater.snr, - rssi: repeater.rssi, - ); - - if (isNew) { - // Add new event - existingEvents.add(newEvent); - // Play receive sound for new repeater echo - _audioService.playReceiveSound(); - } else { - // Update existing event's SNR - final idx = existingEvents - .indexWhere((e) => e.repeaterId == repeater.repeaterId); - if (idx >= 0) { - existingEvents[idx] = newEvent; - } - } - // Replace the entry with updated events - final updatedEntry = TxLogEntry( - timestamp: lastEntry.timestamp, - latitude: lastEntry.latitude, - longitude: lastEntry.longitude, - power: lastEntry.power, - events: existingEvents, - ); - _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog( - '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); - - // Update top repeaters overlay with current TX echoes - _updateTopRepeaters( - existingEvents - .where((e) => e.snr != null) - .map((e) => - (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), - OverlayPingType.tx); - - debugLog('[APP] Calling notifyListeners() to update UI'); - notifyListeners(); - debugLog('[APP] notifyListeners() completed'); + if (isNew) { + existingEvents.add(newEvent); + _audioService.playReceiveSound(); } else { - debugLog( - '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); - } - } else { - debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); - } - }; - - // Wire up ping progress callback for immediate UI refresh (e.g. "Sending..." on disc) - _pingService!.onPingProgressChanged = notifyListeners; - - // Wire up auto ping scheduled callback for countdown display - _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { - _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - - // Track idle time for auto-stop - if (skipReason != null) { - // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && - _idleAutoStopReference != null) { - final elapsed = DateTime.now().difference(_idleAutoStopReference!); - if (elapsed >= _autoStopIdleTimeout) { - _triggerIdleAutoStop(); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); + if (idx >= 0) { + existingEvents[idx] = newEvent; } } - } else { - // Successful ping — reset idle reference - _idleAutoStopReference = DateTime.now(); - } - }; - // Wire up discovery ping callback - fires immediately (like onTxPing) - _pingService!.onDiscPing = (entry) { - _addDiscLogEntry(entry); - }; + final updatedEntry = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: existingEvents, + ); + _txLogEntries[_txLogEntries.length - 1] = updatedEntry; + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); - // Wire up real-time disc node discovery callback (like onEchoReceived) - _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog( - '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); - if (isNew) { - _audioService.playReceiveSound(); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); + + debugLog('[APP] Calling notifyListeners() to update UI'); + notifyListeners(); + debugLog('[APP] notifyListeners() completed'); + } else { + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } + } else { + debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); + } + }; - // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters( - discPing.discoveredNodes - .map((n) => - (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), - OverlayPingType.disc); + _pingService!.onPingProgressChanged = notifyListeners; - notifyListeners(); - }; + _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { + _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - // Wire up TX window complete callback for noise floor graph - _pingService!.onTxWindowComplete = (success) { - // Get location and repeater info from the last TX log entry - double? lat; - double? lon; - List? repeaters; - - if (_txLogEntries.isNotEmpty) { - final lastTx = _txLogEntries.last; - lat = lastTx.latitude; - lon = lastTx.longitude; - if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events - .map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )) - .toList(); + if (skipReason != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { + final elapsed = + DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); } } + } else { + _idleAutoStopReference = DateTime.now(); + } + }; - recordPingEvent( - success ? PingEventType.txSuccess : PingEventType.txFail, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; - - // Wire up discovery window complete callback for noise floor graph - _pingService!.onDiscoveryWindowComplete = (success) { - // Get location and node info from the most recent discovery log entry - // Note: _discLogEntries uses insert(0,...) so .first is newest - double? lat; - double? lon; - List? repeaters; - - if (_discLogEntries.isNotEmpty) { - final lastDisc = _discLogEntries.first; - lat = lastDisc.latitude; - lon = lastDisc.longitude; - if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes - .map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )) - .toList(); - } - } + _pingService!.onDiscPing = (entry) { + _addDiscLogEntry(entry); + }; - PingEventType eventType; - if (success) { - eventType = PingEventType.discSuccess; - } else if (discDropEnabled) { - eventType = PingEventType.txFail; - } else { - eventType = PingEventType.discFail; - } + _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + if (isNew) { + _audioService.playReceiveSound(); + } - recordPingEvent( - eventType, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); - // Wire up trace ping callback (for log entry creation) - _pingService!.onTracePing = (entry) { - _addTraceLogEntry(entry); - }; + notifyListeners(); + }; - // Wire up trace window complete callback for noise floor graph - _pingService!.onTraceWindowComplete = (result) { - double? lat; - double? lon; - List? repeaters; - - if (_traceLogEntries.isNotEmpty) { - final lastTrace = _traceLogEntries.first; - lat = lastTrace.latitude; - lon = lastTrace.longitude; - if (result != null && result.success) { - repeaters = [ - MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - ) - ]; - // Update the log entry with success data - _traceLogEntries[0] = TraceLogEntry( - timestamp: lastTrace.timestamp, - latitude: lastTrace.latitude, - longitude: lastTrace.longitude, - targetRepeaterId: lastTrace.targetRepeaterId, - noiseFloor: lastTrace.noiseFloor, - localSnr: result.localSnr, - remoteSnr: result.remoteSnr, - localRssi: result.localRssi, - success: true, - ); - notifyListeners(); - } + _pingService!.onTxWindowComplete = (success) { + double? lat; + double? lon; + List? repeaters; + + if (_txLogEntries.isNotEmpty) { + final lastTx = _txLogEntries.last; + lat = lastTx.latitude; + lon = lastTx.longitude; + if (lastTx.events.isNotEmpty) { + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } + } - recordPingEvent( - result != null && result.success - ? PingEventType.traceSuccess - : PingEventType.traceFail, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; - - // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) - _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog( - '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); - logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', - severity: ErrorSeverity.warning, autoSwitch: false); - }; + recordPingEvent( + success ? PingEventType.txSuccess : PingEventType.txFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Wire up pending disable complete callback - // Called when user disables Active Mode during sending/listening and the RX window ends - _pingService!.onPendingDisableComplete = () async { - debugLog('[APP] Pending disable completed, cleaning up'); + _pingService!.onDiscoveryWindowComplete = (success) { + double? lat; + double? lon; + List? repeaters; + + if (_discLogEntries.isNotEmpty) { + final lastDisc = _discLogEntries.first; + lat = lastDisc.latitude; + lon = lastDisc.longitude; + if (lastDisc.discoveredNodes.isNotEmpty) { + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); + } + } - // Stop TX echo tracking - _pingService!.stopEchoTracking(); - // Stop RX wardriving (flushes batches) - _rxLogger?.stopWardriving(trigger: 'pending_disable'); + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } - // Stop background service - await BackgroundServiceManager.stopService(); + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Stop countdown timers - _autoPingTimer.stop(); - _rxWindowTimer.stop(); + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + _pingService!.onTraceWindowComplete = (result) { + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + notifyListeners(); } + } - // End noise floor session - await _endNoiseFloorSession(); + recordPingEvent( + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Disable heartbeat - _apiService.disableHeartbeat(); + _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + logError( + 'Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', + severity: ErrorSeverity.warning, autoSwitch: false); + }; - // Update local state - _autoPingEnabled = false; - _idleAutoStopReference = null; + _pingService!.onPendingDisableComplete = () async { + debugLog('[APP] Pending disable completed, cleaning up'); - debugLog('[APP] Pending disable cleanup complete, cooldown running'); - notifyListeners(); - }; + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'pending_disable'); - // Save this device for quick reconnection (mobile only) - await _saveRememberedDevice(device); - - // Update display name from SelfInfo (reflects user's chosen name) - // BLE advertisement name may be cached/stale after device rename - final selfInfoName = _meshCoreConnection?.selfInfo?.name; - if (selfInfoName != null && selfInfoName.isNotEmpty) { - // Keep "Anonymous" display name if anonymous mode is active - _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; - debugLog('[APP] Display name set: "$_displayDeviceName"'); - - // Update remembered device with real name (not "Anonymous") - // BLE advertisement name may be stale after device rename - String? realName; - if (_isAnonymousRenamed) { - realName = _originalDeviceName ?? selfInfoName; - } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { - realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; - } else { - realName = selfInfoName; - } - if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { - final updatedName = 'MeshCore-$realName'; - if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice( - DiscoveredDevice(id: device.id, name: updatedName)); - debugLog( - '[APP] Updated remembered device name from SelfInfo: $updatedName'); - } - } - } + await BackgroundServiceManager.stopService(); - // Restore per-device antenna preference if previously saved - // Use original name for keying, not "Anonymous" - final resolvedName = - _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && - _deviceAntennaPreferences.containsKey(resolvedName)) { - final savedAntenna = _deviceAntennaPreferences[resolvedName]!; - _preferences = _preferences.copyWith( - externalAntenna: savedAntenna, - externalAntennaSet: true, - ); - _antennaRestoredFromDevice = true; - _savePreferences(); - debugLog( - '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); - notifyListeners(); - } + _autoPingTimer.stop(); + _rxWindowTimer.stop(); - // Restore per-device power override if previously saved - if (resolvedName != null && - _devicePowerOverrides.containsKey(resolvedName)) { - final saved = _devicePowerOverrides[resolvedName]!; - _preferences = _preferences.copyWith( - powerLevel: (saved['powerLevel'] as num).toDouble(), - txPower: (saved['txPower'] as num).toInt(), - autoPowerSet: false, - powerLevelSet: true, - ); - _powerRestoredFromDevice = true; - _savePreferences(); - debugLog( - '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); - notifyListeners(); + if (_preferences.offlineMode) { + await _saveOfflineSession(); } - // Log connection status based on TX/RX permissions - if (hasApiSession) { - if (txAllowed && rxAllowed) { - debugLog('[CONN] Connected with full access (TX + RX allowed)'); - } else if (rxAllowed) { - debugLog( - '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); - } else { - debugLog('[CONN] Connected with limited access'); - } + await _endNoiseFloorSession(); + _apiService.disableHeartbeat(); - // Track session zone for zone-to-zone transfer detection - _sessionZoneCode = zoneCode; + _autoPingEnabled = false; + _idleAutoStopReference = null; - // Start periodic zone refresh to keep slot counts current - if (!_preferences.offlineMode) { - _startZoneRefreshTimer(); - } + debugLog('[APP] Pending disable cleanup complete, cooldown running'); + notifyListeners(); + }; - // Enable heartbeat immediately on connection to keep server session alive - // Previously only enabled on auto-ping start, causing silent session expiry - if (!_preferences.offlineMode && _apiService.hasSession) { - _apiService.enableHeartbeat( - gpsProvider: () { - final pos = _gpsService.lastPosition; - if (pos == null) return null; - return (lat: pos.latitude, lon: pos.longitude); - }, - ); - debugLog('[HEARTBEAT] Enabled on connection'); + await _saveRememberedDevice(device, + transportType: _selectedTransport, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath); + + final selfInfoName = _meshCoreConnection?.selfInfo?.name; + if (selfInfoName != null && selfInfoName.isNotEmpty) { + _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; + debugLog('[APP] Display name set: "$_displayDeviceName"'); + + String? realName; + if (_isAnonymousRenamed) { + realName = _originalDeviceName ?? selfInfoName; + } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { + realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; + } else { + realName = selfInfoName; + } + if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { + final updatedName = 'MeshCore-$realName'; + if (_rememberedDevice!.name != updatedName) { + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName), + transportType: _selectedTransport, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } + } + } - // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) - _startIdleDisconnectTimer(); + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { + final savedAntenna = _deviceAntennaPreferences[resolvedName]!; + _preferences = _preferences.copyWith( + externalAntenna: savedAntenna, + externalAntennaSet: true, + ); + _antennaRestoredFromDevice = true; + _savePreferences(); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + notifyListeners(); + } + + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } + + if (hasApiSession) { + if (txAllowed && rxAllowed) { + debugLog('[CONN] Connected with full access (TX + RX allowed)'); + } else if (rxAllowed) { + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { - // No API session - offline mode or auth skipped - debugLog('[CONN] Connected without API session (offline mode)'); + debugLog('[CONN] Connected with limited access'); } - // Log ping validation status after connection - final validation = pingValidation; - if (validation != PingValidation.valid) { - debugLog('[CONN] Ping validation after connect: $validation'); - } - } catch (e) { - debugError('[APP] Connection failed: $e'); + _sessionZoneCode = zoneCode; - // Ensure channel is cleaned up if it was created during connection - // Must happen BEFORE BLE disconnect while connection is still alive - try { - await _meshCoreConnection?.deleteWardrivingChannelEarly(); - } catch (channelError) { - debugError('[APP] Cleanup channel delete failed: $channelError'); + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); } - // Ensure BLE is disconnected on any connection failure - // (connection.dart should have done this, but be defensive) - try { - if (_meshCoreConnection != null) { - await _meshCoreConnection!.disconnect(); - } - } catch (disconnectError) { - debugError('[APP] Cleanup disconnect failed: $disconnectError'); - } - - // Parse auth failure errors for clean display - final errorStr = e.toString(); - if (errorStr.contains('AUTH_FAILED:')) { - // Format: "Exception: AUTH_FAILED:reason:message" - _isAuthError = true; - final parts = errorStr.split('AUTH_FAILED:'); - if (parts.length > 1) { - final errorParts = parts[1].split(':'); - final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = - errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; - _isNetworkError = reason == 'network_error'; - _connectionError = _getErrorMessage(reason, serverMessage); - } else { - _connectionError = 'Authentication failed'; - } - } else { - _isAuthError = false; - _isNetworkError = false; - // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || - errorStr.contains('Timeout') || - errorStr.contains('timed out')) { - _connectionError = 'Bluetooth connection scan timed out'; - } else { - _connectionError = errorStr.replaceFirst('Exception: ', ''); - } + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); } - _connectionStep = ConnectionStep.error; - notifyListeners(); + + _startIdleDisconnectTimer(); + } else { + debugLog('[CONN] Connected without API session (offline mode)'); + } + + final validation = pingValidation; + if (validation != PingValidation.valid) { + debugLog('[CONN] Ping validation after connect: $validation'); } } @@ -2589,6 +2907,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _cancelPendingAutoPingRestore(); + _isConnecting = false; _connectionStep = ConnectionStep.disconnected; // Cancel any active zone grace period @@ -2597,9 +2916,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); - debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + debugLog('[CONN] Heartbeat disabled due to disconnect'); // Stop zone refresh timer _stopZoneRefreshTimer(); @@ -2614,7 +2932,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _autoPingEnabled = false; _idleAutoStopReference = null; - debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); + debugLog('[AUTO] Auto-ping disabled due to disconnect'); } // End noise floor session on BLE disconnect @@ -2638,7 +2956,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Release API session (best effort - don't block on failure) if (_devicePublicKey != null && _apiService.hasSession) { - debugLog('[CONN] Releasing API session due to BLE disconnect'); + debugLog('[CONN] Releasing API session due to disconnect'); try { await _apiService.requestAuth( reason: 'disconnect', @@ -2650,21 +2968,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // Reset anonymous mode state (BLE already gone, can't restore name) _isAnonymousRenamed = false; _originalDeviceName = null; - // Clear top-heard overlay _clearOverlayState(); - // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); _pingService = null; + + // Clean up non-BLE transport + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = null; + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; } - /// Start auto-reconnect after unexpected BLE disconnect + /// Start auto-reconnect after unexpected transport disconnect Future _startAutoReconnect() async { // Defensive: cancel zone grace period if active if (_isInZoneGracePeriod) { @@ -2830,8 +3153,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry. + /// Only applies to BLE transports. Future _handleBondErrorIfNeeded(Object error) async { + if (_selectedTransport != TransportType.ble) return; final errorStr = error.toString(); if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || @@ -2942,6 +3267,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Immediate UI feedback + _connectionStep = ConnectionStep.disconnecting; + notifyListeners(); + // Cancel idle disconnect timer _cancelIdleDisconnectTimer(); @@ -3056,7 +3385,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker = null; // TxTracker is disposed by UnifiedRxHandler _rxLogger = null; // RxLogger is disposed by UnifiedRxHandler - // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); // Cancel stream subscriptions @@ -3065,6 +3393,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _batterySubscription?.cancel(); _batterySubscription = null; + // Clean up non-BLE transport (TCP/USB instances are owned by us, not shared) + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = null; + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); @@ -6054,8 +6390,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save device for quick reconnection - Future _saveRememberedDevice(DiscoveredDevice device) async { - // Skip on web - Web Bluetooth requires user interaction for each connection + Future _saveRememberedDevice( + DiscoveredDevice device, { + TransportType transportType = TransportType.ble, + String? tcpHost, + int? tcpPort, + String? serialPortPath, + }) async { if (kIsWeb) return; final box = await _openBoxSafely(_rememberedDeviceBoxName); @@ -6066,34 +6407,51 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: device.id, name: device.name, lastConnected: DateTime.now(), + transportType: transportType, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath, ); await box.put('device', remembered.toJson()); _rememberedDevice = remembered; - debugLog('[APP] Saved remembered device: ${device.name}'); + debugLog( + '[APP] Saved remembered device: ${device.name} (${transportType.name})'); notifyListeners(); } catch (e) { debugLog('[APP] Failed to save remembered device: $e'); } } - /// Reconnect to remembered device without scanning + /// Reconnect to remembered device without scanning. + /// Routes to the correct transport based on the remembered device's type. Future reconnectToRememberedDevice() async { if (_rememberedDevice == null) return; - if (kIsWeb) return; // Not supported on web - - final device = DiscoveredDevice( - id: _rememberedDevice!.id, - name: _rememberedDevice!.name, - ); - - // Pre-populate the BLE scan cache with remembered device info - // This ensures the device name is available during connect() - // (normally populated by scanning, but we're skipping the scan) - _bluetoothService.cacheDeviceInfo(device); + if (kIsWeb) return; - await connectToDevice(device); + switch (_rememberedDevice!.transportType) { + case TransportType.ble: + final device = DiscoveredDevice( + id: _rememberedDevice!.id, + name: _rememberedDevice!.name, + ); + _bluetoothService.cacheDeviceInfo(device); + await connectToDevice(device); + break; + case TransportType.tcp: + final host = _rememberedDevice!.tcpHost; + final port = _rememberedDevice!.tcpPort; + if (host != null && port != null) { + await connectViaTcp(host, port); + } else { + debugError('[APP] Cannot reconnect via TCP: missing host/port'); + } + break; + case TransportType.usbSerial: + debugLog('[APP] USB Serial reconnect requires user to select device'); + break; + } } /// Clear remembered device diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 5e6b9a79..14721329 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,9 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../services/transport/android_serial_service.dart'; +import '../services/transport/tcp_service.dart'; +import '../services/transport/web_serial_factory.dart'; import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; @@ -26,14 +29,23 @@ class ConnectionScreen extends StatefulWidget { class _ConnectionScreenState extends State with WidgetsBindingObserver { + final _tcpHostController = TextEditingController(); + final _tcpPortController = TextEditingController(text: '5000'); + bool _tcpConnecting = false; + Future>>? _usbDevicesFuture; + Future>? _savedTcpFuture; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _savedTcpFuture = TcpService.getSavedConnections(); } @override void dispose() { + _tcpHostController.dispose(); + _tcpPortController.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -77,13 +89,17 @@ class _ConnectionScreenState extends State Widget build(BuildContext context) { final appState = context.watch(); - return Scaffold( - appBar: AppBar( - toolbarHeight: 40, - title: const Text('Connection', style: TextStyle(fontSize: 18)), - automaticallyImplyLeading: false, + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), + automaticallyImplyLeading: false, + ), + body: _buildBody(context, appState), ), - body: _buildBody(context, appState), ); } @@ -94,6 +110,7 @@ class _ConnectionScreenState extends State } if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && + appState.connectionStep != ConnectionStep.disconnecting && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } @@ -120,6 +137,20 @@ class _ConnectionScreenState extends State return _buildZoneGraceView(context, appState); } + // Show disconnecting state + if (appState.connectionStep == ConnectionStep.disconnecting) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Disconnecting...'), + ], + ), + ); + } + // Show connected state if (appState.isConnected) { final pathWarning = appState.pendingPathHashWarning; @@ -143,26 +174,43 @@ class _ConnectionScreenState extends State return _buildDeviceList(context, appState); } - /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + /// Persistent bottom action bar: transport picker + offline toggle + action button Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Expanded(child: OfflineModeToggle()), - const SizedBox(width: 12), - Expanded(child: _buildActionButton(context, appState)), + Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + if (!appState.isConnected) ...[ + const SizedBox(height: 8), + _buildTransportPicker(context, appState), + ], ], ), ); } - /// The right-side action button: Scan, Cancel, or Disconnect + /// The right-side action button: Scan/Connect, Cancel, or Disconnect Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.connectionStep == ConnectionStep.disconnecting) { + return _buildBottomButton( + icon: Icons.hourglass_top, + label: 'Disconnecting', + color: Colors.grey, + onPressed: null, + ); + } + if (appState.isConnected) { - // Disconnect return _buildBottomButton( - icon: Icons.bluetooth_disabled, + icon: Icons.link_off, label: 'Disconnect', color: Colors.red, onPressed: () async => await appState.disconnect(), @@ -170,7 +218,6 @@ class _ConnectionScreenState extends State } if (appState.isScanning) { - // Cancel scan return _buildBottomButton( icon: Icons.close, label: 'Cancel', @@ -179,19 +226,54 @@ class _ConnectionScreenState extends State ); } - // Scan — disabled when can't scan - final canScan = appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff && - (appState.offlineMode || appState.inZone == true); - - return _buildBottomButton( - icon: Icons.bluetooth_searching, - label: 'Scan', - color: Theme.of(context).colorScheme.primary, - onPressed: canScan ? () => appState.startScan() : null, - ); + final canConnect = + appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + (appState.offlineMode || appState.inZone == true); + + switch (appState.selectedTransport) { + case TransportType.ble: + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect && !appState.isBluetoothOff + ? () => appState.startScan() + : null, + ); + case TransportType.tcp: + final hostFilled = _tcpHostController.text.trim().isNotEmpty; + return _buildBottomButton( + icon: Icons.lan, + label: _tcpConnecting ? 'Connecting' : 'Connect', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect && hostFilled && !_tcpConnecting + ? () => _connectTcp(appState) + : null, + ); + case TransportType.usbSerial: + if (kIsWeb) { + return _buildBottomButton( + icon: Icons.usb, + label: 'Connect', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect + ? () => _connectWebSerial(appState) + : null, + ); + } + return _buildBottomButton( + icon: Icons.refresh, + label: 'Refresh', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect + ? () => setState(() { + _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); + }) + : null, + ); + } } /// Styled button matching OfflineModeToggle shape @@ -1569,8 +1651,9 @@ class _ConnectionScreenState extends State ); } - // Show Bluetooth off message (takes priority over zone checks) - if (appState.isBluetoothOff) { + // Show Bluetooth off message (BLE transport only) + if (appState.selectedTransport == TransportType.ble && + appState.isBluetoothOff) { return _buildMessageContent( context: context, icon: Icons.bluetooth_disabled, @@ -1743,6 +1826,106 @@ class _ConnectionScreenState extends State ); } + return _buildTransportContent(context, appState, canConnect); + } + + /// Transport selector buttons styled like the bottom bar. + Widget _buildTransportPicker( + BuildContext context, AppStateProvider appState) { + final available = _availableTransports(); + if (available.length <= 1) return const SizedBox.shrink(); + + final primary = Theme.of(context).colorScheme.primary; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: primary.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: Row( + children: [ + for (final transport in available) + Expanded( + child: _buildTransportSegment( + context, appState, transport, + isSelected: appState.selectedTransport == transport, + ), + ), + ], + ), + ); + } + + Widget _buildTransportSegment( + BuildContext context, AppStateProvider appState, TransportType type, + {required bool isSelected}) { + const meta = { + TransportType.ble: (Icons.bluetooth, 'BLE'), + TransportType.tcp: (Icons.lan, 'TCP'), + TransportType.usbSerial: (Icons.usb, 'USB'), + }; + final (icon, label) = meta[type]!; + final primary = Theme.of(context).colorScheme.primary; + final color = isSelected ? primary : Colors.grey; + + return Material( + color: isSelected ? primary.withValues(alpha: 0.2) : Colors.transparent, + child: InkWell( + onTap: () => appState.setSelectedTransport(type), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } + + /// Available transports based on platform. + /// BLE: all platforms. TCP: mobile only. USB: Android and Web. + List _availableTransports() { + final transports = [TransportType.ble]; + if (!kIsWeb) { + transports.add(TransportType.tcp); + if (Platform.isAndroid) { + transports.add(TransportType.usbSerial); + } + } else { + transports.add(TransportType.usbSerial); + } + return transports; + } + + /// Routes to the correct transport-specific content. + Widget _buildTransportContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + switch (appState.selectedTransport) { + case TransportType.ble: + return _buildBleContent(context, appState, canConnect); + case TransportType.tcp: + return _buildTcpContent(context, appState, canConnect); + case TransportType.usbSerial: + return _buildUsbContent(context, appState, canConnect); + } + } + + /// BLE transport content — scanning, device list, remembered device. + Widget _buildBleContent( + BuildContext context, AppStateProvider appState, bool canConnect) { if (appState.isScanning) { return Column( children: [ @@ -1755,18 +1938,16 @@ class _ConnectionScreenState extends State } if (appState.discoveredDevices.isEmpty) { - // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; - if (!kIsWeb && remembered != null) { + if (!kIsWeb && + remembered != null && + remembered.transportType == TransportType.ble) { return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); } - // Show GPS disabled message when location services are off if (appState.gpsStatus == GpsStatus.disabled) { - // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return _buildMessageContent( context: context, icon: Icons.gps_off, @@ -1784,7 +1965,6 @@ class _ConnectionScreenState extends State ); } - // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { return _buildMessageContent( context: context, @@ -1813,6 +1993,259 @@ class _ConnectionScreenState extends State return _buildDeviceListView(context, appState, canConnect: canConnect); } + /// TCP transport content — host/port entry + saved connections. + Widget _buildTcpContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Host/port input + TextField( + controller: _tcpHostController, + decoration: const InputDecoration( + labelText: 'Host', + hintText: 'IP address or hostname', + prefixIcon: Icon(Icons.dns_outlined), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + enabled: !_tcpConnecting, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _tcpPortController, + decoration: const InputDecoration( + labelText: 'Port', + hintText: '5000', + prefixIcon: Icon(Icons.numbers), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + enabled: !_tcpConnecting, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + // Saved connections + FutureBuilder>( + future: _savedTcpFuture ??= TcpService.getSavedConnections(), + builder: (context, snapshot) { + final saved = snapshot.data; + if (saved == null || saved.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saved Connections', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + ...saved.map((conn) => _buildSavedTcpTile( + context, appState, conn, canConnect)), + ], + ); + }, + ), + ], + ), + ); + } + + void _connectTcp(AppStateProvider appState) async { + final host = _tcpHostController.text.trim(); + final portStr = _tcpPortController.text.trim(); + if (host.isEmpty) return; + final port = int.tryParse(portStr) ?? 5000; + + setState(() => _tcpConnecting = true); + try { + await appState.connectViaTcp(host, port); + } finally { + if (mounted) { + setState(() { + _tcpConnecting = false; + _savedTcpFuture = TcpService.getSavedConnections(); + }); + } + } + } + + Widget _buildSavedTcpTile(BuildContext context, AppStateProvider appState, + SavedTcpConnection conn, bool canConnect) { + final showName = !conn.name.startsWith('TCP '); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName ? conn.name : conn.host, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${conn.host}:${conn.port}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(8), + onPressed: () async { + await TcpService.deleteConnection(conn.id); + if (mounted) { + final future = TcpService.getSavedConnections(); + setState(() { _savedTcpFuture = future; }); + } + }, + ), + const SizedBox(width: 4), + FilledButton( + onPressed: canConnect && !_tcpConnecting + ? () { + _tcpHostController.text = conn.host; + _tcpPortController.text = conn.port.toString(); + _connectTcp(appState); + } + : null, + child: const Text('Reconnect'), + ), + ], + ), + ), + ); + } + + /// USB Serial transport content. + Widget _buildUsbContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + if (kIsWeb) { + return _buildWebSerialContent(context, appState, canConnect); + } + return _buildAndroidSerialContent(context, appState, canConnect); + } + + Widget _buildAndroidSerialContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return FutureBuilder>>( + future: _usbDevicesFuture ??= AndroidSerialService.getAvailablePorts(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final devices = snapshot.data ?? []; + if (devices.isEmpty) { + return _buildMessageContent( + context: context, + icon: Icons.usb_off, + iconColor: Colors.grey.withValues(alpha: 0.5), + title: 'No USB Devices', + message: + 'Connect a MeshCore device via USB OTG and tap Refresh.', + action: FilledButton.icon( + onPressed: () => setState(() { + _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); + }), + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ); + } + + return ListView.builder( + itemCount: devices.length, + itemBuilder: (context, index) { + final device = devices[index]; + final vid = device['vid'] as int? ?? 0; + final pid = device['pid'] as int? ?? 0; + return ListTile( + leading: const Icon(Icons.usb), + title: Text( + device['productName'] as String? ?? 'USB Device'), + subtitle: Text( + 'VID: ${vid.toRadixString(16)} PID: ${pid.toRadixString(16)}'), + enabled: canConnect, + onTap: canConnect + ? () => appState.connectViaUsb(device) + : null, + ); + }, + ); + }, + ); + } + + Widget _buildWebSerialContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.usb, + size: 64, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'USB Serial', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Connect a MeshCore device via USB and tap Connect below. ' + 'Your browser will show a port picker.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ); + } + + void _connectWebSerial(AppStateProvider appState) async { + try { + final transport = await openWebSerialTransport(); + await appState.connectWithTransport( + transport, + deviceId: 'webserial', + deviceName: 'USB Serial (Web)', + serialPortPath: 'webserial', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('USB Serial error: $e')), + ); + } + } + } + void _launchOnboardingUrl() async { final uri = Uri.parse('https://meshmapper.net/?onboarding'); try { diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index 47021780..47bc82fd 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import '../../models/connection_state.dart'; +import '../transport/companion_transport.dart'; /// Exception thrown when BLE permissions are permanently denied /// User must enable permissions in device Settings @@ -41,20 +42,24 @@ enum BluetoothAdapterState { /// Abstract Bluetooth service interface /// Platform implementations provided by MobileBluetoothService and WebBluetoothService -abstract class BluetoothService { +abstract class BluetoothService implements CompanionTransport { /// Stream of connection status changes + @override Stream get connectionStream; /// Stream of received data from device + @override Stream get dataStream; /// Stream of Bluetooth adapter state changes (on/off) Stream get adapterStateStream; /// Current connection status + @override ConnectionStatus get connectionStatus; /// Currently connected device (null if not connected) + @override DiscoveredDevice? get connectedDevice; /// Check if Bluetooth is available on this platform @@ -77,9 +82,11 @@ abstract class BluetoothService { Future connect(String deviceId); /// Disconnect from current device + @override Future disconnect(); /// Write data to device + @override Future write(Uint8List data); /// Pre-populate device cache with known device info @@ -93,5 +100,6 @@ abstract class BluetoothService { Future removeBond(String deviceId); /// Dispose of resources + @override void dispose(); } diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 1ed79776..48219812 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -5,7 +5,7 @@ import 'dart:typed_data'; import '../../models/connection_state.dart'; import '../../models/device_model.dart'; import '../../utils/debug_logger_io.dart'; -import '../bluetooth/bluetooth_service.dart'; +import '../transport/companion_transport.dart'; import 'buffer_utils.dart'; import 'channel_service.dart'; import 'crypto_service.dart'; @@ -69,7 +69,7 @@ class SelfInfo { /// 8. GPS Init /// 9. Connected State class MeshCoreConnection { - final BluetoothService _bluetooth; + final CompanionTransport _transport; bool _disposed = false; final _stepController = StreamController.broadcast(); final _channelMessageController = @@ -116,9 +116,9 @@ class MeshCoreConnection { int? _lastBatteryMilliVolts; // millivolts or null if not supported Timer? _batteryTimer; - MeshCoreConnection({required BluetoothService bluetooth}) - : _bluetooth = bluetooth { - _dataSubscription = _bluetooth.dataStream.listen(_onFrameReceived); + MeshCoreConnection({required CompanionTransport transport}) + : _transport = transport { + _dataSubscription = _transport.dataStream.listen(_onFrameReceived); } /// Stream of connection step changes @@ -205,16 +205,15 @@ class MeshCoreConnection { /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect( - String deviceId, List deviceModels) async { + List deviceModels) async { if (_disposed) { throw Exception('Connection instance has been disposed'); } bool deviceModelMatched = false; try { - // Step 1: BLE Connect - _updateStep(ConnectionStep.bleConnecting); - await _bluetooth.connect(deviceId); + // Step 1: Transport connect (already connected by caller) + _updateStep(ConnectionStep.transportConnecting); // Step 2: Protocol Handshake (handled automatically by device) _updateStep(ConnectionStep.protocolHandshake); @@ -281,6 +280,13 @@ class MeshCoreConnection { '[CONN] No auth callback set, skipping API session acquisition'); } + // Guard: transport may have disconnected during the async auth API call + if (_disposed || + _transport.connectionStatus != ConnectionStatus.connected) { + throw Exception( + 'Transport disconnected during authentication. Please try connecting again.'); + } + // Step 7: Channel Setup _updateStep(ConnectionStep.channelSetup); debugLog('[CONN] Creating #wardriving channel'); @@ -315,7 +321,7 @@ class MeshCoreConnection { _updateStep(ConnectionStep.error); // Clean up BLE connection on failure try { - await _bluetooth.disconnect(); + await _transport.disconnect(); debugLog('[CONN] Disconnected BLE after connection failure'); } catch (disconnectError) { debugError('[CONN] Failed to disconnect after error: $disconnectError'); @@ -349,7 +355,7 @@ class MeshCoreConnection { // See deleteWardrivingChannelEarly() called from app_state_provider // Disconnect BLE - await _bluetooth.disconnect(); + await _transport.disconnect(); _deviceInfo = null; _deviceModel = null; _selfInfo = null; @@ -775,7 +781,7 @@ class MeshCoreConnection { /// Write frame to device Future _sendToRadio(BufferWriter data) async { - await _bluetooth.write(data.toBytes()); + await _transport.write(data.toBytes()); } // ============================================ @@ -824,6 +830,10 @@ class MeshCoreConnection { data.writeByte(appTargetVer); await _sendToRadio(data); + // Send APP_START so device enters companion mode. + // Without this, some devices won't respond to the device query. + await sendCommandAppStart(); + return future.timeout( const Duration(seconds: 5), onTimeout: () => throw TimeoutException('Device query timed out'), @@ -891,7 +901,7 @@ class MeshCoreConnection { final bytes = data.toBytes(); debugLog( '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); - await _bluetooth.write(bytes); + await _transport.write(bytes); return future.timeout( const Duration(seconds: 5), diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 1e2de5ed..ba4bb0f9 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -5,7 +5,7 @@ class ProtocolConstants { ProtocolConstants._(); /// Supported companion protocol version - static const int supportedCompanionProtocolVersion = 1; + static const int supportedCompanionProtocolVersion = 4; /// Serial frame types static const int serialFrameTypeIncoming = 0x3e; // ">" diff --git a/lib/services/transport/android_serial_service.dart b/lib/services/transport/android_serial_service.dart new file mode 100644 index 00000000..f3a37a05 --- /dev/null +++ b/lib/services/transport/android_serial_service.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +/// USB Serial transport for Android via USB OTG. +/// +/// Uses a native Kotlin implementation (MeshMapperUsbService) that directly +/// accesses Android's USB API with proper CDC control transfers. +class AndroidSerialService extends StreamTransportBase { + final String deviceName; + final String productName; + + static const _methodChannel = + MethodChannel('net.meshmapper.app/usb_serial'); + static const _eventChannel = + EventChannel('net.meshmapper.app/usb_serial/data'); + + StreamSubscription? _dataSubscription; + + AndroidSerialService({ + required this.deviceName, + required this.productName, + }); + + @override + Future openConnection() async { + debugLog('[CONN] USB Serial connecting to $productName'); + setConnecting(); + + try { + _dataSubscription = _eventChannel.receiveBroadcastStream().listen( + (data) { + if (data is Uint8List) { + onRawBytesReceived(data); + } else if (data is List) { + onRawBytesReceived(Uint8List.fromList(data.cast())); + } + }, + onError: (error) { + debugError('[CONN] USB Serial stream error: $error'); + setDisconnected(); + }, + onDone: () { + debugLog('[CONN] USB Serial stream closed'); + setDisconnected(); + }, + ); + + final connectResult = await _methodChannel.invokeMethod('connect', { + 'deviceName': deviceName, + 'baudRate': 115200, + }); + if (connectResult != null) { + debugLog('[CONN] USB device info: controlFound=${connectResult['controlFound']}, ' + 'interfaces=${connectResult['interfaceCount']}, ' + 'dataClass=0x${(connectResult['dataClass'] as int?)?.toRadixString(16) ?? '?'}, ' + 'inPacket=${connectResult['inMaxPacket']}, outPacket=${connectResult['outMaxPacket']}'); + } + + setConnected(DiscoveredDevice( + id: deviceName, + name: productName, + )); + + debugLog('[CONN] USB Serial connected: $productName'); + } catch (e) { + _dataSubscription?.cancel(); + _dataSubscription = null; + debugError('[CONN] USB Serial connection failed: $e'); + setError(); + rethrow; + } + } + + @override + Future writeRawBytes(Uint8List data) async { + await _methodChannel.invokeMethod('write', {'data': data}); + } + + @override + Future closeConnection() async { + try { + final diag = + await _methodChannel.invokeMethod('readDiagnostics'); + if (diag != null) { + debugLog('[CONN] USB read loop: ' + 'reads=${diag['readAttempts']}, ' + 'bytesRx=${diag['bytesReceived']}, ' + 'events=${diag['eventsPosted']}, ' + 'sinkNull=${diag['sinkNullCount']}, ' + 'reading=${diag['isReading']}, ' + 'threadAlive=${diag['threadAlive']}, ' + 'sinkSet=${diag['sinkSet']}'); + } + } catch (e) { + debugWarn('[CONN] Read diagnostics unavailable: $e'); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + try { + await _methodChannel.invokeMethod('disconnect'); + } catch (e) { + debugError('[CONN] USB Serial disconnect error: $e'); + } + debugLog('[CONN] USB Serial connection closed'); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _dataSubscription = null; + try { + _methodChannel.invokeMethod('disconnect'); + } catch (_) {} + super.dispose(); + } + + /// List available USB serial devices via native Android USB API. + static Future>> getAvailablePorts() async { + final result = + await _methodChannel.invokeMethod('listDevices'); + if (result == null) return []; + return result + .map((e) => Map.from(e as Map)) + .toList(); + } +} diff --git a/lib/services/transport/companion_transport.dart b/lib/services/transport/companion_transport.dart new file mode 100644 index 00000000..e3647ba5 --- /dev/null +++ b/lib/services/transport/companion_transport.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../models/connection_state.dart'; +import '../bluetooth/bluetooth_service.dart'; + +/// Transport-agnostic interface for MeshCore companion connections. +/// +/// Implemented by BLE (BluetoothService), TCP (TcpService), +/// and USB Serial (AndroidSerialService, WebSerialService). +/// MeshCoreConnection depends only on this interface. +abstract class CompanionTransport { + Stream get dataStream; + + Stream get connectionStream; + + ConnectionStatus get connectionStatus; + + DiscoveredDevice? get connectedDevice; + + Future write(Uint8List data); + + Future disconnect(); + + void dispose(); +} diff --git a/lib/services/transport/stream_frame_codec.dart b/lib/services/transport/stream_frame_codec.dart new file mode 100644 index 00000000..8532d513 --- /dev/null +++ b/lib/services/transport/stream_frame_codec.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Framing codec for TCP and USB Serial MeshCore companion connections. +/// +/// TCP and Serial use identical byte-stream framing: +/// - Outgoing (app→device): [0x3C][len_lo][len_hi][payload] +/// - Incoming (device→app): [0x3E][len_lo][len_hi][payload] +/// +/// BLE does NOT use this codec — GATT provides message boundaries natively. +class StreamFrameCodec { + static const int outgoingMarker = 0x3C; // '<' + static const int incomingMarker = 0x3E; // '>' + static const int headerSize = 3; + static const int maxTxPayload = 172; // firmware MAX_FRAME_SIZE + static const int maxRxPayload = 300; // defensive limit (matches meshcore_py) + + final _frameController = StreamController.broadcast(); + final _buffer = BytesBuilder(copy: true); + + Stream get frames => _frameController.stream; + + /// Encode an outgoing payload with the frame header. + static Uint8List encode(Uint8List payload) { + assert(payload.length <= maxTxPayload, + 'Payload exceeds max TX size: ${payload.length} > $maxTxPayload'); + final frame = Uint8List(payload.length + headerSize); + frame[0] = outgoingMarker; + frame[1] = payload.length & 0xFF; + frame[2] = (payload.length >> 8) & 0xFF; + frame.setRange(headerSize, frame.length, payload); + return frame; + } + + /// Feed incoming raw bytes for frame reassembly. + /// + /// Handles: junk byte discard, partial headers across chunks, + /// multiple frames in one chunk, zero-length and oversized frame rejection. + void addBytes(Uint8List data) { + if (data.isEmpty) return; + _buffer.add(data); + _processBuffer(); + } + + void _processBuffer() { + while (true) { + final bytes = _buffer.toBytes(); + if (bytes.length < headerSize) return; + + // Scan for incoming marker, discarding junk bytes before it + int markerIndex = -1; + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == incomingMarker) { + markerIndex = i; + break; + } + } + + if (markerIndex == -1) { + // No marker found — discard all bytes + _buffer.clear(); + return; + } + + if (markerIndex > 0) { + // Discard junk bytes before marker + _replaceBuffer(bytes.sublist(markerIndex)); + continue; + } + + // Marker is at position 0 — check if we have the full header + if (bytes.length < headerSize) return; + + final payloadLength = bytes[1] | (bytes[2] << 8); + + // Zero-length frame — skip the marker byte and rescan + if (payloadLength == 0) { + _replaceBuffer(bytes.sublist(1)); + continue; + } + + // Oversized frame — treat marker as junk, skip it and rescan + if (payloadLength > maxRxPayload) { + debugWarn( + '[CODEC] Oversized frame ($payloadLength bytes), skipping marker'); + _replaceBuffer(bytes.sublist(1)); + continue; + } + + final totalFrameSize = headerSize + payloadLength; + + // Not enough data yet for the full frame — wait for more + if (bytes.length < totalFrameSize) return; + + // Extract the payload (strip header) + final payload = Uint8List.fromList( + bytes.sublist(headerSize, totalFrameSize)); + + // Remove consumed bytes, keep remainder + if (bytes.length > totalFrameSize) { + _replaceBuffer(bytes.sublist(totalFrameSize)); + } else { + _buffer.clear(); + } + + // Emit the complete frame + if (!_frameController.isClosed) { + _frameController.add(payload); + } + } + } + + void _replaceBuffer(List remaining) { + _buffer.clear(); + _buffer.add(remaining); + } + + void reset() { + _buffer.clear(); + } + + void dispose() { + _buffer.clear(); + _frameController.close(); + } +} diff --git a/lib/services/transport/stream_transport_base.dart b/lib/services/transport/stream_transport_base.dart new file mode 100644 index 00000000..88cbaacb --- /dev/null +++ b/lib/services/transport/stream_transport_base.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../models/connection_state.dart'; +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'companion_transport.dart'; +import 'stream_frame_codec.dart'; + +/// Shared base class for TCP and USB Serial transports. +/// +/// Handles frame encoding/decoding via [StreamFrameCodec] and manages +/// connection state, data streams, and lifecycle. Subclasses implement +/// the raw byte I/O for their specific transport. +abstract class StreamTransportBase implements CompanionTransport { + final StreamFrameCodec _codec = StreamFrameCodec(); + final _connectionController = StreamController.broadcast(); + final _dataController = StreamController.broadcast(); + ConnectionStatus _status = ConnectionStatus.disconnected; + DiscoveredDevice? _device; + StreamSubscription? _codecSubscription; + + StreamTransportBase() { + _codecSubscription = _codec.frames.listen((payload) { + if (!_dataController.isClosed) { + _dataController.add(payload); + } + }); + } + + @override + Stream get dataStream => _dataController.stream; + + @override + Stream get connectionStream => + _connectionController.stream; + + @override + ConnectionStatus get connectionStatus => _status; + + @override + DiscoveredDevice? get connectedDevice => _device; + + @override + Future write(Uint8List data) async { + if (_status != ConnectionStatus.connected) { + throw StateError('Cannot write: transport not connected'); + } + final framed = StreamFrameCodec.encode(data); + await writeRawBytes(framed); + } + + @override + Future disconnect() async { + if (_status == ConnectionStatus.disconnected) return; + try { + await closeConnection(); + } catch (e) { + debugError('[CONN] Error closing connection: $e'); + } + setDisconnected(); + } + + @override + void dispose() { + _codecSubscription?.cancel(); + _codec.dispose(); + _connectionController.close(); + _dataController.close(); + } + + /// Called when raw bytes arrive from the underlying transport. + /// Feeds them into the frame codec for reassembly. + void onRawBytesReceived(Uint8List data) { + _codec.addBytes(data); + } + + /// Update state to connecting and emit. + void setConnecting() { + _status = ConnectionStatus.connecting; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.connecting); + } + } + + /// Update state to error and emit. + void setError() { + _status = ConnectionStatus.error; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.error); + } + } + + /// Update state to connected and emit. + void setConnected(DiscoveredDevice device) { + _device = device; + _status = ConnectionStatus.connected; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.connected); + } + } + + /// Update state to disconnected and emit. + void setDisconnected() { + _device = null; + _codec.reset(); + _status = ConnectionStatus.disconnected; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.disconnected); + } + } + + /// Open the underlying byte stream (socket, serial port, etc). + Future openConnection(); + + /// Write raw (already framed) bytes to the underlying stream. + Future writeRawBytes(Uint8List data); + + /// Close the underlying byte stream. + Future closeConnection(); +} diff --git a/lib/services/transport/tcp_service.dart b/lib/services/transport/tcp_service.dart new file mode 100644 index 00000000..d1a54284 --- /dev/null +++ b/lib/services/transport/tcp_service.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +/// Saved TCP connection for quick reconnect. +class SavedTcpConnection { + final String id; + final String host; + final int port; + final String name; + final DateTime lastConnected; + + const SavedTcpConnection({ + required this.id, + required this.host, + required this.port, + required this.name, + required this.lastConnected, + }); + + Map toJson() => { + 'id': id, + 'host': host, + 'port': port, + 'name': name, + 'lastConnected': lastConnected.toIso8601String(), + }; + + factory SavedTcpConnection.fromJson(Map json) { + return SavedTcpConnection( + id: json['id'] as String, + host: json['host'] as String, + port: json['port'] as int, + name: json['name'] as String, + lastConnected: DateTime.parse(json['lastConnected'] as String), + ); + } +} + +/// TCP transport for MeshCore companion connections. +/// +/// Connects to a MeshCore device via raw TCP socket (default port 5000). +/// Available on Android, iOS, and Desktop (NOT web — browsers cannot +/// open raw TCP sockets). +class TcpService extends StreamTransportBase { + final String host; + final int port; + + Socket? _socket; + StreamSubscription? _socketSubscription; + + TcpService({required this.host, this.port = 5000}); + + @override + Future openConnection() async { + debugLog('[CONN] TCP connecting to $host:$port'); + setConnecting(); + + try { + _socket = await Socket.connect(host, port, + timeout: const Duration(seconds: 10)); + debugLog('[CONN] TCP connected to $host:$port'); + + setConnected(DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + )); + + _socketSubscription = _socket!.listen( + (data) => onRawBytesReceived(Uint8List.fromList(data)), + onError: (error) { + debugError('[CONN] TCP socket error: $error'); + setDisconnected(); + }, + onDone: () { + debugLog('[CONN] TCP socket closed by remote'); + setDisconnected(); + }, + ); + } catch (e) { + debugError('[CONN] TCP connection failed: $e'); + setError(); + rethrow; + } + } + + @override + Future writeRawBytes(Uint8List data) async { + _socket?.add(data); + } + + @override + Future closeConnection() async { + _socketSubscription?.cancel(); + _socketSubscription = null; + _socket?.destroy(); + _socket = null; + debugLog('[CONN] TCP connection closed'); + } + + @override + void dispose() { + _socketSubscription?.cancel(); + _socket?.destroy(); + super.dispose(); + } + + // --- Saved connections persistence --- + + static const _prefsKey = 'saved_tcp_connections'; + + static Future> getSavedConnections() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_prefsKey); + if (jsonString == null) return []; + + try { + final list = jsonDecode(jsonString) as List; + return list + .map((e) => SavedTcpConnection.fromJson(e as Map)) + .toList() + ..sort((a, b) => b.lastConnected.compareTo(a.lastConnected)); + } catch (e) { + debugError('[CONN] Failed to load saved TCP connections: $e'); + return []; + } + } + + static Future saveConnection( + String host, int port, String name) async { + final prefs = await SharedPreferences.getInstance(); + final connections = await getSavedConnections(); + + final id = '$host:$port'; + connections.removeWhere((c) => c.id == id); + connections.insert( + 0, + SavedTcpConnection( + id: id, + host: host, + port: port, + name: name.isEmpty ? 'TCP $host:$port' : name, + lastConnected: DateTime.now(), + )); + + final jsonString = + jsonEncode(connections.map((c) => c.toJson()).toList()); + await prefs.setString(_prefsKey, jsonString); + } + + static Future deleteConnection(String id) async { + final prefs = await SharedPreferences.getInstance(); + final connections = await getSavedConnections(); + connections.removeWhere((c) => c.id == id); + + final jsonString = + jsonEncode(connections.map((c) => c.toJson()).toList()); + await prefs.setString(_prefsKey, jsonString); + } +} diff --git a/lib/services/transport/web_serial_factory.dart b/lib/services/transport/web_serial_factory.dart new file mode 100644 index 00000000..2f9d138b --- /dev/null +++ b/lib/services/transport/web_serial_factory.dart @@ -0,0 +1,2 @@ +export 'web_serial_factory_stub.dart' + if (dart.library.js_interop) 'web_serial_factory_web.dart'; diff --git a/lib/services/transport/web_serial_factory_stub.dart b/lib/services/transport/web_serial_factory_stub.dart new file mode 100644 index 00000000..514fcf99 --- /dev/null +++ b/lib/services/transport/web_serial_factory_stub.dart @@ -0,0 +1,4 @@ +import 'companion_transport.dart'; + +Future openWebSerialTransport() => + throw UnsupportedError('Web Serial API is only available on web'); diff --git a/lib/services/transport/web_serial_factory_web.dart b/lib/services/transport/web_serial_factory_web.dart new file mode 100644 index 00000000..0ccc7db3 --- /dev/null +++ b/lib/services/transport/web_serial_factory_web.dart @@ -0,0 +1,8 @@ +import 'companion_transport.dart'; +import 'web_serial_service.dart'; + +Future openWebSerialTransport() async { + final service = WebSerialService(); + await service.openConnection(); + return service; +} diff --git a/lib/services/transport/web_serial_service.dart b/lib/services/transport/web_serial_service.dart new file mode 100644 index 00000000..161b046c --- /dev/null +++ b/lib/services/transport/web_serial_service.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +// Web Serial API JS interop bindings + +@JS('navigator.serial') +external JSObject? get _jsNavigatorSerial; + +extension type SerialApi._(JSObject _) implements JSObject { + external JSPromise requestPort([JSObject? options]); +} + +extension type SerialPort._(JSObject _) implements JSObject { + external JSPromise open(JSObject options); + external JSPromise close(); + external web.ReadableStream get readable; + external web.WritableStream get writable; +} + +extension type SerialOptions._(JSObject _) implements JSObject { + external factory SerialOptions({int baudRate}); +} + +/// USB Serial transport for web browsers via the Web Serial API. +/// +/// Uses `navigator.serial` (Chrome/Edge only). The browser shows a native +/// port picker dialog when [openConnection] is called. +class WebSerialService extends StreamTransportBase { + SerialPort? _port; + web.ReadableStreamDefaultReader? _reader; + bool _reading = false; + + @override + Future openConnection() async { + debugLog('[CONN] Web Serial: requesting port'); + setConnecting(); + + try { + final serial = _jsNavigatorSerial; + if (serial == null) { + throw UnsupportedError('Web Serial API not available in this browser'); + } + + final serialApi = SerialApi._(serial); + final portObj = await serialApi.requestPort().toDart; + _port = SerialPort._(portObj); + await _port!.open(SerialOptions(baudRate: 115200)).toDart; + + setConnected(const DiscoveredDevice( + id: 'webserial', + name: 'USB Serial (Web)', + )); + + _startReadLoop(); + debugLog('[CONN] Web Serial connected'); + } catch (e) { + debugError('[CONN] Web Serial connection failed: $e'); + setError(); + rethrow; + } + } + + void _startReadLoop() async { + _reader = _port!.readable.getReader() as web.ReadableStreamDefaultReader; + _reading = true; + + try { + while (_reading) { + final result = await _reader!.read().toDart; + if (result.done) break; + + final value = result.value; + if (value != null) { + final jsArray = value as JSUint8Array; + final bytes = jsArray.toDart; + onRawBytesReceived(bytes); + } + } + } catch (e) { + if (_reading) { + debugError('[CONN] Web Serial read error: $e'); + setDisconnected(); + } + } + } + + @override + Future writeRawBytes(Uint8List data) async { + final writer = _port!.writable.getWriter(); + try { + final jsData = data.toJS; + await writer.write(jsData).toDart; + } finally { + writer.releaseLock(); + } + } + + @override + Future closeConnection() async { + _reading = false; + try { + await _reader?.cancel().toDart; + } catch (_) {} + _reader = null; + try { + await _port?.close().toDart; + } catch (_) {} + _port = null; + debugLog('[CONN] Web Serial connection closed'); + } + + @override + void dispose() { + _reading = false; + _reader?.cancel(); + super.dispose(); + } + + /// Check if Web Serial API is available in this browser. + static bool get isAvailable => _jsNavigatorSerial != null; +} diff --git a/pubspec.yaml b/pubspec.yaml index fed05ed2..80b57d4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=3.2.0 <4.0.0' + sdk: '>=3.3.0 <4.0.0' dependencies: flutter: diff --git a/test/services/transport/stream_frame_codec_test.dart b/test/services/transport/stream_frame_codec_test.dart new file mode 100644 index 00000000..ccabac5c --- /dev/null +++ b/test/services/transport/stream_frame_codec_test.dart @@ -0,0 +1,282 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/transport/stream_frame_codec.dart'; + +void main() { + late StreamFrameCodec codec; + late List receivedFrames; + + setUp(() { + codec = StreamFrameCodec(); + receivedFrames = []; + codec.frames.listen((frame) => receivedFrames.add(frame)); + }); + + tearDown(() { + codec.dispose(); + }); + + Uint8List makeIncomingFrame(List payload) { + final frame = Uint8List(3 + payload.length); + frame[0] = StreamFrameCodec.incomingMarker; // 0x3E + frame[1] = payload.length & 0xFF; + frame[2] = (payload.length >> 8) & 0xFF; + frame.setRange(3, frame.length, payload); + return frame; + } + + group('encode', () { + test('prepends outgoing marker and length', () { + final payload = Uint8List.fromList([0x01, 0x02, 0x03]); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 6); + expect(encoded[0], StreamFrameCodec.outgoingMarker); // 0x3C + expect(encoded[1], 3); // length low byte + expect(encoded[2], 0); // length high byte + expect(encoded.sublist(3), payload); + }); + + test('handles max TX payload (172 bytes)', () { + final payload = Uint8List(172); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 175); + expect(encoded[0], StreamFrameCodec.outgoingMarker); + expect(encoded[1], 172); // 0xAC + expect(encoded[2], 0); + }); + + test('encodes length as little-endian uint16', () { + final payload = Uint8List(100); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded[1], 100); + expect(encoded[2], 0); + }); + + test('handles single-byte payload', () { + final payload = Uint8List.fromList([0xFF]); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 4); + expect(encoded[3], 0xFF); + }); + }); + + group('addBytes - single frame', () { + test('decodes a complete frame', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + codec.addBytes(frame); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01, 0x02, 0x03])); + }); + + test('decodes frame with max RX payload (300 bytes)', () async { + final payload = List.generate(300, (i) => i & 0xFF); + final frame = makeIncomingFrame(payload); + codec.addBytes(frame); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0].length, 300); + }); + }); + + group('addBytes - multiple frames in one chunk', () { + test('extracts two frames from one chunk', () async { + final frame1 = makeIncomingFrame([0xAA]); + final frame2 = makeIncomingFrame([0xBB, 0xCC]); + final combined = Uint8List.fromList([...frame1, ...frame2]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 2); + expect(receivedFrames[0], Uint8List.fromList([0xAA])); + expect(receivedFrames[1], Uint8List.fromList([0xBB, 0xCC])); + }); + + test('extracts three frames from one chunk', () async { + final frame1 = makeIncomingFrame([0x01]); + final frame2 = makeIncomingFrame([0x02]); + final frame3 = makeIncomingFrame([0x03]); + final combined = + Uint8List.fromList([...frame1, ...frame2, ...frame3]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 3); + }); + }); + + group('addBytes - partial frames across chunks', () { + test('header split across two chunks', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + + // Send marker + first length byte + codec.addBytes(Uint8List.fromList(frame.sublist(0, 2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Send rest + codec.addBytes(Uint8List.fromList(frame.sublist(2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01, 0x02, 0x03])); + }); + + test('one byte at a time', () async { + final frame = makeIncomingFrame([0xAA, 0xBB]); + + for (int i = 0; i < frame.length; i++) { + codec.addBytes(Uint8List.fromList([frame[i]])); + await Future.delayed(Duration.zero); + } + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xAA, 0xBB])); + }); + + test('payload split across two chunks', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03, 0x04, 0x05]); + + // Send header + partial payload + codec.addBytes(Uint8List.fromList(frame.sublist(0, 5))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Send remaining payload + codec.addBytes(Uint8List.fromList(frame.sublist(5))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], + Uint8List.fromList([0x01, 0x02, 0x03, 0x04, 0x05])); + }); + }); + + group('addBytes - junk byte handling', () { + test('discards junk bytes before marker', () async { + final junk = [0x41, 0x42, 0x43]; // "ABC" debug text + final frame = makeIncomingFrame([0x01]); + final combined = Uint8List.fromList([...junk, ...frame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01])); + }); + + test('discards all bytes when no marker present', () async { + codec.addBytes(Uint8List.fromList([0x41, 0x42, 0x43, 0x44])); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 0); + }); + + test('handles junk between two frames', () async { + final frame1 = makeIncomingFrame([0xAA]); + final junk = [0x44, 0x45, 0x42, 0x55, 0x47]; // "DEBUG" + final frame2 = makeIncomingFrame([0xBB]); + final combined = + Uint8List.fromList([...frame1, ...junk, ...frame2]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 2); + expect(receivedFrames[0], Uint8List.fromList([0xAA])); + expect(receivedFrames[1], Uint8List.fromList([0xBB])); + }); + }); + + group('addBytes - invalid frames', () { + test('skips zero-length frame', () async { + // Zero-length frame: marker + [0x00, 0x00] + final zeroFrame = Uint8List.fromList([0x3E, 0x00, 0x00]); + final validFrame = makeIncomingFrame([0x01]); + final combined = Uint8List.fromList([...zeroFrame, ...validFrame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01])); + }); + + test('skips oversized frame (>300 bytes)', () async { + // Frame claiming 301 bytes: marker + [0x2D, 0x01] = 301 + final oversizedHeader = Uint8List.fromList([0x3E, 0x2D, 0x01]); + final validFrame = makeIncomingFrame([0x42]); + final combined = + Uint8List.fromList([...oversizedHeader, ...validFrame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x42])); + }); + }); + + group('addBytes - edge cases', () { + test('empty addBytes is a no-op', () async { + codec.addBytes(Uint8List(0)); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 0); + }); + + test('only marker byte, then rest later', () async { + codec.addBytes(Uint8List.fromList([0x3E])); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + codec.addBytes(Uint8List.fromList([0x02, 0x00, 0xAA, 0xBB])); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xAA, 0xBB])); + }); + + test('reset clears buffer state', () async { + // Send partial frame + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + codec.addBytes(Uint8List.fromList(frame.sublist(0, 2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Reset + codec.reset(); + + // Send a complete new frame + final newFrame = makeIncomingFrame([0xFF]); + codec.addBytes(newFrame); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xFF])); + }); + }); + + group('encode/decode round-trip', () { + test('encode then decode produces original payload', () async { + final original = Uint8List.fromList([0x16, 0x01, 0x4D, 0x65, 0x73]); + final encoded = StreamFrameCodec.encode(original); + + // Simulate what the device would do: change the marker from 0x3C to 0x3E + // (device echoes back with incoming marker) + final incoming = Uint8List.fromList(encoded); + incoming[0] = StreamFrameCodec.incomingMarker; + + codec.addBytes(incoming); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], original); + }); + }); +} From 21395acb7dc8c7a924364bdaa0580dd8105045f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 13 May 2026 22:29:28 -0400 Subject: [PATCH 102/360] - **Auto-Ping Grace Period:** Fixed auto-ping timers not actually stopping during zone grace period, causing a desync where re-enabling auto-ping silently failed. - **Cross-Zone Auto-Ping Restore:** Fixed auto-ping mode not being restored after transferring to a different zone during grace period. The saved mode was being cleared before the transfer could read it. - **Path Hash Mode on Zone Change:** Fixed path hash mode not being reconfigured correctly when moving from an enforced zone to a non-enforced zone whose firmware default matched the previous setting. --- lib/providers/app_state_provider.dart | 41 ++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 20fba856..b9a145c7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2744,14 +2744,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final deviceInfo = _meshCoreConnection?.deviceInfo; if (deviceInfo == null) return; - // Store the device's current mode (from DeviceInfo response) + // Capture what the radio is CURRENTLY doing before resetting to firmware + // default — during zone transfer this reflects the previous zone's mode + final currentRuntimeHopBytes = _hopBytes; + + // Store the device's original firmware mode (from DeviceInfo response) _originalPathHashMode = deviceInfo.pathHashMode; - // Sync runtime hopBytes from device's current mode + // Sync runtime hopBytes from device's firmware mode + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceHopBytes = deviceMode + 1; if (_originalPathHashMode != null) { - final deviceHopBytes = _originalPathHashMode! + 1; _hopBytes = deviceHopBytes; - // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; debugLog( @@ -2762,19 +2767,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final effective = effectiveHopBytes; - final deviceMode = - _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) - final deviceHopBytes = deviceMode + 1; - if (effective != deviceHopBytes && _originalPathHashMode != null) { + if (effective != currentRuntimeHopBytes && _originalPathHashMode != null) { // Need to change the radio's path hash mode try { await _meshCoreConnection!.setPathHashMode(effective - 1); - _hopBytes = effective; // Update runtime state to reflect new mode + _hopBytes = effective; _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; debugLog( - '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + '[PATH] Set path hash mode: radio was $currentRuntimeHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2782,7 +2784,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? 'set by your regional admin' : 'set in your app preferences'; _pendingPathHashWarning = (hopBytes: effective, reason: reason); - notifyListeners(); // Trigger UI to show warning + notifyListeners(); } } catch (e) { debugError('[PATH] Failed to set path hash mode: $e'); @@ -2798,7 +2800,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { debugLog( - '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + '[PATH] Path hash mode OK: radio=$currentRuntimeHopBytes-byte, effective=$effective-byte'); } } @@ -5464,6 +5466,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { _autoPingEnabled = false; _idleAutoStopReference = null; + await _pingService?.forceDisableAutoPing(); debugLog('[ZONE GRACE] Auto-ping paused'); } @@ -5545,9 +5548,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; + final savedAutoPing = _autoPingWasEnabledBeforeGrace; + final savedMode = _autoModeBeforeGrace; _autoPingWasEnabledBeforeGrace = false; await _handleZoneTransfer( - reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown', + wasAutoPingOverride: savedAutoPing, + previousModeOverride: savedMode); return; } @@ -5646,7 +5653,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. Future _handleZoneTransfer( - String newZoneCode, String newZoneName) async { + String newZoneCode, String newZoneName, + {bool? wasAutoPingOverride, AutoMode? previousModeOverride}) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -5661,8 +5669,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { // 1. Save auto-ping state for restoration - final wasAutoPing = _autoPingEnabled; - final previousMode = _autoMode; + // Prefer overrides from grace period (where provider state was already cleared) + final wasAutoPing = wasAutoPingOverride ?? _autoPingEnabled; + final previousMode = previousModeOverride ?? _autoMode; // 2. Pause auto-ping and wardriving activity _autoPingTimer.stop(); From f6ae59a44c82b14f81d7c0492f569114dfd8c88d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:00:17 -0400 Subject: [PATCH 103/360] Add zoneDisabled enum value and new offline upload diagnostic script --- bin/test_offline_upload.dart | 975 ++++++++++++++++++++++++++ lib/providers/app_state_provider.dart | 3 + 2 files changed, 978 insertions(+) create mode 100644 bin/test_offline_upload.dart diff --git a/bin/test_offline_upload.dart b/bin/test_offline_upload.dart new file mode 100644 index 00000000..89ba5d48 --- /dev/null +++ b/bin/test_offline_upload.dart @@ -0,0 +1,975 @@ +/// MeshMapper Offline Upload Diagnostic Script +/// +/// Reproduces server-side session invalidation during offline batch uploads. +/// Runs four scenarios to determine if the server has an item count limit, +/// rate limit, zone mismatch issue, or other constraint. +/// +/// Usage: +/// dart run bin/test_offline_upload.dart --key= --pubkey= [options] +/// +/// Required: +/// --key= MeshMapper API key +/// --pubkey= Registered device public key (hex) +/// +/// Optional: +/// --lat= Latitude for auth (default: 45.3215) +/// --lon= Longitude for auth (default: -75.6693) +/// --data-lat= Latitude for ping data (default: same as --lat) +/// --data-lon= Longitude for ping data (default: same as --lon) +/// --who= Device name (default: DIAG-TEST) +/// --scenario=<1|2|3|4|all> Run specific scenario (default: all) +/// --contact-uri= Signed contact URI (for unregistered devices) +/// +/// Examples: +/// dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef... +/// dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef... --scenario=1 +library; + +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const String baseUrl = 'https://meshmapper.net'; +const String authEndpoint = '$baseUrl/wardrive-api.php/auth'; +const String wardriveEndpoint = '$baseUrl/wardrive-api.php/wardrive'; +final String testVersion = + 'APP-${DateTime.now().millisecondsSinceEpoch ~/ 1000}'; +const String testModel = 'Offline Upload'; + +// ============================================================================ +// GLOBAL STATE (for Ctrl+C cleanup) +// ============================================================================ + +String? _activeSessionId; +String? _activeApiKey; +String? _activePubkey; +http.Client? _activeClient; + +// ============================================================================ +// DATA GENERATION +// ============================================================================ + +List> generateTestItems( + int count, { + double baseLat = 45.3215, + double baseLon = -75.6693, +}) { + final baseTimestamp = + (DateTime.now().subtract(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000); + final items = >[]; + + for (var i = 0; i < count; i++) { + final timestamp = baseTimestamp + (i * 10); + final lat = baseLat + (i * 0.00001); + final lon = baseLon + (i * 0.00001); + + if (i.isEven) { + items.add({ + 'type': 'RX', + 'lat': lat, + 'lon': lon, + 'noisefloor': -100, + 'heard_repeats': 'ff(0.0)', + 'timestamp': timestamp, + 'external_antenna': true, + 'power': '0.3w', + }); + } else { + items.add({ + 'type': 'DISC', + 'lat': lat, + 'lon': lon, + 'noisefloor': -100, + 'repeater_id': 'ff', + 'node_type': 'REPEATER', + 'local_snr': 0.0, + 'local_rssi': -100, + 'remote_snr': 0.0, + 'public_key': + '0000000000000000000000000000000000000000000000000000000000000000', + 'timestamp': timestamp, + 'external_antenna': true, + 'power': '0.3w', + }); + } + } + return items; +} + +// ============================================================================ +// API HELPERS +// ============================================================================ + +Future?> authenticate({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + required String who, + String? contactUri, +}) async { + final payload = { + 'key': apiKey, + 'reason': 'connect', + 'offline_mode': true, + 'who': who, + 'ver': testVersion, + 'power': '0.3w', + 'model': testModel, + 'coords': { + 'lat': lat, + 'lng': lon, + 'accuracy_m': 5.0, + 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, + }; + + if (contactUri != null) { + payload['contact_uri'] = contactUri; + } else { + payload['public_key'] = pubkey; + } + + try { + final stopwatch = Stopwatch()..start(); + final response = await client + .post( + Uri.parse(authEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + stopwatch.stop(); + + final data = json.decode(response.body) as Map; + + if (response.statusCode != 200) { + print(' Auth HTTP ${response.statusCode}: ${response.body}'); + } + + return { + ...data, + '_http_status': response.statusCode, + '_elapsed_ms': stopwatch.elapsedMilliseconds, + }; + } catch (e) { + print(' Auth exception: $e'); + return null; + } +} + +Future?> registerDevice({ + required http.Client client, + required String apiKey, + required String contactUri, + required double lat, + required double lon, + required String who, +}) async { + return authenticate( + client: client, + apiKey: apiKey, + pubkey: '', + lat: lat, + lon: lon, + who: who, + contactUri: contactUri, + ); +} + +Future uploadBatch({ + required http.Client client, + required String apiKey, + required String sessionId, + required List> items, + required int batchNum, + required int cumulativeItems, +}) async { + final stopwatch = Stopwatch()..start(); + + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': items, + }; + + final response = await client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + final data = json.decode(response.body) as Map; + + return BatchResult( + batchNumber: batchNum, + itemCount: items.length, + cumulativeItems: cumulativeItems, + success: data['success'] == true, + httpStatus: response.statusCode, + reason: data['reason'] as String?, + message: data['message'] as String?, + expiresAt: data['expires_at'] as int?, + responseTime: stopwatch.elapsed, + ); + } catch (e) { + stopwatch.stop(); + return BatchResult( + batchNumber: batchNum, + itemCount: items.length, + cumulativeItems: cumulativeItems, + success: false, + httpStatus: 0, + reason: 'exception', + message: '$e', + expiresAt: null, + responseTime: stopwatch.elapsed, + ); + } +} + +Future disconnectSession({ + required http.Client client, + required String apiKey, + required String pubkey, + required String sessionId, +}) async { + try { + final payload = { + 'key': apiKey, + 'reason': 'disconnect', + 'public_key': pubkey, + 'session_id': sessionId, + }; + + await client + .post( + Uri.parse(authEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + } catch (_) { + // Best effort + } +} + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +class BatchResult { + final int batchNumber; + final int itemCount; + final int cumulativeItems; + final bool success; + final int httpStatus; + final String? reason; + final String? message; + final int? expiresAt; + final Duration responseTime; + + BatchResult({ + required this.batchNumber, + required this.itemCount, + required this.cumulativeItems, + required this.success, + required this.httpStatus, + this.reason, + this.message, + this.expiresAt, + required this.responseTime, + }); +} + +class ScenarioResult { + final String name; + final String description; + final int batchSize; + final Duration interBatchDelay; + final List batches; + final int? failedAtBatch; + final int? failedAtCumulativeItems; + final String? failureReason; + final bool authFailed; + + ScenarioResult({ + required this.name, + required this.description, + required this.batchSize, + required this.interBatchDelay, + required this.batches, + this.failedAtBatch, + this.failedAtCumulativeItems, + this.failureReason, + this.authFailed = false, + }); + + bool get passed => failedAtBatch == null && !authFailed; + int get totalUploaded => + batches.where((b) => b.success).fold(0, (sum, b) => sum + b.itemCount); +} + +// ============================================================================ +// SCENARIO RUNNER +// ============================================================================ + +Future runScenario({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + required String who, + required String name, + required String description, + required int batchSize, + required int maxBatches, + required Duration interBatchDelay, + String? contactUri, + double? dataLat, + double? dataLon, +}) async { + // Authenticate + stdout.write(' Authenticating...'); + final authResult = await authenticate( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + who: who, + contactUri: contactUri, + ); + + if (authResult == null || authResult['success'] != true) { + final reason = authResult?['reason'] as String? ?? 'network error'; + + // Try registration if unknown_device and contactUri available + if (reason == 'unknown_device' && contactUri != null) { + stdout.write(' unknown device, trying registration...'); + final regResult = await registerDevice( + client: client, + apiKey: apiKey, + contactUri: contactUri, + lat: lat, + lon: lon, + who: who, + ); + if (regResult == null || regResult['success'] != true) { + print(' FAILED (registration: ${regResult?['reason'] ?? 'error'})'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Registration failed: ${regResult?['reason']}', + authFailed: true, + ); + } + // Use registration session + final sessionId = regResult['session_id'] as String?; + if (sessionId == null) { + print(' FAILED (no session_id after registration)'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'No session_id after registration', + authFailed: true, + ); + } + print(' OK via registration (session: $sessionId)'); + _activeSessionId = sessionId; + _activeApiKey = apiKey; + _activePubkey = pubkey; + _activeClient = client; + // Fall through to upload with sessionId + return _runBatches( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + sessionId: sessionId, + name: name, + description: description, + batchSize: batchSize, + maxBatches: maxBatches, + interBatchDelay: interBatchDelay, + ); + } + + print(' FAILED ($reason)'); + if (reason == 'unknown_device') { + print( + ' Hint: device not registered. Pass --contact-uri= or use a registered device key.'); + } + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Auth failed: $reason', + authFailed: true, + ); + } + + final sessionId = authResult['session_id'] as String?; + final expiresAt = authResult['expires_at'] as int?; + final elapsed = authResult['_elapsed_ms'] as int?; + if (sessionId == null) { + print(' FAILED (no session_id)'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Auth succeeded but no session_id', + authFailed: true, + ); + } + + final ttl = expiresAt != null + ? expiresAt - (DateTime.now().millisecondsSinceEpoch ~/ 1000) + : 0; + print(' OK (session: $sessionId, TTL: ${ttl}s, ${elapsed}ms)'); + + _activeSessionId = sessionId; + _activeApiKey = apiKey; + _activePubkey = pubkey; + _activeClient = client; + + return _runBatches( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + sessionId: sessionId, + name: name, + description: description, + batchSize: batchSize, + maxBatches: maxBatches, + interBatchDelay: interBatchDelay, + ); +} + +Future _runBatches({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + double? dataLat, + double? dataLon, + required String sessionId, + required String name, + required String description, + required int batchSize, + required int maxBatches, + required Duration interBatchDelay, +}) async { + final batches = []; + int? failedAtBatch; + int? failedAtCumulative; + String? failureReason; + + // Wait for session propagation + print(' Waiting 4s for session propagation...'); + await Future.delayed(const Duration(seconds: 4)); + + // Generate all test items up front (use data coords if provided, else auth coords) + final totalItems = batchSize * maxBatches; + final allItems = generateTestItems(totalItems, + baseLat: dataLat ?? lat, baseLon: dataLon ?? lon); + + // Upload batches + var cumulativeItems = 0; + for (var i = 0; i < maxBatches; i++) { + final batchNum = i + 1; + final start = i * batchSize; + final end = (start + batchSize).clamp(0, allItems.length); + final batch = allItems.sublist(start, end); + cumulativeItems += batch.length; + + stdout.write( + ' Batch $batchNum/$maxBatches (${batch.length} items, total: $cumulativeItems)...'); + + final result = await uploadBatch( + client: client, + apiKey: apiKey, + sessionId: sessionId, + items: batch, + batchNum: batchNum, + cumulativeItems: cumulativeItems, + ); + batches.add(result); + + if (result.success) { + final elapsed = + '${(result.responseTime.inMilliseconds / 1000).toStringAsFixed(2)}s'; + print(' OK ($elapsed)'); + } else { + final elapsed = + '${(result.responseTime.inMilliseconds / 1000).toStringAsFixed(2)}s'; + print( + ' FAIL ${result.httpStatus} ${result.reason ?? 'unknown'} ($elapsed)'); + if (result.message != null) { + print(' "${result.message}"'); + } + failedAtBatch = batchNum; + failedAtCumulative = cumulativeItems; + failureReason = result.reason; + break; + } + + // Inter-batch delay + if (interBatchDelay.inMilliseconds > 0 && i < maxBatches - 1) { + stdout.write( + ' (waiting ${interBatchDelay.inSeconds}s before next batch...)\n'); + await Future.delayed(interBatchDelay); + } + } + + // Disconnect + stdout.write(' Disconnecting...'); + await disconnectSession( + client: client, + apiKey: apiKey, + pubkey: pubkey, + sessionId: sessionId, + ); + print(' OK'); + + _activeSessionId = null; + _activeApiKey = null; + _activePubkey = null; + _activeClient = null; + + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: batches, + failedAtBatch: failedAtBatch, + failedAtCumulativeItems: failedAtCumulative, + failureReason: failureReason, + ); +} + +// ============================================================================ +// OUTPUT +// ============================================================================ + +void printHeader(String apiKey, String pubkey, double lat, double lon, + double? dataLat, double? dataLon, String who, String scenarios) { + print(''); + print('=' * 60); + print(' MESHMAPPER OFFLINE UPLOAD DIAGNOSTIC'); + print('=' * 60); + print(''); + print(' Config:'); + print( + ' API Key: ${apiKey.length > 8 ? '${apiKey.substring(0, 4)}...${apiKey.substring(apiKey.length - 4)}' : '****'}'); + print( + ' Public Key: ${pubkey.length > 16 ? '${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}' : pubkey}'); + print(' Device: $who'); + print(' Auth Coords: $lat, $lon'); + if (dataLat != null || dataLon != null) { + print(' Data Coords: ${dataLat ?? lat}, ${dataLon ?? lon}'); + } + print(' Scenarios: $scenarios'); + print(''); +} + +void printScenarioHeader(String name, String description) { + print('=' * 60); + print(' SCENARIO: $name'); + print(' $description'); + print('=' * 60); + print(''); +} + +void printSummary(List results) { + print(''); + print('=' * 60); + print(' DIAGNOSTIC SUMMARY'); + print('=' * 60); + print(''); + + for (final result in results) { + final icon = result.passed ? '[+]' : '[X]'; + final delayStr = result.interBatchDelay.inSeconds > 0 + ? '${result.interBatchDelay.inSeconds}s delay' + : 'no delay'; + + if (result.authFailed) { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print(' Auth failed: ${result.failureReason}'); + } else if (result.passed) { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print(' PASSED ${result.totalUploaded} items across ${result.batches.length} batches'); + } else { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print( + ' FAILED at batch ${result.failedAtBatch} (${result.failedAtCumulativeItems} cumulative items)'); + print(' Reason: ${result.failureReason}'); + print( + ' Successfully uploaded: ${result.totalUploaded} items before failure'); + } + print(''); + } + + // Analysis + final validResults = results.where((r) => !r.authFailed).toList(); + if (validResults.isEmpty) { + print(' FINDINGS:'); + print(' - All scenarios failed to authenticate.'); + print( + ' - Check your API key and device public key, or provide --contact-uri'); + } else { + print(' FINDINGS:'); + + final rapidFire = validResults + .where( + (r) => r.interBatchDelay.inSeconds == 0 && r.batchSize == 50) + .toList(); + final throttled = validResults + .where((r) => r.interBatchDelay.inSeconds > 0 && r.batchSize == 50) + .toList(); + final batchSweep = validResults + .where( + (r) => r.interBatchDelay.inSeconds == 0 && r.batchSize != 50) + .toList(); + + // Check if rapid fire failed but throttled passed + if (rapidFire.isNotEmpty && + throttled.isNotEmpty && + !rapidFire.first.passed && + throttled.first.passed) { + print( + ' - Rapid-fire batches fail at ~${rapidFire.first.totalUploaded} items, but throttled batches pass.'); + print(' - CONCLUSION: Server-side RATE LIMIT (not item count limit).'); + print( + ' - The server rejects rapid consecutive POSTs to the same session.'); + print( + ' - Workaround: add a delay between batch uploads (${throttled.first.interBatchDelay.inSeconds}s worked).'); + } else if (rapidFire.isNotEmpty && + throttled.isNotEmpty && + !rapidFire.first.passed && + !throttled.first.passed) { + final rapidItems = rapidFire.first.totalUploaded; + final throttledItems = throttled.first.totalUploaded; + if ((rapidItems - throttledItems).abs() < 60) { + print( + ' - Both rapid and throttled fail at ~$rapidItems items.'); + print( + ' - CONCLUSION: Server-side ITEM COUNT LIMIT (~$rapidItems per session).'); + print( + ' - Workaround: re-authenticate after every ~${(rapidItems * 0.8).round()} items.'); + } else { + print( + ' - Rapid: failed at $rapidItems items. Throttled: failed at $throttledItems items.'); + print( + ' - CONCLUSION: Mixed behavior — may be a combination of rate + count limits.'); + } + } else if (validResults.every((r) => r.passed)) { + print(' - All scenarios passed!'); + print( + ' - Could not reproduce the session invalidation in this run.'); + print( + ' - The issue may depend on server load, time of day, or specific session state.'); + } + + // Batch size analysis + if (batchSweep.length >= 2) { + print(''); + print(' Batch size analysis:'); + for (final r in batchSweep) { + final status = r.passed ? 'PASSED' : 'FAILED at ${r.totalUploaded}'; + print(' batch=${r.batchSize}: $status'); + } + } + + // Out-of-zone analysis + final oozResults = + validResults.where((r) => r.name == 'Out-of-Zone Data').toList(); + if (oozResults.isNotEmpty) { + print(''); + print(' Out-of-zone data analysis:'); + final ooz = oozResults.first; + if (ooz.passed) { + print( + ' PASSED — server accepted ${ooz.totalUploaded} items with out-of-zone coordinates.'); + print( + ' Zone mismatch between auth and ping data does NOT cause session invalidation.'); + } else { + print( + ' FAILED at batch ${ooz.failedAtBatch} (${ooz.failedAtCumulativeItems} cumulative items).'); + print(' Reason: ${ooz.failureReason}'); + if (ooz.failureReason == 'bad_session' || + ooz.failureReason == 'session_expired') { + print( + ' CONCLUSION: Server MAY invalidate sessions when ping coords are outside auth zone.'); + } + } + } + } + + print(''); + print('=' * 60); + print(' NOTE: Test data uses who="DIAG-TEST" and past timestamps.'); + print(' Ask the backend team to purge DIAG-TEST data after review.'); + print('=' * 60); + print(''); +} + +// ============================================================================ +// MAIN +// ============================================================================ + +Future main(List arguments) async { + if (arguments.isEmpty || arguments.contains('--help')) { + printUsage(); + return; + } + + // Parse arguments + String? apiKey; + String? pubkey; + String? contactUri; + double lat = 45.3215; + double lon = -75.6693; + double? dataLat; + double? dataLon; + String who = 'DIAG-TEST'; + String scenario = 'all'; + + for (final arg in arguments) { + if (arg.startsWith('--key=')) { + apiKey = arg.substring(6); + } else if (arg.startsWith('--pubkey=')) { + pubkey = arg.substring(9); + } else if (arg.startsWith('--contact-uri=')) { + contactUri = arg.substring(14); + } else if (arg.startsWith('--lat=')) { + lat = double.parse(arg.substring(6)); + } else if (arg.startsWith('--lon=')) { + lon = double.parse(arg.substring(6)); + } else if (arg.startsWith('--data-lat=')) { + dataLat = double.parse(arg.substring(11)); + } else if (arg.startsWith('--data-lon=')) { + dataLon = double.parse(arg.substring(11)); + } else if (arg.startsWith('--who=')) { + who = arg.substring(6); + } else if (arg.startsWith('--scenario=')) { + scenario = arg.substring(11); + } + } + + if (apiKey == null) { + print('Error: --key= is required'); + printUsage(); + exit(1); + } + if (pubkey == null && contactUri == null) { + print('Error: --pubkey= or --contact-uri= is required'); + printUsage(); + exit(1); + } + + pubkey ??= ''; + + // Setup signal handler for Ctrl+C + ProcessSignal.sigint.watch().listen((_) async { + print('\n\n [CTRL+C] Cleaning up...'); + if (_activeSessionId != null && _activeClient != null) { + await disconnectSession( + client: _activeClient!, + apiKey: _activeApiKey!, + pubkey: _activePubkey!, + sessionId: _activeSessionId!, + ); + print(' Session disconnected.'); + } + _activeClient?.close(); + exit(1); + }); + + final client = http.Client(); + final results = []; + + printHeader(apiKey, pubkey, lat, lon, dataLat, dataLon, who, scenario); + + // Scenario 1: Rapid fire (reproduce the bug) + if (scenario == 'all' || scenario == '1') { + printScenarioHeader( + '1 - Rapid Fire', + 'Batch=50, no delay. Should reproduce failure at ~batch 4.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Rapid Fire', + description: 'batch=50, no delay', + batchSize: 50, + maxBatches: 10, + interBatchDelay: Duration.zero, + )); + print(''); + } + + // Scenario 2: Throttled (test rate limit hypothesis) + if (scenario == 'all' || scenario == '2') { + printScenarioHeader( + '2 - Throttled', + 'Batch=50, 3s delay between batches. Tests rate limit hypothesis.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Throttled', + description: 'batch=50, 3s delay', + batchSize: 50, + maxBatches: 10, + interBatchDelay: const Duration(seconds: 3), + )); + print(''); + } + + // Scenario 3: Batch size sweep + if (scenario == 'all' || scenario == '3') { + for (final size in [10, 25, 100]) { + final maxBatches = (200 / size).ceil(); + printScenarioHeader( + '3 - Batch Size $size', + 'Batch=$size, no delay, up to $maxBatches batches (${size * maxBatches} items).', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Batch Size $size', + description: 'batch=$size, no delay', + batchSize: size, + maxBatches: maxBatches, + interBatchDelay: Duration.zero, + )); + print(''); + } + } + + // Scenario 4: Out-of-zone ping data + if (scenario == 'all' || scenario == '4') { + // Auth from in-zone coords, but ping data from far outside any zone + const oozLat = 50.0; + const oozLon = -90.0; + printScenarioHeader( + '4 - Out-of-Zone Data', + 'Auth at ($lat, $lon) but ping coords at ($oozLat, $oozLon). Tests zone mismatch.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat ?? oozLat, + dataLon: dataLon ?? oozLon, + who: who, + contactUri: contactUri, + name: 'Out-of-Zone Data', + description: 'auth in-zone, data out-of-zone', + batchSize: 50, + maxBatches: 3, + interBatchDelay: Duration.zero, + )); + print(''); + } + + printSummary(results); + client.close(); +} + +void printUsage() { + print(''' +MeshMapper Offline Upload Diagnostic + +Reproduces server-side session invalidation during offline batch uploads. +Runs four scenarios to determine if the server has an item count limit, +rate limit, zone mismatch issue, or other constraint. + +Usage: + dart run bin/test_offline_upload.dart --key= --pubkey= [options] + +Required: + --key= MeshMapper API key + --pubkey= Registered device public key (hex) + +Optional: + --contact-uri= Signed contact URI (if device not yet registered) + --lat= Auth latitude (default: 45.3215) + --lon= Auth longitude (default: -75.6693) + --data-lat= Ping data latitude (default: same as --lat) + --data-lon= Ping data longitude (default: same as --lon) + --who= Device name (default: DIAG-TEST) + --scenario=<1|2|3|4|all> Run specific scenario (default: all) + +Scenarios: + 1 Rapid fire Batch=50, no delay, 10 batches. Reproduces the bug. + 2 Throttled Batch=50, 3s delay, 10 batches. Tests rate limit. + 3 Batch sweep Batch=10/25/100, no delay. Tests per-batch vs cumulative limit. + 4 Out-of-zone data Auth in-zone, ping coords out-of-zone. Tests zone mismatch. + +Examples: + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 --scenario=1 + dart run bin/test_offline_upload.dart --key=abc123 --contact-uri="meshcore://..." --scenario=2 + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 --scenario=4 --data-lat=50.0 --data-lon=-90.0 +'''); +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b9a145c7..4703d071 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -87,6 +87,9 @@ enum OfflineUploadResult { /// GPS position required but not available gpsRequired, + + /// Zone is disabled server-side + zoneDisabled, } /// Main application state provider From dae5bf4c91541df69c471cff6cb96eab80ff25f5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:23:58 -0400 Subject: [PATCH 104/360] - Fixed auto-ping mode not validated against new zone permissions on transfer - Fixed zone admin overrides persisting after moving to a less restrictive zone - Fixed TCP dropping in background on Android - Fixed Connect screen always defaulting to BLE instead of last used transport --- android/app/src/main/AndroidManifest.xml | 3 +- lib/providers/app_state_provider.dart | 91 ++++++++++++++++++++++-- lib/services/background_service.dart | 1 + lib/services/transport/tcp_service.dart | 1 + 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bd38937d..ab81f274 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ + diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4703d071..c2ac4461 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -246,6 +246,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; + // User's original preferences before zone admin overrides (single baseline). + // Saved on initial connect; restored before applying each new zone's policies. + int? _userOriginalAutoPingInterval; + bool? _userOriginalHybridMode; + bool? _userOriginalDiscDrop; + bool? _userOriginalFloodTraffic; + // Debug logs state (non-persistent, always starts false) bool _debugLogsEnabled = false; List _debugLogFiles = []; @@ -1991,6 +1998,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] No regional scope — using unscoped flood'); } + // Snapshot user's preferences before zone admin overrides (single baseline) + _userOriginalAutoPingInterval = _preferences.autoPingInterval; + _userOriginalHybridMode = _preferences.hybridModeEnabled; + _userOriginalDiscDrop = _preferences.discDropEnabled; + _userOriginalFloodTraffic = _preferences.floodTrafficEnabled; + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { _preferences = _preferences.copyWith(hybridModeEnabled: true); debugLog('[CONN] Hybrid mode force-enabled by regional admin'); @@ -3406,6 +3419,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _activeTransport = null; + // Restore transport tab selection so connection screen shows the right tab + if (_rememberedDevice != null) { + _selectedTransport = _rememberedDevice!.transportType; + } + _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); @@ -3436,6 +3454,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _regionalChannels = []; _scope = null; + // Clear user-original preference tracking + _userOriginalAutoPingInterval = null; + _userOriginalHybridMode = null; + _userOriginalDiscDrop = null; + _userOriginalFloodTraffic = null; + // Clear zone transfer state _sessionZoneCode = null; _isZoneTransferInProgress = false; @@ -4610,6 +4634,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = preferences; + // Update user-original baseline when user changes zone-overridable settings + if (_userOriginalAutoPingInterval != null) { + _userOriginalAutoPingInterval = preferences.autoPingInterval; + _userOriginalHybridMode = preferences.hybridModeEnabled; + _userOriginalDiscDrop = preferences.discDropEnabled; + _userOriginalFloodTraffic = preferences.floodTrafficEnabled; + } + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; @@ -5612,8 +5644,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { - toggleAutoPing(previousMode); - debugLog('[ZONE GRACE] Auto-ping restored (mode=$previousMode)'); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); } }); } else { @@ -5648,6 +5682,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _abandonZoneGracePeriod(); } + /// Resolve the desired auto-ping mode against the current zone's permissions. + /// Maps invalid modes to the best valid alternative for the new zone. + AutoMode _resolveAutoModeForZone(AutoMode desired) { + final txOk = _apiService.txAllowed; + final rxOk = _apiService.rxAllowed; + final hybrid = _apiService.enforceHybrid; + + // No TX allowed → passive only + if (!txOk && rxOk) return AutoMode.passive; + + // TX allowed but zone enforces hybrid → map active to hybrid + if (txOk && hybrid && desired == AutoMode.active) return AutoMode.hybrid; + + // TX allowed, zone doesn't enforce hybrid → map hybrid back to active + // (unless user explicitly chose hybrid via _userOriginalHybridMode) + if (txOk && !hybrid && desired == AutoMode.hybrid) { + if (_userOriginalHybridMode != true) return AutoMode.active; + } + + // Targeted/trace mode is transport-level, not channel TX — keep as-is + return desired; + } + // ============================================ // Zone-to-Zone Transfer // ============================================ @@ -5894,7 +5951,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] No regional scope — using unscoped flood'); } - // 15. Enforce regional admin policies from new zone + // 15. Restore user's original preferences, then apply new zone's policies + if (_userOriginalAutoPingInterval != null) { + _preferences = _preferences.copyWith( + autoPingInterval: _userOriginalAutoPingInterval!); + } + if (_userOriginalHybridMode != null) { + _preferences = + _preferences.copyWith(hybridModeEnabled: _userOriginalHybridMode!); + } + if (_userOriginalDiscDrop != null) { + _preferences = + _preferences.copyWith(discDropEnabled: _userOriginalDiscDrop!); + } + if (_userOriginalFloodTraffic != null) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: _userOriginalFloodTraffic!); + } + debugLog( + '[ZONE] Preferences restored to user baseline before applying new zone policies'); + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { _preferences = _preferences.copyWith(hybridModeEnabled: true); debugLog('[ZONE] Hybrid mode force-enabled by new zone admin'); @@ -5969,8 +6045,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { _cooldownTimer.stop(); - toggleAutoPing(previousMode); - debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE] Mode resolved for new zone: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); } }); } else { @@ -6393,7 +6471,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (json != null) { _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); - debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); + _selectedTransport = _rememberedDevice!.transportType; + debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name} (${_rememberedDevice!.transportType.name})'); notifyListeners(); } } catch (e) { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 2d7bed52..d5dd44e0 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -82,6 +82,7 @@ class BackgroundServiceManager { foregroundServiceTypes: [ AndroidForegroundType.location, AndroidForegroundType.connectedDevice, + AndroidForegroundType.dataSync, ], ), iosConfiguration: IosConfiguration( diff --git a/lib/services/transport/tcp_service.dart b/lib/services/transport/tcp_service.dart index d1a54284..16f71059 100644 --- a/lib/services/transport/tcp_service.dart +++ b/lib/services/transport/tcp_service.dart @@ -66,6 +66,7 @@ class TcpService extends StreamTransportBase { try { _socket = await Socket.connect(host, port, timeout: const Duration(seconds: 10)); + _socket!.setOption(SocketOption.tcpNoDelay, true); debugLog('[CONN] TCP connected to $host:$port'); setConnected(DiscoveredDevice( From a6edfca9ead5122b893360ff3e44ad044d57cf87 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:43:53 -0400 Subject: [PATCH 105/360] Add design spec for focus mode minimize feature Allows minimizing the ping details popup to a compact pill, giving full map zoom/pan access while keeping focus lines visible. --- .../2026-05-17-focus-mode-minimize-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md diff --git a/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md b/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md new file mode 100644 index 00000000..a0399e41 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md @@ -0,0 +1,134 @@ +# Focus Mode Minimize + +Allow users to minimize the ping details popup during focus mode to get a full map view with zoom/pan access, while keeping focus lines and context visible. + +## Current Behavior + +When a ping marker is tapped, focus mode activates: +1. `_activatePingFocus()` saves pre-focus camera state, hides unrelated markers/coverage, draws focus lines to repeaters, zooms to fit bounds +2. `showModalBottomSheet()` displays ping details (TX/RX/Disc/Trace variants) +3. The sheet uses `barrierColor: Colors.transparent` — map is visible but the invisible barrier blocks all touch events (no zoom/pan) +4. Closing the sheet (X or swipe-down) calls `_dismissPingFocus()` which restores the pre-focus map state + +**Problem:** Users cannot zoom or pan the map while viewing focus lines because the modal barrier intercepts all gestures. + +## Design + +### Minimize Button in Sheet Header + +Each of the 4 detail sheet variants (TX, RX, Disc, Trace) gains a minimize button (down-chevron icon) next to the existing close button: + +``` + [↑] TX Ping 12:34:05 ▽ ✕ +``` + +- `▽` (minimize): closes the sheet but keeps focus mode active → shows minimized pill +- `✕` (close): exits focus mode entirely (unchanged behavior) + +### Minimized Pill + +A compact, non-modal widget rendered in the map widget's `Stack`: + +``` +┌──────────────────────────────────────────────┐ +│ ↑ TX Ping 12:34:05 3 repeaters [△] [✕] │ +└──────────────────────────────────────────────┘ +``` + +- **Position:** Bottom of map, horizontally centered, above safe area inset +- **Style:** `surfaceContainerHighest` background, rounded corners (12px), subtle border — matches existing sheet theme +- **Content:** Ping type icon (colored), type label, formatted timestamp, repeater count, expand button, close button +- **Behavior:** + - `△` (expand): re-opens the full details sheet without re-zooming the map + - `✕` (close): calls `_dismissPingFocus()` to exit focus mode entirely + - Tapping the pill body also expands (same as △) + +### Map Interaction When Minimized + +Since the pill is a regular widget in the Stack (not a modal), the map underneath is fully interactable: +- Pinch-to-zoom works +- Pan/drag works +- Rotation works (if rotation lock is off) +- Map control buttons (top-right) remain accessible +- Focus lines and distance labels remain visible + +### What Stays the Same + +- Full details sheet content (repeater tables, location chip, path chain, etc.) +- Focus activation logic (zoom-to-fit, save pre-focus state, focus lines, coverage hide) +- `_dismissPingFocus()` restore behavior (auto-follow, rotation, zoom-back animation) +- Transparent barrier color on the full sheet when expanded +- Swipe-down on sheet still closes and exits focus (unchanged) + +## Implementation Details + +### New State in `_MapWidgetState` + +```dart +bool _focusPanelMinimized = false; +dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry +``` + +### Modified Methods + +**`_activatePingFocus()`:** +- If `_focusedPingLocation != null` (already in focus): skip saving pre-focus state, skip auto-follow/rotation changes — just update the focused ping/repeaters and zoom to new bounds +- Clear `_focusPanelMinimized = false` on activation + +**`_dismissPingFocus()`:** +- Additionally clears `_focusPanelMinimized = false` and `_focusedPingSource = null` + +**`_show{Tx,Rx,Disc,Trace}Details()`:** +- Store the ping/entry in `_focusedPingSource` before showing the sheet +- Accept optional `{bool fromMinimized = false}` parameter — when true, skip `_activatePingFocus` call (focus is already active) +- Add minimize `IconButton` to header row +- Change `.whenComplete(() => _dismissPingFocus())` to `.then((result) { ... })`: + - If `result == 'minimized'`: `setState(() => _focusPanelMinimized = true)` + - Otherwise: `_dismissPingFocus()` + +**Minimize button action:** `Navigator.pop(context, 'minimized')` + +### New Methods + +**`_buildMinimizedFocusPanel()`:** +- Returns the pill widget +- Derives title/icon/color from `_focusedPingSource` runtime type +- Repeater count from `_focusedRepeaters.length` +- Timestamp from `_focusedPingTimestamp` + +**`_reshowFocusPanel()`:** +- Checks `_focusedPingSource` type, calls the matching `_show*Details(source, fromMinimized: true)` +- Sets `_focusPanelMinimized = false` via setState + +### Build Method Change + +In the outer `Stack` (line ~1198), add after map controls: + +```dart +if (_focusPanelMinimized && _focusedPingLocation != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: _buildMinimizedFocusPanel(), + ), +``` + +### Edge Cases + +- **User taps a different ping while minimized:** `_handleSymbolTap` calls `_show*Details` for the new ping. `_activatePingFocus` detects existing focus, updates focus state without re-saving pre-focus. The new sheet opens, minimized state is cleared. +- **Auto-reconnect during minimized state:** Disconnect cleanup calls `_dismissPingFocus()` which clears everything including minimized state. +- **Orientation change while minimized:** Pill repositions naturally via `Positioned` + safe area insets. + +## Files Modified + +- `lib/widgets/map_widget.dart` — all changes are in this single file + +## Testing + +- Tap TX/RX/Disc/Trace ping → verify minimize button visible in header +- Tap minimize → verify pill appears, map is pannable/zoomable, focus lines stay +- Tap expand on pill → verify full sheet re-opens without re-zooming +- Tap close on pill → verify focus mode fully dismisses, map restores +- While minimized, tap a different ping → verify new focus replaces old +- Swipe-down on full sheet → verify still exits focus mode (unchanged) From e13e5f858fdb0698cc7fe7f90a6a122aa71bdb5a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:50:29 -0400 Subject: [PATCH 106/360] Add implementation plan for focus mode minimize feature 9-task plan covering state fields, _activatePingFocus re-activation, minimize buttons in all 4 detail sheets, minimized pill widget, and Stack integration. --- .../plans/2026-05-17-focus-mode-minimize.md | 822 ++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-focus-mode-minimize.md diff --git a/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md b/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md new file mode 100644 index 00000000..1ac975a6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md @@ -0,0 +1,822 @@ +# Focus Mode Minimize Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to minimize the focus mode ping details popup to a compact pill, giving full map zoom/pan access while keeping focus lines visible. + +**Architecture:** All changes are in `lib/widgets/map_widget.dart`. Two new state fields (`_focusPanelMinimized`, `_focusedPingSource`) track minimized state. The 4 existing `_show*Details` methods gain a minimize button and `fromMinimized` parameter. A new `_buildMinimizedFocusPanel()` method renders the pill in the map's outer `Stack`. `_activatePingFocus` and `_dismissPingFocus` are updated to manage the new state. + +**Tech Stack:** Flutter, MapLibre GL, Provider + +--- + +### Task 1: Add state fields and update `_activatePingFocus` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:382` (state fields) +- Modify: `lib/widgets/map_widget.dart:5357-5413` (`_activatePingFocus`) + +- [ ] **Step 1: Add new state fields** + +After line 382 (`bool _wasRotatingBeforeFocus = false;`), add: + +```dart + bool _focusPanelMinimized = false; + dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry +``` + +- [ ] **Step 2: Update `_activatePingFocus` to handle re-activation** + +Replace the entire `_activatePingFocus` method (lines 5357-5413) with: + +```dart + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { + // Drop repeaters lacking GPS — they would draw lines off to (0, 0). + // The bottom-sheet row builder still surfaces them with a no-location + // icon. If nothing is left to focus on, skip activation entirely so + // the user's current map view (zoom, autofollow, rotation) is kept. + final located = + repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); + if (located.isEmpty) return; + + // Only save pre-focus state on first activation. When re-activating + // (e.g. user taps a different ping while already in focus, or expanding + // from minimized), we keep the original pre-focus snapshot so dismiss + // restores the correct camera position. + final alreadyInFocus = _focusedPingLocation != null; + if (!alreadyInFocus) { + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + + if (_autoFollow) { + _autoFollow = false; + } + + // Lock to north-up during focus so the zoom-to-fit view is stable + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } + } + } + + _focusPanelMinimized = false; + + setState(() { + _focusedPingLocation = pingLocation; + _focusedPingTimestamp = timestamp; + _focusedRepeaters = located; + }); + + // Hide the MeshMapper coverage raster overlay for a clean focus view. + // Uses opacity=0 rather than removing the layer to avoid a tile refetch + // on dismiss. No-ops gracefully if the layer isn't present. + _applyCoverageOverlayOpacity(0.0); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _focusedPingLocation != null) { + _zoomToFocusBounds(pingLocation, located); + } + }); + + // Once the 500ms zoom-to-fit animation settles, re-flow the distance + // labels so any that collide on screen slide along their lines to a + // non-overlapping slot. 600ms gives the camera a bit of buffer beyond + // the animation duration. + Future.delayed(const Duration(milliseconds: 600), () { + if (!mounted || _focusedPingLocation == null) return; + _reflowDistanceLabelsForCollisions(); + }); + } +``` + +- [ ] **Step 3: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors (existing warnings are OK) + +- [ ] **Step 4: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add focus panel state fields and update _activatePingFocus for re-activation" +``` + +--- + +### Task 2: Update `_dismissPingFocus` to clear new state + +**Files:** +- Modify: `lib/widgets/map_widget.dart:5417-5461` (`_dismissPingFocus`) + +- [ ] **Step 1: Update `_dismissPingFocus`** + +Replace lines 5417-5461 (the full `_dismissPingFocus` method) with: + +```dart + void _dismissPingFocus() { + if (_focusedPingLocation == null || !mounted) return; + + final center = _preFocusCenter; + final zoom = _preFocusZoom; + final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; + final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; + + // Clear focus state but do NOT restore auto-follow or rotation yet — + // they would immediately trigger animations in the build method that + // override our zoom-back animation (both share _animationController). + setState(() { + _focusedPingLocation = null; + _focusedPingTimestamp = null; + _focusedRepeaters = []; + _focusPanelMinimized = false; + _focusedPingSource = null; + }); + + // Restore the MeshMapper coverage raster overlay opacity. Safe if the + // layer was hidden via the toggle during focus — setLayerProperties is + // wrapped in try/catch inside the helper. + final appState = context.read(); + _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); + + if (center != null && zoom != null) { + _animateToPositionWithZoom(center, zoom); + + // Restore auto-follow and heading rotation after the zoom-back + // animation completes (500ms) so they don't clobber it mid-flight. + if (shouldRestoreAutoFollow || shouldRestoreRotation) { + Future.delayed(const Duration(milliseconds: 550), () { + if (mounted) { + setState(() { + if (shouldRestoreAutoFollow) _autoFollow = true; + if (shouldRestoreRotation) _alwaysNorth = false; + }); + } + }); + } + } else { + setState(() { + if (shouldRestoreAutoFollow) _autoFollow = true; + if (shouldRestoreRotation) _alwaysNorth = false; + }); + } + } +``` + +- [ ] **Step 2: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: update _dismissPingFocus to clear minimized state" +``` + +--- + +### Task 3: Add minimize button to `_showTxPingDetails` and change completion handler + +**Files:** +- Modify: `lib/widgets/map_widget.dart:5582-5908` (`_showTxPingDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** + +Replace the method signature and pre-sheet logic (lines 5582-5599): + +```dart + void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { + // Use the heardRepeaters directly from the TxPing + final heardRepeaters = ping.heardRepeaters; + + // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) + final resolved = heardRepeaters.isNotEmpty + ? _resolveRepeatersByHexIds( + heardRepeaters.map((r) => r.repeaterId).toList(), + snrValues: heardRepeaters.map((r) => r.snr).toList(), + ) + : <_ResolvedRepeater>[]; + final hasAmbiguous = resolved.any((r) => r.ambiguous); + + _focusedPingSource = ping; + + // Activate focus mode if the ping was heard by known repeaters + if (!fromMinimized && resolved.isNotEmpty) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the TX sheet (around line 5665): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 5907): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately after the TX sheet builder's closing brackets, before `_showRxPingDetails`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to TX ping details sheet" +``` + +--- + +### Task 4: Add minimize button to `_showRxPingDetails` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:5911-6193` (`_showRxPingDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** + +Replace the method signature and pre-sheet logic (lines 5911-5923): + +```dart + void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); + + // Activate focus mode for the RX ping's repeater + final resolved = _resolveRepeatersByHexIds( + [ping.repeaterId], + snrValues: [ping.snr], + ); + + _focusedPingSource = ping; + + if (!fromMinimized && resolved.isNotEmpty) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the RX sheet (around line 5978): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 6193): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately before `_showDiscPingDetails`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to RX ping details sheet" +``` + +--- + +### Task 5: Add minimize button to `_showDiscPingDetails` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:6197-6512` (`_showDiscPingDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** + +Replace the method signature and pre-sheet logic (lines 6197-6210): + +```dart + void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { + // Activate focus mode for discovered nodes with known repeater positions + _focusedPingSource = entry; + + if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { + final resolved = _resolveRepeatersByHexIds( + entry.discoveredNodes.map((n) => n.repeaterId).toList(), + fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + ); + if (resolved.isNotEmpty) { + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + } + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the Disc sheet (around line 6276): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 6512): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately before `_buildRepeaterStatusChip`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to Disc ping details sheet" +``` + +--- + +### Task 6: Add minimize button to `_showTraceDetails` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:4971-5275` (`_showTraceDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** + +Replace the method signature and pre-sheet logic (lines 4971-4982): + +```dart + void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { + // Activate focus mode for successful traces with a known repeater + _focusedPingSource = entry; + + if (!fromMinimized && entry.success) { + final resolved = _resolveRepeatersByHexIds( + [entry.targetRepeaterId], + snrValues: [entry.localSnr], + ); + if (resolved.isNotEmpty) { + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + } + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the Trace sheet (around line 5047): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 5275): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately before `/// DISC marker color`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to Trace details sheet" +``` + +--- + +### Task 7: Add `_buildMinimizedFocusPanel` and `_reshowFocusPanel` methods + +**Files:** +- Modify: `lib/widgets/map_widget.dart` (add new methods near `_dismissPingFocus`, around line 5462) + +- [ ] **Step 1: Add `_reshowFocusPanel` method** + +Insert after the closing brace of `_dismissPingFocus()` (which ends with `}`): + +```dart + void _reshowFocusPanel() { + setState(() => _focusPanelMinimized = false); + final source = _focusedPingSource; + if (source is TxPing) { + _showTxPingDetails(source, fromMinimized: true); + } else if (source is RxPing) { + _showRxPingDetails(source, fromMinimized: true); + } else if (source is DiscLogEntry) { + _showDiscPingDetails(source, fromMinimized: true); + } else if (source is TraceLogEntry) { + _showTraceDetails(source, fromMinimized: true); + } + } +``` + +- [ ] **Step 2: Add `_buildMinimizedFocusPanel` method** + +Insert immediately after `_reshowFocusPanel`: + +```dart + Widget _buildMinimizedFocusPanel() { + final source = _focusedPingSource; + String title; + IconData icon; + Color color; + if (source is TxPing) { + title = 'TX Ping'; + icon = Icons.arrow_upward; + color = PingColors.txSuccess; + } else if (source is RxPing) { + title = 'RX Ping'; + icon = Icons.arrow_downward; + color = PingColors.rx; + } else if (source is DiscLogEntry) { + title = 'Disc Request'; + icon = Icons.radar; + color = PingColors.discSuccess; + } else if (source is TraceLogEntry) { + title = 'Trace'; + icon = Icons.gps_fixed; + color = Colors.cyan; + } else { + return const SizedBox.shrink(); + } + + final repeaterCount = _focusedRepeaters.length; + final timeStr = + _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; + + return GestureDetector( + onTap: _reshowFocusPanel, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + timeStr, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (repeaterCount > 0) ...[ + const SizedBox(width: 8), + Text( + '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(width: 8), + GestureDetector( + onTap: _reshowFocusPanel, + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: _dismissPingFocus, + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +``` + +- [ ] **Step 3: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 4: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimized focus panel pill and reshow logic" +``` + +--- + +### Task 8: Add minimized pill to the outer Stack in `build()` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:1198-1246` (outer Stack in `build()`) + +- [ ] **Step 1: Add minimized pill to Stack** + +Find the tile load failure banner block (around line 1234-1244): + +```dart + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + ], + ); +``` + +Replace with: + +```dart + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + + // Minimized focus panel pill — shown when user minimizes a ping + // details sheet. Not a modal, so the map underneath stays fully + // interactable (zoom, pan, rotation). + if (_focusPanelMinimized && _focusedPingLocation != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildMinimizedFocusPanel(), + ), + ), + ], + ); +``` + +- [ ] **Step 2: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: render minimized focus pill in map Stack" +``` + +--- + +### Task 9: Final verification + +**Files:** +- Verify: `lib/widgets/map_widget.dart` + +- [ ] **Step 1: Run full static analysis** + +Run: `flutter analyze` +Expected: No new errors introduced. Pre-existing warnings are acceptable. + +- [ ] **Step 2: Run tests** + +Run: `flutter test` +Expected: All existing tests pass. + +- [ ] **Step 3: Final commit (squash-friendly message)** + +Only if there were any fixups needed from analysis/tests: + +```bash +git add lib/widgets/map_widget.dart +git commit -m "fix: address analysis warnings from focus minimize feature" +``` From 918b78ba6c521119026189f6b2185a7b7c4244e9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:54:22 -0400 Subject: [PATCH 107/360] feat: add focus panel state fields, update _activatePingFocus and _dismissPingFocus - Added _focusPanelMinimized and _focusedPingSource state fields - _activatePingFocus now skips pre-focus state snapshot when re-activating (already in focus), preserving the original camera position for dismiss; always resets _focusPanelMinimized to false on activation - _dismissPingFocus clears _focusPanelMinimized and _focusedPingSource in its setState block --- lib/widgets/map_widget.dart | 49 +++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index b8ff36c3..e2f8ddeb 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -380,6 +380,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { double? _preFocusZoom; bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active + bool _focusPanelMinimized = false; + dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry // MapLibre style and overlay tracking int _lastCacheBust = 0; @@ -5364,28 +5366,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); if (located.isEmpty) return; - final pos = _mapController?.cameraPosition; - _preFocusCenter = pos?.target; - _preFocusZoom = pos?.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; - } + // Only save pre-focus state on first activation. When re-activating + // (e.g. user taps a different ping while already in focus, or expanding + // from minimized), we keep the original pre-focus snapshot so dismiss + // restores the correct camera position. + final alreadyInFocus = _focusedPingLocation != null; + if (!alreadyInFocus) { + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + + if (_autoFollow) { + _autoFollow = false; + } - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null && _canAnimateCamera) { - _mapController!.animateCamera( - CameraUpdate.bearingTo(0), - duration: const Duration(milliseconds: 1), - ); + // Lock to north-up during focus so the zoom-to-fit view is stable + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } } } + _focusPanelMinimized = false; + setState(() { _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; @@ -5429,6 +5440,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusedPingLocation = null; _focusedPingTimestamp = null; _focusedRepeaters = []; + _focusPanelMinimized = false; + _focusedPingSource = null; }); // Restore the MeshMapper coverage raster overlay opacity. Safe if the From 417d2ae0db2b4a29003d88b3ae9741aeb21ce9e1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:56:15 -0400 Subject: [PATCH 108/360] feat: add minimize button to TX ping details sheet Adds fromMinimized parameter to _showTxPingDetails, stores _focusedPingSource, guards _activatePingFocus on fromMinimized, and adds minimize/close buttons with .then() handler to set _focusPanelMinimized state. --- lib/widgets/map_widget.dart | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e2f8ddeb..c67cb96c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -5592,7 +5592,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Show TX ping details popup - void _showTxPingDetails(TxPing ping) { + void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; @@ -5605,8 +5605,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : <_ResolvedRepeater>[]; final hasAmbiguous = resolved.any((r) => r.ambiguous); + _focusedPingSource = ping; + // Activate focus mode if the ping was heard by known repeaters - if (resolved.isNotEmpty) { + if (!fromMinimized && resolved.isNotEmpty) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } @@ -5675,6 +5677,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -5917,7 +5927,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Show RX ping details popup From d835cf384f71db778876cf4c7e2c84cd19a423d7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 14:07:49 -0400 Subject: [PATCH 109/360] Add minimized focus panel pill and wire into map Stack Adds _reshowFocusPanel() dispatch and _buildMinimizedFocusPanel() pill widget. Pill shows in the map Stack when user taps the minimize button, giving full map zoom/pan access while keeping focus lines. --- lib/widgets/map_widget.dart | 196 ++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 9 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index c67cb96c..32882ca7 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1244,6 +1244,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildTileLoadFailedBanner(), ), ), + + // Minimized focus panel pill — shown when user minimizes a ping + // details sheet. Not a modal, so the map underneath stays fully + // interactable (zoom, pan, rotation). + if (_focusPanelMinimized && _focusedPingLocation != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildMinimizedFocusPanel(), + ), + ), ], ); } @@ -4970,9 +4983,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusedPingLocation!.longitude == lon; } - void _showTraceDetails(TraceLogEntry entry) { + void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { // Activate focus mode for successful traces with a known repeater - if (entry.success) { + _focusedPingSource = entry; + + if (!fromMinimized && entry.success) { final resolved = _resolveRepeatersByHexIds( [entry.targetRepeaterId], snrValues: [entry.localSnr], @@ -5046,6 +5061,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -5274,7 +5297,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// DISC marker color (delegates to active palette) @@ -5473,6 +5502,122 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + void _reshowFocusPanel() { + setState(() => _focusPanelMinimized = false); + final source = _focusedPingSource; + if (source is TxPing) { + _showTxPingDetails(source, fromMinimized: true); + } else if (source is RxPing) { + _showRxPingDetails(source, fromMinimized: true); + } else if (source is DiscLogEntry) { + _showDiscPingDetails(source, fromMinimized: true); + } else if (source is TraceLogEntry) { + _showTraceDetails(source, fromMinimized: true); + } + } + + Widget _buildMinimizedFocusPanel() { + final source = _focusedPingSource; + String title; + IconData icon; + Color color; + if (source is TxPing) { + title = 'TX Ping'; + icon = Icons.arrow_upward; + color = PingColors.txSuccess; + } else if (source is RxPing) { + title = 'RX Ping'; + icon = Icons.arrow_downward; + color = PingColors.rx; + } else if (source is DiscLogEntry) { + title = 'Disc Request'; + icon = Icons.radar; + color = PingColors.discSuccess; + } else if (source is TraceLogEntry) { + title = 'Trace'; + icon = Icons.gps_fixed; + color = Colors.cyan; + } else { + return const SizedBox.shrink(); + } + + final repeaterCount = _focusedRepeaters.length; + final timeStr = + _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; + + return GestureDetector( + onTap: _reshowFocusPanel, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + timeStr, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (repeaterCount > 0) ...[ + const SizedBox(width: 8), + Text( + '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(width: 8), + GestureDetector( + onTap: _reshowFocusPanel, + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: _dismissPingFocus, + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + Set _getDuplicateRepeaterIds(List repeaters) { final idCounts = {}; for (final repeater in repeaters) { @@ -5937,7 +6082,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Show RX ping details popup - void _showRxPingDetails(RxPing ping) { + void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { final snrColor = PingColors.snrColor(ping.snr); final rssiColor = PingColors.rssiColor(ping.rssi); @@ -5946,7 +6091,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { [ping.repeaterId], snrValues: [ping.snr], ); - if (resolved.isNotEmpty) { + + _focusedPingSource = ping; + + if (!fromMinimized && resolved.isNotEmpty) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } @@ -6004,6 +6152,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -6219,13 +6375,21 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Show DISC ping details popup - void _showDiscPingDetails(DiscLogEntry entry) { + void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { // Activate focus mode for discovered nodes with known repeater positions - if (entry.discoveredNodes.isNotEmpty) { + _focusedPingSource = entry; + + if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), @@ -6302,6 +6466,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -6538,7 +6710,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Build a status chip for the repeater popup From af2e771fd630a3daf7066629969cff6411ad73e7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 14:10:34 -0400 Subject: [PATCH 110/360] Hide control panel during focus mode so minimized pill is visible Adds isFocusModeActive to AppStateProvider, set by _activatePingFocus and cleared by _dismissPingFocus. Home screen hides the control panel (portrait and landscape) when focus mode is active, giving the minimized pill clear space and maximizing map visibility. --- lib/providers/app_state_provider.dart | 9 +++++++++ lib/screens/home_screen.dart | 12 ++++++------ lib/widgets/map_widget.dart | 3 +++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c2ac4461..b0e92fba 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -600,6 +600,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? get zoneTransferFrom => _zoneTransferFrom; String? get zoneTransferTo => _zoneTransferTo; + // Focus mode (map ping detail sheet active) + bool _isFocusModeActive = false; + bool get isFocusModeActive => _isFocusModeActive; + set isFocusModeActive(bool value) { + if (_isFocusModeActive != value) { + _isFocusModeActive = value; + notifyListeners(); + } + } // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index bd65adc8..2ffd4267 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -424,7 +424,7 @@ class _HomeScreenState extends State { if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( child: MapWidget( - bottomPaddingPixels: isLandscape ? 0 : _getControlPanelHeight(), + bottomPaddingPixels: isLandscape || appState.isFocusModeActive ? 0 : _getControlPanelHeight(), mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, onMapControlsToggle: isLandscape ? _toggleMapControls : null, ), @@ -476,8 +476,8 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel - if (!isLandscape) + // Portrait: bottom control panel (hidden during focus mode) + if (!isLandscape && !appState.isFocusModeActive) Positioned( bottom: 0, left: 0, @@ -487,14 +487,14 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB - if (isLandscape && _showControlPanel) + // Landscape: side control panel or FAB (hidden during focus mode) + if (isLandscape && _showControlPanel && !appState.isFocusModeActive) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel) + if (isLandscape && !_showControlPanel && !appState.isFocusModeActive) Positioned( bottom: 16, left: leftInset, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 32882ca7..1bbdc273 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -5401,6 +5401,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // restores the correct camera position. final alreadyInFocus = _focusedPingLocation != null; if (!alreadyInFocus) { + context.read().isFocusModeActive = true; final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; @@ -5457,6 +5458,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void _dismissPingFocus() { if (_focusedPingLocation == null || !mounted) return; + context.read().isFocusModeActive = false; + final center = _preFocusCenter; final zoom = _preFocusZoom; final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; From ef1d2c8652f21bbae618c4a1ca2f31146aef9c8a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 20:29:08 -0400 Subject: [PATCH 111/360] feat: enhance repeater ID display with ambiguity indicator and update UI elements --- lib/screens/log_screen.dart | 49 ++++++++++++++++++------------- lib/screens/main_scaffold.dart | 10 +++---- lib/screens/settings_screen.dart | 4 +++ lib/widgets/repeater_id_chip.dart | 14 ++++++--- 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 16a88b33..d84db19f 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -340,10 +340,6 @@ class _AllPingsTabState extends State<_AllPingsTab> { } /// True if the query looks like a hex string (only 0-9, a-f). - static bool _isHexQuery(String query) { - return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); - } - /// Whether an entry matches the current search query. bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty) return true; @@ -392,8 +388,6 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. bool _shouldShowAmbiguity( UnifiedPingLogEntry entry, List repeaters) { - if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; - switch (entry.type) { case PingLogType.tx: return entry.asTx.events @@ -706,7 +700,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), - _buildRepeaterTable(context, entry.events), + _buildRepeaterTable(context, entry.events, widget.repeaters), ] else ...[ const SizedBox(height: 8), Text( @@ -725,7 +719,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildRepeaterTable(BuildContext context, List events) { + Widget _buildRepeaterTable( + BuildContext context, List events, List repeaters) { var maxIdLen = 0; for (final e in events) { if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; @@ -753,17 +748,18 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...events.map( - (event) => _buildTxRepeaterRow(context, event, nodeWidth)), + ...events.map((event) => + _buildTxRepeaterRow(context, event, nodeWidth, repeaters)), ], ), ); } - Widget _buildTxRepeaterRow( - BuildContext context, RxEvent event, double nodeWidth) { + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event, + double nodeWidth, List repeaters) { final snrColor = _snrColor(event.severity); final rssiColor = _rssiColor(event.rssi); + final isAmbiguous = _isAmbiguousId(event.repeaterId, repeaters); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), child: Padding( @@ -773,7 +769,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: event.repeaterId, fontSize: 14, - width: nodeWidth), + width: nodeWidth, + isAmbiguous: isAmbiguous), Expanded( child: Center( child: _buildChip( @@ -799,6 +796,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); final nodeWidth = _nodeColumnWidthForLength(entry.repeaterId.length); + final isAmbiguous = _isAmbiguousId(entry.repeaterId, widget.repeaters); return Card( margin: const EdgeInsets.only(bottom: 8), @@ -857,7 +855,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: entry.repeaterId, fontSize: 14, - width: nodeWidth), + width: nodeWidth, + isAmbiguous: isAmbiguous), Expanded( child: Center( child: _buildChip( @@ -968,8 +967,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes - .map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => + _buildDiscNodeRow(context, node, widget.repeaters)), ], ), ), @@ -991,10 +990,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + Widget _buildDiscNodeRow( + BuildContext context, DiscoveredNodeEntry node, List repeaters) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final isAmbiguous = _isAmbiguousId(node.repeaterId, repeaters); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, @@ -1009,7 +1010,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { children: [ Flexible( child: RepeaterIdChip( - repeaterId: node.repeaterId, fontSize: 14)), + repeaterId: node.repeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Text( node.nodeTypeLabel, style: TextStyle( @@ -1094,7 +1097,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - _buildTraceNodeRow(context, entry), + _buildTraceNodeRow(context, entry, widget.repeaters), ], ), ), @@ -1116,10 +1119,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + Widget _buildTraceNodeRow( + BuildContext context, TraceLogEntry entry, List repeaters) { final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); final rssiColor = _rssiColor(entry.localRssi); final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); + final isAmbiguous = _isAmbiguousId(entry.targetRepeaterId, repeaters); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -1128,7 +1133,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { SizedBox( width: 70, child: RepeaterIdChip( - repeaterId: entry.targetRepeaterId, fontSize: 14)), + repeaterId: entry.targetRepeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Expanded( child: Center( child: _buildChip( diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index b080be78..27815d73 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -342,8 +342,8 @@ class _MainScaffoldState extends State { showBadge: appState.errorLogEntries.isNotEmpty, ), _buildCompactNavItem( - icon: Icons.show_chart_outlined, - activeIcon: Icons.show_chart, + icon: Icons.history_outlined, + activeIcon: Icons.history, index: 2, ), _buildCompactNavItem( @@ -433,9 +433,9 @@ class _MainScaffoldState extends State { label: 'Log', ), const BottomNavigationBarItem( - icon: Icon(Icons.show_chart_outlined), - activeIcon: Icon(Icons.show_chart), - label: 'Graph', + icon: Icon(Icons.history_outlined), + activeIcon: Icon(Icons.history), + label: 'History', ), BottomNavigationBarItem( icon: Icon( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b0f16273..11e19cd5 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2215,6 +2215,10 @@ class _SettingsScreenState extends State { message = 'Another upload is already in progress'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.zoneDisabled: + message = 'Upload failed - wardriving is disabled in this zone'; + backgroundColor = Colors.red; + break; } ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 533990a5..32371b8f 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -23,11 +23,15 @@ class RepeaterIdChip extends StatelessWidget { /// Optional SizedBox width constraint (e.g., 50 or 60) final double? width; + /// When true, the info icon is rendered in amber to flag an ambiguous ID. + final bool isAmbiguous; + const RepeaterIdChip({ super.key, required this.repeaterId, this.fontSize = 11, this.width, + this.isAmbiguous = false, }); @override @@ -57,10 +61,12 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.info_outline, size: fontSize - 1, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.5), + color: isAmbiguous + ? const Color(0xFFF59E0B) + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ), ], ); From 415f11dc1112fdce383bd2114043f85afd1ffa93 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 21:25:02 -0400 Subject: [PATCH 112/360] - Session History: The "Graph" tab is now "History." View past sessions on the map with ping markers and camera fit-to-bounds, or open the Noise Floor graph. Tapping history markers opens the same detail sheets as live sessions. --- lib/providers/app_state_provider.dart | 31 +++ lib/screens/graph_screen.dart | 253 +++++++++++------ lib/screens/home_screen.dart | 16 +- lib/widgets/map_widget.dart | 376 +++++++++++++++++++++----- 4 files changed, 525 insertions(+), 151 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b0e92fba..c0d7a590 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -379,6 +379,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List _storedNoiseFloorSessions = []; Box? _noiseFloorSessionBox; + // History session map view + List? _historySessionMarkers; + bool _viewingHistorySession = false; + // Flag to track if preferences have been loaded from storage bool _preferencesLoaded = false; @@ -630,6 +634,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get storedNoiseFloorSessions => List.unmodifiable(_storedNoiseFloorSessions); + // History session map view getters + List? get historySessionMarkers => _historySessionMarkers; + bool get viewingHistorySession => _viewingHistorySession; + // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; bool get isTxSoundEnabled => _audioService.isTxEnabled; @@ -7013,6 +7021,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Show a historical session's ping markers on the map + void viewHistorySessionOnMap(NoiseFloorSession session) { + final markers = session.markers + .where((m) => m.latitude != null && m.longitude != null) + .toList(); + if (markers.isEmpty) return; + + _historySessionMarkers = markers; + _viewingHistorySession = true; + _requestMapTabSwitch = true; + debugLog('[GRAPH] Viewing session on map: ${markers.length} markers'); + notifyListeners(); + } + + /// Dismiss the history session map view + void clearHistorySession() { + if (!_viewingHistorySession) return; + _historySessionMarkers = null; + _viewingHistorySession = false; + debugLog('[GRAPH] Cleared history session map view'); + notifyListeners(); + } + // ============================================ // Cleanup // ============================================ diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 623520de..6fe8e08f 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -7,7 +7,7 @@ import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; import '../widgets/noise_floor_chart.dart'; -/// Screen showing noise floor session history and graph popup +/// Screen showing session history with options to view on map or noise floor graph class GraphScreen extends StatelessWidget { const GraphScreen({super.key}); @@ -20,8 +20,7 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: - const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: const Text('Session History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) @@ -32,16 +31,16 @@ class GraphScreen extends StatelessWidget { ), ], ), - body: _buildBody(context, currentSession, sessions), + body: _buildBody(context, appState, currentSession, sessions), ); } Widget _buildBody( BuildContext context, + AppStateProvider appState, NoiseFloorSession? currentSession, List sessions, ) { - // Show empty state if no sessions if (currentSession == null && sessions.isEmpty) { return Center( child: Padding( @@ -50,7 +49,7 @@ class GraphScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.show_chart, + Icons.history, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -61,7 +60,7 @@ class GraphScreen extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Enable Active or Passive Mode to start recording noise floor data.', + 'Enable Active or Passive Mode to start recording session data.', textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -75,27 +74,33 @@ class GraphScreen extends StatelessWidget { return ListView( children: [ - // Current active session (if recording) if (currentSession != null) ...[ - _SessionListTile( + _SessionCard( session: currentSession, isActive: true, - onTap: () => + onViewGraph: () => _openFullScreenGraph(context, currentSession, isLive: true), + onViewMap: null, ), - if (sessions.isNotEmpty) const Divider(), + if (sessions.isNotEmpty) const SizedBox(height: 4), ], - - // Stored sessions (last 10) - ...sessions.map((session) => _SessionListTile( + ...sessions.map((session) => _SessionCard( session: session, isActive: false, - onTap: () => _openFullScreenGraph(context, session), + onViewGraph: () => _openFullScreenGraph(context, session), + onViewMap: _sessionHasGpsMarkers(session) + ? () => appState.viewHistorySessionOnMap(session) + : null, )), ], ); } + bool _sessionHasGpsMarkers(NoiseFloorSession session) { + return session.markers + .any((m) => m.latitude != null && m.longitude != null); + } + void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, {bool isLive = false}) { Navigator.of(context).push( @@ -112,7 +117,7 @@ class GraphScreen extends StatelessWidget { builder: (context) => AlertDialog( title: const Text('Clear All Sessions?'), content: const Text( - 'This will delete all saved noise floor session graphs. The current active session will not be affected.'), + 'This will delete all saved session history. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -131,6 +136,162 @@ class GraphScreen extends StatelessWidget { } } +/// Card showing a session with action buttons +class _SessionCard extends StatelessWidget { + final NoiseFloorSession session; + final bool isActive; + final VoidCallback onViewGraph; + final VoidCallback? onViewMap; + + const _SessionCard({ + required this.session, + required this.isActive, + required this.onViewGraph, + required this.onViewMap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: mode icon, mode name, live badge, time + Row( + children: [ + Icon( + _modeIcon, + size: 20, + color: isActive ? Colors.green : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + session.modeDisplay, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (isActive) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: const Text( + 'LIVE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ), + ], + const Spacer(), + Text( + _formatDateTime(session.startTime), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 6), + // Stats row + Text( + '${session.durationDisplay} duration | ' + '${session.samples.length} samples | ' + '${session.markers.length} events', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + // Action buttons row + Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.map_outlined, + label: 'View on Map', + onPressed: onViewMap, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + icon: Icons.show_chart, + label: 'Noise Floor', + onPressed: onViewGraph, + ), + ), + ], + ), + ], + ), + ), + ); + } + + IconData get _modeIcon => switch (session.mode) { + 'active' => Icons.send, + 'hybrid' => Icons.swap_horiz, + 'targeted' => Icons.track_changes, + _ => Icons.hearing, + }; + + String _formatDateTime(DateTime dt) { + return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; + } +} + +/// Styled action button for session cards +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + + const _ActionButton({ + required this.icon, + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: const Size(0, 34), + foregroundColor: + onPressed != null ? colorScheme.primary : colorScheme.outline, + side: BorderSide( + color: onPressed != null + ? colorScheme.outline + : colorScheme.outline.withValues(alpha: 0.4), + ), + ), + ); + } +} + /// Full-screen graph page with pinch-to-zoom and pan class _FullScreenGraphPage extends StatefulWidget { final NoiseFloorSession session; @@ -161,7 +322,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = current; }); } else { - // Session ended while viewing _liveTimer?.cancel(); _liveTimer = null; setState(() { @@ -214,7 +374,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { onPressed: () => Navigator.pop(context), ), actions: [ - // Reset zoom button IconButton( icon: const Icon(Icons.zoom_out_map), tooltip: 'Reset zoom', @@ -227,7 +386,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { body: SafeArea( child: Column( children: [ - // Session info header Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -254,7 +412,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), const Divider(height: 1), - // Hint text Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text( @@ -266,7 +423,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), ), - // Chart Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), @@ -287,58 +443,3 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; } } - -/// List tile showing a session summary -class _SessionListTile extends StatelessWidget { - final NoiseFloorSession session; - final bool isActive; - final VoidCallback onTap; - - const _SessionListTile({ - required this.session, - required this.isActive, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon( - session.mode == 'active' ? Icons.send : Icons.hearing, - color: isActive ? Colors.green : null, - ), - title: Text(session.modeDisplay), - subtitle: Text( - '${_formatDateTime(session.startTime)} | ${session.durationDisplay} | ' - '${session.samples.length} samples, ${session.markers.length} events', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - trailing: isActive - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green), - ), - child: const Text( - 'LIVE', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ) - : const Icon(Icons.chevron_right), - onTap: onTap, - ); - } - - String _formatDateTime(DateTime dt) { - return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; - } -} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 2ffd4267..7960b018 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -424,7 +424,7 @@ class _HomeScreenState extends State { if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( child: MapWidget( - bottomPaddingPixels: isLandscape || appState.isFocusModeActive ? 0 : _getControlPanelHeight(), + bottomPaddingPixels: isLandscape || appState.isFocusModeActive || appState.viewingHistorySession ? 0 : _getControlPanelHeight(), mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, onMapControlsToggle: isLandscape ? _toggleMapControls : null, ), @@ -432,8 +432,8 @@ class _HomeScreenState extends State { ], ), - // Landscape: floating status bar overlay - if (isLandscape) + // Landscape: floating status bar overlay (hidden during history view) + if (isLandscape && !appState.viewingHistorySession) Positioned( top: 16, left: leftInset + 72, @@ -476,8 +476,8 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel (hidden during focus mode) - if (!isLandscape && !appState.isFocusModeActive) + // Portrait: bottom control panel (hidden during focus mode and history view) + if (!isLandscape && !appState.isFocusModeActive && !appState.viewingHistorySession) Positioned( bottom: 0, left: 0, @@ -487,14 +487,14 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB (hidden during focus mode) - if (isLandscape && _showControlPanel && !appState.isFocusModeActive) + // Landscape: side control panel or FAB (hidden during focus mode and history view) + if (isLandscape && _showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel && !appState.isFocusModeActive) + if (isLandscape && !_showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) Positioned( bottom: 16, left: leftInset, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1bbdc273..7a31be4c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -12,6 +12,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/noise_floor_session.dart'; import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; @@ -372,6 +373,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Map navigation trigger tracking (from log screen) int _lastNavigationTrigger = 0; + // History session map view tracking + bool _wasViewingHistory = false; + // Ping focus mode — highlight connected repeaters when a marker is tapped LatLng? _focusedPingLocation; DateTime? _focusedPingTimestamp; @@ -1119,6 +1123,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // Handle history session view — fit camera to session bounds on transition + if (_isMapReady && appState.viewingHistorySession && !_wasViewingHistory) { + _wasViewingHistory = true; + final markers = appState.historySessionMarkers; + if (markers != null && markers.isNotEmpty) { + _autoFollow = false; + _alwaysNorth = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _mapController == null) return; + _fitCameraToHistoryMarkers(markers); + }); + } + } else if (!appState.viewingHistorySession && _wasViewingHistory) { + _wasViewingHistory = false; + } + // Sync native annotations whenever marker data changes (provider triggers // a rebuild). The version hash detects changes to ping/repeater counts, // GPS position, focus state, prefs, etc. Native annotations stay in sync @@ -1210,28 +1230,30 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: SizedBox.expand(), ), - // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) - Positioned( - top: topPadding, - left: leftPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildGpsInfoOverlay(appState), - if (appState.preferences.showTopRepeaters) ...[ - const SizedBox(height: 6), - _buildTopRepeatersOverlay(appState), + // GPS Info + Top Repeaters overlay (hidden during history view) + if (!appState.viewingHistorySession) + Positioned( + top: topPadding, + left: leftPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], ], - ], + ), ), - ), - // Map controls - top-right in both orientations, collapsible - Positioned( - top: topPadding, - right: 8, - child: _buildCollapsibleMapControls(appState), - ), + // Map controls (hidden during history view) + if (!appState.viewingHistorySession) + Positioned( + top: topPadding, + right: 8, + child: _buildCollapsibleMapControls(appState), + ), // Tile load failure banner — appears if base tiles haven't finished // loading within ${_tileLoadTimeoutSeconds}s after style load. @@ -1257,6 +1279,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildMinimizedFocusPanel(), ), ), + + // History session pill (bottom, styled like minimized focus panel) + if (appState.viewingHistorySession) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildHistoryBanner(appState), + ), + ), ], ); } @@ -1291,6 +1324,67 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } + /// Pill shown at the bottom of the map when viewing a history session + Widget _buildHistoryBanner(AppStateProvider appState) { + final count = appState.historySessionMarkers?.length ?? 0; + return GestureDetector( + onTap: () => appState.clearHistorySession(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Session History', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + '$count events', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => appState.clearHistorySession(), + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + /// Collapsible map controls (toggle at top, expands downward) Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state @@ -1546,7 +1640,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { .firstOrNull; if (entry != null) _showTraceDetails(entry); break; - // gps, distance-label: not tappable in original — no action + case 'history_tx': + case 'history_rx': + case 'history_disc': + case 'history_trace': + final marker = appState.historySessionMarkers + ?.where((m) => m.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (marker != null) _showHistoryMarkerAsLive(marker); + break; } } @@ -3187,56 +3289,74 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - // TX pings - for (final ping in appState.txPings) { - await syncOne( - type: 'tx', - lat: ping.latitude, - lon: ping.longitude, - ts: ping.timestamp, - success: ping.heardRepeaters.isNotEmpty, - idForMetadata: ping.timestamp.millisecondsSinceEpoch, - ); - } + // When viewing a history session, show only those markers + if (appState.viewingHistorySession && + appState.historySessionMarkers != null) { + for (final marker in appState.historySessionMarkers!) { + if (marker.latitude == null || marker.longitude == null) continue; + final mapping = _historyMarkerType(marker.type); + await syncOne( + type: 'history_${mapping.type}', + lat: marker.latitude!, + lon: marker.longitude!, + ts: marker.timestamp, + success: mapping.success, + idForMetadata: marker.timestamp.millisecondsSinceEpoch, + iconImageOverride: _MapImages.coverage(mapping.type, mapping.success), + ); + } + } else { + // TX pings + for (final ping in appState.txPings) { + await syncOne( + type: 'tx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: ping.heardRepeaters.isNotEmpty, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } - // RX pings - for (final ping in appState.rxPings) { - await syncOne( - type: 'rx', - lat: ping.latitude, - lon: ping.longitude, - ts: ping.timestamp, - success: true, // RX has no fail state — always uses the rx color - idForMetadata: ping.timestamp.millisecondsSinceEpoch, - ); - } + // RX pings + for (final ping in appState.rxPings) { + await syncOne( + type: 'rx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: true, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } - // DISC entries (success = received node responses; drop = treat as TX fail) - for (final entry in appState.discLogEntries) { - final received = entry.nodeCount > 0; - final renderAsTxFail = !received && appState.discDropEnabled; - await syncOne( - type: 'disc', - lat: entry.latitude, - lon: entry.longitude, - ts: entry.timestamp, - success: received, - idForMetadata: entry.timestamp.millisecondsSinceEpoch, - iconImageOverride: - renderAsTxFail ? _MapImages.coverage('tx', false) : null, - ); - } + // DISC entries (success = received node responses; drop = treat as TX fail) + for (final entry in appState.discLogEntries) { + final received = entry.nodeCount > 0; + final renderAsTxFail = !received && appState.discDropEnabled; + await syncOne( + type: 'disc', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: received, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + iconImageOverride: + renderAsTxFail ? _MapImages.coverage('tx', false) : null, + ); + } - // Trace entries - for (final entry in appState.traceLogEntries) { - await syncOne( - type: 'trace', - lat: entry.latitude, - lon: entry.longitude, - ts: entry.timestamp, - success: entry.success, - idForMetadata: entry.timestamp.millisecondsSinceEpoch, - ); + // Trace entries + for (final entry in appState.traceLogEntries) { + await syncOne( + type: 'trace', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: entry.success, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } } // Remove symbols for pings that no longer exist (e.g., user cleared markers) @@ -3886,6 +4006,59 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _syncDistanceLabels(appState); } + /// Fit camera to show all history session markers + void _fitCameraToHistoryMarkers(List markers) { + final pts = markers + .where((m) => m.latitude != null && m.longitude != null) + .toList(); + if (pts.isEmpty) return; + + if (pts.length == 1) { + _animateToPositionWithZoom( + LatLng(pts.first.latitude!, pts.first.longitude!), + 16.0 - _zoomEpsilon, + ); + return; + } + + double minLat = pts.first.latitude!; + double maxLat = pts.first.latitude!; + double minLon = pts.first.longitude!; + double maxLon = pts.first.longitude!; + for (final p in pts) { + if (p.latitude! < minLat) minLat = p.latitude!; + if (p.latitude! > maxLat) maxLat = p.latitude!; + if (p.longitude! < minLon) minLon = p.longitude!; + if (p.longitude! > maxLon) maxLon = p.longitude!; + } + + _mapController!.animateCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ), + left: 60, + top: 60, + right: 60, + bottom: 60, + ), + duration: const Duration(milliseconds: 500), + ); + } + + /// Map PingEventType to coverage marker type and success state + static ({String type, bool success}) _historyMarkerType(PingEventType t) => + switch (t) { + PingEventType.txSuccess => (type: 'tx', success: true), + PingEventType.txFail => (type: 'tx', success: false), + PingEventType.rx => (type: 'rx', success: true), + PingEventType.discSuccess => (type: 'disc', success: true), + PingEventType.discFail => (type: 'disc', success: false), + PingEventType.traceSuccess => (type: 'trace', success: true), + PingEventType.traceFail => (type: 'trace', success: false), + }; + /// Compute a version hash of all data that affects the marker list. /// When this changes, the cached marker list is rebuilt; otherwise it's reused /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). @@ -3920,6 +4093,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.preferences.markerStyle, txEchoTotal, discNodeTotal, + appState.viewingHistorySession, + appState.historySessionMarkers?.length ?? 0, ); } @@ -5621,6 +5796,73 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } + void _showHistoryMarkerAsLive(PingEventMarker marker) { + if (marker.latitude == null || marker.longitude == null) return; + final lat = marker.latitude!; + final lon = marker.longitude!; + final repeaters = marker.repeaters ?? []; + + switch (marker.type) { + case PingEventType.txSuccess: + case PingEventType.txFail: + _showTxPingDetails(TxPing( + latitude: lat, + longitude: lon, + timestamp: marker.timestamp, + power: 0, + deviceId: '', + heardRepeaters: repeaters + .map((r) => HeardRepeater( + repeaterId: r.repeaterId, + snr: r.snr, + rssi: r.rssi, + )) + .toList(), + )); + case PingEventType.rx: + final r = repeaters.isNotEmpty ? repeaters.first : null; + _showRxPingDetails(RxPing( + latitude: lat, + longitude: lon, + timestamp: marker.timestamp, + repeaterId: r?.repeaterId ?? '', + snr: r?.snr ?? 0, + rssi: r?.rssi ?? 0, + )); + case PingEventType.discSuccess: + case PingEventType.discFail: + _showDiscPingDetails(DiscLogEntry( + timestamp: marker.timestamp, + latitude: lat, + longitude: lon, + noiseFloor: marker.noiseFloor, + discoveredNodes: repeaters + .map((r) => DiscoveredNodeEntry( + repeaterId: r.repeaterId, + nodeType: 'REPEATER', + localSnr: r.snr, + localRssi: r.rssi, + remoteSnr: 0, + pubkeyHex: r.pubkeyHex, + )) + .toList(), + )); + case PingEventType.traceSuccess: + case PingEventType.traceFail: + final r = repeaters.isNotEmpty ? repeaters.first : null; + _showTraceDetails(TraceLogEntry( + timestamp: marker.timestamp, + latitude: lat, + longitude: lon, + targetRepeaterId: r?.repeaterId ?? '', + noiseFloor: marker.noiseFloor, + localSnr: r?.snr, + localRssi: r?.rssi, + success: marker.type == PingEventType.traceSuccess, + )); + } + } + Set _getDuplicateRepeaterIds(List repeaters) { final idCounts = {}; for (final repeater in repeaters) { From 73cad99d0ade0073829196f039889c7f0ee932dc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 21:40:50 -0400 Subject: [PATCH 113/360] - Coverage overlay tile refresh debounce increased from 5s to 30s to reduce visual churn during active wardriving - Fixed iOS reconnect taking 4+ minutes after BLE disconnect with stale bond keys, now under 10 seconds --- lib/providers/app_state_provider.dart | 4 +++- lib/services/bluetooth/mobile_bluetooth.dart | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c0d7a590..33d3f147 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -822,8 +822,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Schedule overlay tile refresh after server has time to regenerate tiles. // The MapWidget watches _overlayCacheBust and calls _refreshCoverageOverlay() // (remove + re-add raster source with new URL) when it changes. + // Use 30s debounce to avoid excessive tile refreshes during rapid auto-ping + // (especially on flaky networks where tiles may fail to load). _tileRefreshTimer?.cancel(); - _tileRefreshTimer = Timer(const Duration(seconds: 5), () { + _tileRefreshTimer = Timer(const Duration(seconds: 30), () { _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; debugLog('[MAP] Refreshing overlay tiles'); notifyListeners(); diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 47a5f23f..70513891 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -439,9 +439,23 @@ class MobileBluetoothService implements BluetoothService { if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { debugLog( - '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + '[BLE] Bond error (apple-code 14/15) on attempt $attempt'); await removeBond(deviceId); - await Future.delayed(const Duration(seconds: 2)); + // On iOS, removeBond() is a no-op (not supported by Core Bluetooth). + // Don't burn internal retries against stale bond keys — they will all + // fail the same way and each hangs for the full connect timeout. + // Rethrow immediately so the auto-reconnect system can apply a longer + // delay (giving iOS time to resolve) or inform the user. + debugWarn( + '[BLE] iOS bond error — skipping internal retries (stale keys cannot be cleared programmatically)'); + try { + await _bleDevice?.disconnect(); + } catch (_) {} + _bleDevice = null; + _rxCharacteristic = null; + _txCharacteristic = null; + _updateStatus(ConnectionStatus.error); + rethrow; } else { debugLog( '[BLE] Error 133 on attempt $attempt, retrying after delay...'); From 6e4ec3d3d1f681b73176560b751a612714909a06 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 22:41:09 -0400 Subject: [PATCH 114/360] - Fixed generic "Timestamp is too old" registration failure now diagnosing a device clock stuck in the future with actionable instructions to power-cycleYou said: Word smith this --- .../plans/2026-05-17-focus-mode-minimize.md | 822 ------------------ .../2026-05-17-focus-mode-minimize-design.md | 134 --- lib/providers/app_state_provider.dart | 60 +- lib/services/meshcore/connection.dart | 27 + lib/widgets/map_widget.dart | 153 +--- 5 files changed, 117 insertions(+), 1079 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-17-focus-mode-minimize.md delete mode 100644 docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md diff --git a/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md b/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md deleted file mode 100644 index 1ac975a6..00000000 --- a/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md +++ /dev/null @@ -1,822 +0,0 @@ -# Focus Mode Minimize Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Allow users to minimize the focus mode ping details popup to a compact pill, giving full map zoom/pan access while keeping focus lines visible. - -**Architecture:** All changes are in `lib/widgets/map_widget.dart`. Two new state fields (`_focusPanelMinimized`, `_focusedPingSource`) track minimized state. The 4 existing `_show*Details` methods gain a minimize button and `fromMinimized` parameter. A new `_buildMinimizedFocusPanel()` method renders the pill in the map's outer `Stack`. `_activatePingFocus` and `_dismissPingFocus` are updated to manage the new state. - -**Tech Stack:** Flutter, MapLibre GL, Provider - ---- - -### Task 1: Add state fields and update `_activatePingFocus` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:382` (state fields) -- Modify: `lib/widgets/map_widget.dart:5357-5413` (`_activatePingFocus`) - -- [ ] **Step 1: Add new state fields** - -After line 382 (`bool _wasRotatingBeforeFocus = false;`), add: - -```dart - bool _focusPanelMinimized = false; - dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry -``` - -- [ ] **Step 2: Update `_activatePingFocus` to handle re-activation** - -Replace the entire `_activatePingFocus` method (lines 5357-5413) with: - -```dart - void _activatePingFocus(LatLng pingLocation, DateTime timestamp, - List<_ResolvedRepeater> repeaters) { - // Drop repeaters lacking GPS — they would draw lines off to (0, 0). - // The bottom-sheet row builder still surfaces them with a no-location - // icon. If nothing is left to focus on, skip activation entirely so - // the user's current map view (zoom, autofollow, rotation) is kept. - final located = - repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); - if (located.isEmpty) return; - - // Only save pre-focus state on first activation. When re-activating - // (e.g. user taps a different ping while already in focus, or expanding - // from minimized), we keep the original pre-focus snapshot so dismiss - // restores the correct camera position. - final alreadyInFocus = _focusedPingLocation != null; - if (!alreadyInFocus) { - final pos = _mapController?.cameraPosition; - _preFocusCenter = pos?.target; - _preFocusZoom = pos?.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; - } - - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null && _canAnimateCamera) { - _mapController!.animateCamera( - CameraUpdate.bearingTo(0), - duration: const Duration(milliseconds: 1), - ); - } - } - } - - _focusPanelMinimized = false; - - setState(() { - _focusedPingLocation = pingLocation; - _focusedPingTimestamp = timestamp; - _focusedRepeaters = located; - }); - - // Hide the MeshMapper coverage raster overlay for a clean focus view. - // Uses opacity=0 rather than removing the layer to avoid a tile refetch - // on dismiss. No-ops gracefully if the layer isn't present. - _applyCoverageOverlayOpacity(0.0); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _focusedPingLocation != null) { - _zoomToFocusBounds(pingLocation, located); - } - }); - - // Once the 500ms zoom-to-fit animation settles, re-flow the distance - // labels so any that collide on screen slide along their lines to a - // non-overlapping slot. 600ms gives the camera a bit of buffer beyond - // the animation duration. - Future.delayed(const Duration(milliseconds: 600), () { - if (!mounted || _focusedPingLocation == null) return; - _reflowDistanceLabelsForCollisions(); - }); - } -``` - -- [ ] **Step 3: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors (existing warnings are OK) - -- [ ] **Step 4: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add focus panel state fields and update _activatePingFocus for re-activation" -``` - ---- - -### Task 2: Update `_dismissPingFocus` to clear new state - -**Files:** -- Modify: `lib/widgets/map_widget.dart:5417-5461` (`_dismissPingFocus`) - -- [ ] **Step 1: Update `_dismissPingFocus`** - -Replace lines 5417-5461 (the full `_dismissPingFocus` method) with: - -```dart - void _dismissPingFocus() { - if (_focusedPingLocation == null || !mounted) return; - - final center = _preFocusCenter; - final zoom = _preFocusZoom; - final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; - final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; - - // Clear focus state but do NOT restore auto-follow or rotation yet — - // they would immediately trigger animations in the build method that - // override our zoom-back animation (both share _animationController). - setState(() { - _focusedPingLocation = null; - _focusedPingTimestamp = null; - _focusedRepeaters = []; - _focusPanelMinimized = false; - _focusedPingSource = null; - }); - - // Restore the MeshMapper coverage raster overlay opacity. Safe if the - // layer was hidden via the toggle during focus — setLayerProperties is - // wrapped in try/catch inside the helper. - final appState = context.read(); - _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); - - if (center != null && zoom != null) { - _animateToPositionWithZoom(center, zoom); - - // Restore auto-follow and heading rotation after the zoom-back - // animation completes (500ms) so they don't clobber it mid-flight. - if (shouldRestoreAutoFollow || shouldRestoreRotation) { - Future.delayed(const Duration(milliseconds: 550), () { - if (mounted) { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - }); - } - } else { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - } -``` - -- [ ] **Step 2: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: update _dismissPingFocus to clear minimized state" -``` - ---- - -### Task 3: Add minimize button to `_showTxPingDetails` and change completion handler - -**Files:** -- Modify: `lib/widgets/map_widget.dart:5582-5908` (`_showTxPingDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** - -Replace the method signature and pre-sheet logic (lines 5582-5599): - -```dart - void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { - // Use the heardRepeaters directly from the TxPing - final heardRepeaters = ping.heardRepeaters; - - // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) - final resolved = heardRepeaters.isNotEmpty - ? _resolveRepeatersByHexIds( - heardRepeaters.map((r) => r.repeaterId).toList(), - snrValues: heardRepeaters.map((r) => r.snr).toList(), - ) - : <_ResolvedRepeater>[]; - final hasAmbiguous = resolved.any((r) => r.ambiguous); - - _focusedPingSource = ping; - - // Activate focus mode if the ping was heard by known repeaters - if (!fromMinimized && resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the TX sheet (around line 5665): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 5907): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately after the TX sheet builder's closing brackets, before `_showRxPingDetails`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to TX ping details sheet" -``` - ---- - -### Task 4: Add minimize button to `_showRxPingDetails` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:5911-6193` (`_showRxPingDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** - -Replace the method signature and pre-sheet logic (lines 5911-5923): - -```dart - void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { - final snrColor = PingColors.snrColor(ping.snr); - final rssiColor = PingColors.rssiColor(ping.rssi); - - // Activate focus mode for the RX ping's repeater - final resolved = _resolveRepeatersByHexIds( - [ping.repeaterId], - snrValues: [ping.snr], - ); - - _focusedPingSource = ping; - - if (!fromMinimized && resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the RX sheet (around line 5978): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 6193): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately before `_showDiscPingDetails`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to RX ping details sheet" -``` - ---- - -### Task 5: Add minimize button to `_showDiscPingDetails` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:6197-6512` (`_showDiscPingDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** - -Replace the method signature and pre-sheet logic (lines 6197-6210): - -```dart - void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for discovered nodes with known repeater positions - _focusedPingSource = entry; - - if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - entry.discoveredNodes.map((n) => n.repeaterId).toList(), - fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: - entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the Disc sheet (around line 6276): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 6512): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately before `_buildRepeaterStatusChip`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to Disc ping details sheet" -``` - ---- - -### Task 6: Add minimize button to `_showTraceDetails` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:4971-5275` (`_showTraceDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** - -Replace the method signature and pre-sheet logic (lines 4971-4982): - -```dart - void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for successful traces with a known repeater - _focusedPingSource = entry; - - if (!fromMinimized && entry.success) { - final resolved = _resolveRepeatersByHexIds( - [entry.targetRepeaterId], - snrValues: [entry.localSnr], - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the Trace sheet (around line 5047): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 5275): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately before `/// DISC marker color`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to Trace details sheet" -``` - ---- - -### Task 7: Add `_buildMinimizedFocusPanel` and `_reshowFocusPanel` methods - -**Files:** -- Modify: `lib/widgets/map_widget.dart` (add new methods near `_dismissPingFocus`, around line 5462) - -- [ ] **Step 1: Add `_reshowFocusPanel` method** - -Insert after the closing brace of `_dismissPingFocus()` (which ends with `}`): - -```dart - void _reshowFocusPanel() { - setState(() => _focusPanelMinimized = false); - final source = _focusedPingSource; - if (source is TxPing) { - _showTxPingDetails(source, fromMinimized: true); - } else if (source is RxPing) { - _showRxPingDetails(source, fromMinimized: true); - } else if (source is DiscLogEntry) { - _showDiscPingDetails(source, fromMinimized: true); - } else if (source is TraceLogEntry) { - _showTraceDetails(source, fromMinimized: true); - } - } -``` - -- [ ] **Step 2: Add `_buildMinimizedFocusPanel` method** - -Insert immediately after `_reshowFocusPanel`: - -```dart - Widget _buildMinimizedFocusPanel() { - final source = _focusedPingSource; - String title; - IconData icon; - Color color; - if (source is TxPing) { - title = 'TX Ping'; - icon = Icons.arrow_upward; - color = PingColors.txSuccess; - } else if (source is RxPing) { - title = 'RX Ping'; - icon = Icons.arrow_downward; - color = PingColors.rx; - } else if (source is DiscLogEntry) { - title = 'Disc Request'; - icon = Icons.radar; - color = PingColors.discSuccess; - } else if (source is TraceLogEntry) { - title = 'Trace'; - icon = Icons.gps_fixed; - color = Colors.cyan; - } else { - return const SizedBox.shrink(); - } - - final repeaterCount = _focusedRepeaters.length; - final timeStr = - _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; - - return GestureDetector( - onTap: _reshowFocusPanel, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 8), - Text( - timeStr, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (repeaterCount > 0) ...[ - const SizedBox(width: 8), - Text( - '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(width: 8), - GestureDetector( - onTap: _reshowFocusPanel, - child: Icon( - Icons.keyboard_arrow_up, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: _dismissPingFocus, - child: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -``` - -- [ ] **Step 3: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 4: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimized focus panel pill and reshow logic" -``` - ---- - -### Task 8: Add minimized pill to the outer Stack in `build()` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:1198-1246` (outer Stack in `build()`) - -- [ ] **Step 1: Add minimized pill to Stack** - -Find the tile load failure banner block (around line 1234-1244): - -```dart - // Tile load failure banner — appears if base tiles haven't finished - // loading within ${_tileLoadTimeoutSeconds}s after style load. - if (_tileLoadFailed) - Positioned( - top: topPadding, - left: 0, - right: 0, - child: Center( - child: _buildTileLoadFailedBanner(), - ), - ), - ], - ); -``` - -Replace with: - -```dart - // Tile load failure banner — appears if base tiles haven't finished - // loading within ${_tileLoadTimeoutSeconds}s after style load. - if (_tileLoadFailed) - Positioned( - top: topPadding, - left: 0, - right: 0, - child: Center( - child: _buildTileLoadFailedBanner(), - ), - ), - - // Minimized focus panel pill — shown when user minimizes a ping - // details sheet. Not a modal, so the map underneath stays fully - // interactable (zoom, pan, rotation). - if (_focusPanelMinimized && _focusedPingLocation != null) - Positioned( - bottom: 16 + MediaQuery.of(context).padding.bottom, - left: 16, - right: 16, - child: Center( - child: _buildMinimizedFocusPanel(), - ), - ), - ], - ); -``` - -- [ ] **Step 2: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: render minimized focus pill in map Stack" -``` - ---- - -### Task 9: Final verification - -**Files:** -- Verify: `lib/widgets/map_widget.dart` - -- [ ] **Step 1: Run full static analysis** - -Run: `flutter analyze` -Expected: No new errors introduced. Pre-existing warnings are acceptable. - -- [ ] **Step 2: Run tests** - -Run: `flutter test` -Expected: All existing tests pass. - -- [ ] **Step 3: Final commit (squash-friendly message)** - -Only if there were any fixups needed from analysis/tests: - -```bash -git add lib/widgets/map_widget.dart -git commit -m "fix: address analysis warnings from focus minimize feature" -``` diff --git a/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md b/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md deleted file mode 100644 index a0399e41..00000000 --- a/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Focus Mode Minimize - -Allow users to minimize the ping details popup during focus mode to get a full map view with zoom/pan access, while keeping focus lines and context visible. - -## Current Behavior - -When a ping marker is tapped, focus mode activates: -1. `_activatePingFocus()` saves pre-focus camera state, hides unrelated markers/coverage, draws focus lines to repeaters, zooms to fit bounds -2. `showModalBottomSheet()` displays ping details (TX/RX/Disc/Trace variants) -3. The sheet uses `barrierColor: Colors.transparent` — map is visible but the invisible barrier blocks all touch events (no zoom/pan) -4. Closing the sheet (X or swipe-down) calls `_dismissPingFocus()` which restores the pre-focus map state - -**Problem:** Users cannot zoom or pan the map while viewing focus lines because the modal barrier intercepts all gestures. - -## Design - -### Minimize Button in Sheet Header - -Each of the 4 detail sheet variants (TX, RX, Disc, Trace) gains a minimize button (down-chevron icon) next to the existing close button: - -``` - [↑] TX Ping 12:34:05 ▽ ✕ -``` - -- `▽` (minimize): closes the sheet but keeps focus mode active → shows minimized pill -- `✕` (close): exits focus mode entirely (unchanged behavior) - -### Minimized Pill - -A compact, non-modal widget rendered in the map widget's `Stack`: - -``` -┌──────────────────────────────────────────────┐ -│ ↑ TX Ping 12:34:05 3 repeaters [△] [✕] │ -└──────────────────────────────────────────────┘ -``` - -- **Position:** Bottom of map, horizontally centered, above safe area inset -- **Style:** `surfaceContainerHighest` background, rounded corners (12px), subtle border — matches existing sheet theme -- **Content:** Ping type icon (colored), type label, formatted timestamp, repeater count, expand button, close button -- **Behavior:** - - `△` (expand): re-opens the full details sheet without re-zooming the map - - `✕` (close): calls `_dismissPingFocus()` to exit focus mode entirely - - Tapping the pill body also expands (same as △) - -### Map Interaction When Minimized - -Since the pill is a regular widget in the Stack (not a modal), the map underneath is fully interactable: -- Pinch-to-zoom works -- Pan/drag works -- Rotation works (if rotation lock is off) -- Map control buttons (top-right) remain accessible -- Focus lines and distance labels remain visible - -### What Stays the Same - -- Full details sheet content (repeater tables, location chip, path chain, etc.) -- Focus activation logic (zoom-to-fit, save pre-focus state, focus lines, coverage hide) -- `_dismissPingFocus()` restore behavior (auto-follow, rotation, zoom-back animation) -- Transparent barrier color on the full sheet when expanded -- Swipe-down on sheet still closes and exits focus (unchanged) - -## Implementation Details - -### New State in `_MapWidgetState` - -```dart -bool _focusPanelMinimized = false; -dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry -``` - -### Modified Methods - -**`_activatePingFocus()`:** -- If `_focusedPingLocation != null` (already in focus): skip saving pre-focus state, skip auto-follow/rotation changes — just update the focused ping/repeaters and zoom to new bounds -- Clear `_focusPanelMinimized = false` on activation - -**`_dismissPingFocus()`:** -- Additionally clears `_focusPanelMinimized = false` and `_focusedPingSource = null` - -**`_show{Tx,Rx,Disc,Trace}Details()`:** -- Store the ping/entry in `_focusedPingSource` before showing the sheet -- Accept optional `{bool fromMinimized = false}` parameter — when true, skip `_activatePingFocus` call (focus is already active) -- Add minimize `IconButton` to header row -- Change `.whenComplete(() => _dismissPingFocus())` to `.then((result) { ... })`: - - If `result == 'minimized'`: `setState(() => _focusPanelMinimized = true)` - - Otherwise: `_dismissPingFocus()` - -**Minimize button action:** `Navigator.pop(context, 'minimized')` - -### New Methods - -**`_buildMinimizedFocusPanel()`:** -- Returns the pill widget -- Derives title/icon/color from `_focusedPingSource` runtime type -- Repeater count from `_focusedRepeaters.length` -- Timestamp from `_focusedPingTimestamp` - -**`_reshowFocusPanel()`:** -- Checks `_focusedPingSource` type, calls the matching `_show*Details(source, fromMinimized: true)` -- Sets `_focusPanelMinimized = false` via setState - -### Build Method Change - -In the outer `Stack` (line ~1198), add after map controls: - -```dart -if (_focusPanelMinimized && _focusedPingLocation != null) - Positioned( - bottom: 16 + MediaQuery.of(context).padding.bottom, - left: 16, - right: 16, - child: _buildMinimizedFocusPanel(), - ), -``` - -### Edge Cases - -- **User taps a different ping while minimized:** `_handleSymbolTap` calls `_show*Details` for the new ping. `_activatePingFocus` detects existing focus, updates focus state without re-saving pre-focus. The new sheet opens, minimized state is cleared. -- **Auto-reconnect during minimized state:** Disconnect cleanup calls `_dismissPingFocus()` which clears everything including minimized state. -- **Orientation change while minimized:** Pill repositions naturally via `Positioned` + safe area insets. - -## Files Modified - -- `lib/widgets/map_widget.dart` — all changes are in this single file - -## Testing - -- Tap TX/RX/Disc/Trace ping → verify minimize button visible in header -- Tap minimize → verify pill appears, map is pannable/zoomable, focus lines stay -- Tap expand on pill → verify full sheet re-opens without re-zooming -- Tap close on pill → verify focus mode fully dismisses, map restores -- While minimized, tap a different ping → verify new focus replaces old -- Swipe-down on full sheet → verify still exits focus mode (unchanged) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 33d3f147..84055ac1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1377,6 +1377,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final serverMessage = registerResult['message'] as String?; debugError( '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + + // Diagnose stale ADVERT timestamp: the firmware refuses to set the + // clock backwards (ERR code 6), so if the device's RTC is stuck in + // the future the signed ADVERT will always be rejected by the server. + // Query the device clock and surface an actionable error. + if (serverMessage != null && + serverMessage.contains('Timestamp') && + _meshCoreConnection != null) { + try { + final deviceTime = await _meshCoreConnection!.getDeviceTime(); + final appTime = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + final drift = deviceTime - appTime; + debugError( + '[APP] Device clock: $deviceTime, app clock: $appTime, drift: ${drift}s'); + if (drift > 3600) { + final deviceDate = DateTime.fromMillisecondsSinceEpoch( + deviceTime * 1000, + isUtc: true); + return { + 'success': false, + 'reason': 'clock_error', + 'message': + 'Device clock is set to ${deviceDate.toIso8601String().substring(0, 10)}. ' + 'Power-cycle your device to reset it.', + }; + } + } catch (e) { + debugWarn('[APP] Could not query device time: $e'); + } + } + return { 'success': false, 'reason': serverReason, @@ -4433,17 +4465,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.invalidSession; } - // 2. Get device credentials from session - final publicKey = session.devicePublicKey; - if (publicKey == null) { - debugLog('[OFFLINE] Session missing device public key: $filename'); - return OfflineUploadResult.invalidSession; - } + // 2. Get device credentials from session, falling back to connected device + var publicKey = session.devicePublicKey; + var deviceName = session.deviceName; - final deviceName = session.deviceName; - if (deviceName == null || deviceName.isEmpty) { - debugLog('[OFFLINE] Session missing device name: $filename'); - return OfflineUploadResult.invalidSession; + if (publicKey == null || deviceName == null || deviceName.isEmpty) { + if (_devicePublicKey != null && displayDeviceName != null) { + publicKey ??= _devicePublicKey; + if (deviceName == null || deviceName.isEmpty) { + deviceName = displayDeviceName; + } + debugLog( + '[OFFLINE] Legacy session $filename: using connected device credentials'); + } else { + debugLog( + '[OFFLINE] Session missing credentials and no device connected: $filename'); + return OfflineUploadResult.invalidSession; + } } onProgress?.call('Authenticating...'); @@ -4967,6 +5005,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return 'Service is under maintenance. Try again later.'; case 'network_error': return 'Unable to connect to the MeshMapper server. Please check your internet connection and try again.'; + case 'clock_error': + return serverMessage ?? 'Device clock error. Power-cycle your device to reset it.'; default: return serverMessage ?? 'Unknown error occurred.'; } diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 48219812..7bdecd5c 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -97,6 +97,7 @@ class MeshCoreConnection { Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; + Completer? _getTimeCompleter; // Device self info (contains public key) SelfInfo? _selfInfo; @@ -440,6 +441,8 @@ class MeshCoreConnection { _deviceQueryCompleter = null; _exportContactCompleter?.completeError(errException); _exportContactCompleter = null; + _getTimeCompleter?.completeError(errException); + _getTimeCompleter = null; break; case ResponseCodes.deviceInfo: _onDeviceInfoResponse(reader); @@ -474,6 +477,12 @@ class MeshCoreConnection { case ResponseCodes.batteryVoltage: _onBatteryVoltageResponse(reader); break; + case ResponseCodes.currTime: + if (_getTimeCompleter != null && reader.remainingBytesCount >= 4) { + _getTimeCompleter?.complete(reader.readUInt32LE()); + _getTimeCompleter = null; + } + break; case ResponseCodes.exportContact: _onExportContactResponse(reader); break; @@ -859,6 +868,24 @@ class MeshCoreConnection { ); } + /// Query the device's current RTC clock (epoch seconds) + Future getDeviceTime() async { + _getTimeCompleter = Completer(); + final future = _getTimeCompleter!.future; + + final data = BufferWriter(); + data.writeByte(CommandCodes.getDeviceTime); + await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _getTimeCompleter = null; + throw TimeoutException('getDeviceTime timed out'); + }, + ); + } + /// Set TX power Future setTxPower(int txPower) async { final data = BufferWriter(); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7a31be4c..41ef675f 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -405,18 +405,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // errors in the native log. bool _coverageRefreshScheduled = false; - // Double-buffered coverage overlay: each refresh allocates fresh suffixed - // IDs so the new raster source/layer can be added on top of the previous - // one and rendered before the old layer is removed. This prevents the - // brief blank frame the user previously saw every cache-bust cycle. + // Coverage overlay IDs: each refresh allocates fresh suffixed IDs so + // a remove+add sequence never collides with a stale native source. String? _activeCoverageSourceId; String? _activeCoverageLayerId; int _coverageBufferCounter = 0; - // One-shot completer released by _onMapIdle (or the timeout fallback) to - // signal the swap that new tiles have rendered and the old layer is safe - // to remove. Null when no swap is in flight. - Completer? _coverageSwapIdleCompleter; - Timer? _coverageSwapTimeoutTimer; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -572,20 +565,28 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _cameraAnimationReady = true; + _recoverCoverageOverlayIfNeeded(); } }); } } + /// Re-add the coverage overlay if it should be visible but was lost during + /// a background/foreground transition. + void _recoverCoverageOverlayIfNeeded() { + if (_activeCoverageSourceId != null) return; + if (!_showMeshMapperOverlay) return; + final appState = context.read(); + if (!appState.preferences.mapTilesEnabled) return; + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + debugLog('[MAP] Recovering coverage overlay after app resume'); + _addCoverageOverlay(appState); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); _tileLoadTimeoutTimer?.cancel(); - _coverageSwapTimeoutTimer?.cancel(); - final swapWaiter = _coverageSwapIdleCompleter; - if (swapWaiter != null && !swapWaiter.isCompleted) { - swapWaiter.complete(); - } final controller = _mapController; if (controller != null) { controller.removeListener(_onCameraChanged); @@ -2028,8 +2029,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Style reload wipes the native layer stack, so any tracked coverage // overlay IDs now reference layers that no longer exist. Reset them so - // the next _swapCoverageOverlay treats this as a fresh add (no old - // buffer to retire) instead of attempting a doomed removal. + // the next refresh treats this as a fresh add. _activeCoverageSourceId = null; _activeCoverageLayerId = null; // Same reasoning for the focus-lines source/layers — gone with the @@ -2153,10 +2153,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugLog('[MAP] Tiles recovered after earlier load failure'); setState(() => _tileLoadFailed = false); } - final waiter = _coverageSwapIdleCompleter; - if (waiter != null && !waiter.isCompleted) { - waiter.complete(); - } } /// Fires when the camera stops moving — after both gestures and @@ -2172,13 +2168,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Add MeshMapper coverage raster overlay as a MapLibre source+layer. - /// Allocates fresh suffixed IDs each call so a previous layer can remain - /// in place (and continue rendering its tiles) while the new one's tiles - /// load on top — see [_swapCoverageOverlay] for the double-buffer flow. + /// Allocates fresh suffixed IDs each call to avoid native collisions. Future _addCoverageOverlay(AppStateProvider appState) async { - if (_mapController == null || !_showMeshMapperOverlay) return; - if (!appState.preferences.mapTilesEnabled) return; - if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + if (_mapController == null || !_showMeshMapperOverlay) { + debugLog( + '[MAP] Coverage overlay add skipped: controller=${_mapController != null}, showOverlay=$_showMeshMapperOverlay'); + return; + } + if (!appState.preferences.mapTilesEnabled) { + debugLog('[MAP] Coverage overlay add skipped: mapTilesEnabled=false'); + return; + } + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) { + debugLog( + '[MAP] Coverage overlay add skipped: zoneCode=${appState.zoneCode}'); + return; + } final cvdParam = appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' @@ -2196,20 +2201,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); // Target the bottom of the repeater cluster stack when it exists, so the // raster lands beneath ALL marker layers (repeater clusters + symbol - // annotations). During the initial style load, _setupRepeaterClusterLayers - // runs before this — so _clusterLayersReady is true and we use the - // individual repeater layer as the reference. The zoneCode watcher also - // fires after cluster setup, so both paths converge to the same stack. - // Fallback to the symbol annotation layer only if cluster layers haven't - // been created yet (shouldn't happen in practice, but keeps the raster - // underneath markers either way). - // - // Using the same belowLayerId for the new layer as the previous overlay - // intentionally places this insertion directly under the marker stack - // and ABOVE the previous raster layer — so as the new tiles render - // they paint over the old ones rather than the old being torn down - // first. _swapCoverageOverlay removes the old layer once the new tiles - // have settled. + // annotations). Fallback to the symbol annotation layer if cluster layers + // haven't been created yet. final belowLayer = _clusterLayersReady ? _repeaterIndividualLayerId : _symbolAnnotationLayerId(); @@ -2306,8 +2299,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Remove a specific coverage source+layer pair without touching the - /// active-ID tracking. Used by [_swapCoverageOverlay] to retire the - /// previous buffer once new tiles have rendered. + /// active-ID tracking. Future _removeCoverageLayerById( String layerId, String sourceId) async { if (_mapController == null) return; @@ -2319,78 +2311,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (_) {} } - /// Refresh coverage overlay using a double-buffered swap so the current - /// tiles stay visible until the new ones have rendered on top. - Future _refreshCoverageOverlay(AppStateProvider appState) => - _swapCoverageOverlay(appState); - - /// Double-buffered overlay refresh: - /// 1. Capture the currently-active source/layer IDs (the "old" buffer). - /// 2. Add the new source+layer — [_addCoverageOverlay] uses the same - /// belowLayerId so the new layer lands directly above the old one, - /// and updates the active-ID fields to point at the new buffer. - /// 3. Wait for [_onMapIdle] (or a short timeout) so the new tiles have - /// a chance to paint over the old. - /// 4. Remove the old source+layer. - /// - /// If the add was skipped (overlay disabled, no zone, etc.) the old - /// buffer is dropped immediately — there's nothing to buffer against. - Future _swapCoverageOverlay(AppStateProvider appState) async { - final oldSourceId = _activeCoverageSourceId; - final oldLayerId = _activeCoverageLayerId; - + /// Refresh coverage overlay by removing the current layer and adding a fresh + /// one with updated cache-bust URL. Accepts a brief visual gap between + /// remove and add — imperceptible during driving and quick when stationary + /// (tiles load from cache for same viewport). + Future _refreshCoverageOverlay(AppStateProvider appState) async { + await _removeCoverageOverlay(); await _addCoverageOverlay(appState); - - final addedNewBuffer = _activeCoverageSourceId != oldSourceId && - _activeCoverageSourceId != null; - - if (!addedNewBuffer) { - // Add was a no-op (preconditions failed). Drop the previous buffer if - // the overlay should no longer be visible. _addCoverageOverlay's - // preconditions match the conditions under which we want the overlay - // gone, so this is the correct place to retire it. - if (oldSourceId != null && oldLayerId != null) { - _activeCoverageSourceId = null; - _activeCoverageLayerId = null; - await _removeCoverageLayerById(oldLayerId, oldSourceId); - } - return; - } - - if (oldSourceId == null || oldLayerId == null) { - // No previous buffer to retire (first add since style load or after a - // teardown). Nothing more to do. - return; - } - - await _waitForCoverageSwapIdle(timeout: const Duration(seconds: 3)); - if (!mounted) return; - await _removeCoverageLayerById(oldLayerId, oldSourceId); - } - - /// Block until [_onMapIdle] completes the swap completer, or [timeout] - /// elapses (whichever happens first). Replaces any prior in-flight waiter - /// so a new swap starting mid-flight doesn't strand the old waiter. - Future _waitForCoverageSwapIdle({required Duration timeout}) async { - final prior = _coverageSwapIdleCompleter; - if (prior != null && !prior.isCompleted) { - prior.complete(); - } - final completer = Completer(); - _coverageSwapIdleCompleter = completer; - _coverageSwapTimeoutTimer?.cancel(); - _coverageSwapTimeoutTimer = Timer(timeout, () { - if (!completer.isCompleted) completer.complete(); - }); - try { - await completer.future; - } finally { - if (identical(_coverageSwapIdleCompleter, completer)) { - _coverageSwapIdleCompleter = null; - } - _coverageSwapTimeoutTimer?.cancel(); - _coverageSwapTimeoutTimer = null; - } } /// Returns the fill color for a repeater status keyword. From 22da229f466f38b4fd2e919388cd33f5ca964926 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 23:20:28 -0400 Subject: [PATCH 115/360] feat: add TCP health check after app resume to ensure connection stability --- lib/providers/app_state_provider.dart | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 84055ac1..ffccc054 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -399,6 +399,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + _checkTcpHealthAfterResume(); } else if (state == AppLifecycleState.paused) { debugLog('[APP] App paused (backgrounded)'); // Save offline pings immediately on pause to prevent data loss if OS kills app @@ -408,6 +409,34 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Probe TCP connection after iOS resume — socket may have died while suspended. + Future _checkTcpHealthAfterResume() async { + if (_selectedTransport != TransportType.tcp) return; + if (_connectionStep != ConnectionStep.connected) return; + if (_isAutoReconnecting || _userRequestedDisconnect) return; + + // Let pending socket error/done events propagate first + await Future.delayed(const Duration(milliseconds: 1500)); + + // If socket events already triggered auto-reconnect, nothing to do + if (_connectionStep != ConnectionStep.connected) return; + if (_isAutoReconnecting) return; + + debugLog('[CONN] Probing TCP connection after resume'); + try { + await _meshCoreConnection!.getNoiseFloor(); + debugLog('[CONN] TCP connection healthy after resume'); + } catch (e) { + debugLog('[CONN] TCP probe failed after resume: $e'); + if (_connectionStep == ConnectionStep.connected && + !_isAutoReconnecting && + _rememberedDevice != null && + !_userRequestedDisconnect) { + await _startAutoReconnect(); + } + } + } + // ============================================ // Getters // ============================================ From 51cc2aac876760e131ac9225d7a36cdaafaa47f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 20 May 2026 23:22:58 -0400 Subject: [PATCH 116/360] - Fixed auto-ping permanently stopping when crossing a regional zone boundary --- lib/providers/app_state_provider.dart | 5 +++++ lib/services/ping_service.dart | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ffccc054..1545dc54 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3306,6 +3306,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); toggleAutoPing(previousMode); debugLog( '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); @@ -5732,6 +5734,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); final resolvedMode = _resolveAutoModeForZone(previousMode); debugLog( '[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); @@ -6133,6 +6137,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { _cooldownTimer.stop(); + _pingService!.clearCooldown(); final resolvedMode = _resolveAutoModeForZone(previousMode); debugLog( '[ZONE] Mode resolved for new zone: $previousMode → $resolvedMode'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 0482f818..dd55976a 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -465,6 +465,11 @@ class PingService { return remaining.inSeconds.clamp(0, _autoPingCooldown.inSeconds); } + /// Clear the auto-ping cooldown (used during zone transfer to avoid blocking restart). + void clearCooldown() { + _lastTxTime = null; + } + /// Check if currently in manual ping cooldown period bool isInManualCooldown() { return _manualPingCooldownTimer.remainingMs > 0; From 5d56fe25b1270050df5c256f21fdc2a0e52a025b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 20 May 2026 23:25:28 -0400 Subject: [PATCH 117/360] - "Clear Map Markers" now also removes discovery and trace markers --- lib/providers/app_state_provider.dart | 2 ++ lib/screens/settings_screen.dart | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1545dc54..f0f31153 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3843,6 +3843,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _discLogEntries.clear(); + _traceLogEntries.clear(); _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 11e19cd5..e5ba7299 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1454,7 +1454,7 @@ class _SettingsScreenState extends State { builder: (context) => AlertDialog( title: const Text('Clear Map Markers?'), content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + 'This will remove all markers from the map. This won\'t affect uploaded data.', ), actions: [ TextButton( From 3b5c115c008d840d8ce91c924f74505e1988152a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 20 May 2026 23:30:49 -0400 Subject: [PATCH 118/360] =?UTF-8?q?=E2=8F=BA=20-=20Added=20a=20toggle=20bu?= =?UTF-8?q?tton=20to=20the=20map=20control=20panel=20to=20show/hide=20the?= =?UTF-8?q?=20regional=20boundary=20line=20and=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/map_widget.dart | 47 ++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 41ef675f..ee8e205b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -364,6 +364,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; + // Region boundary overlay toggle (on by default) + bool _showRegionBorders = true; + // Collapsible map controls in landscape bool _mapControlsExpanded = true; @@ -1522,9 +1525,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { )); if (bordersSig != _lastBordersSignature && _isMapReady && _styleLoaded) { _lastBordersSignature = bordersSig; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _refreshRegionBorders(appState); - }); + if (_showRegionBorders) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshRegionBorders(appState); + }); + } } // Detect coverage overlay opacity change (user dragged the slider in @@ -4239,6 +4244,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { isActive: _showMeshMapperOverlay, ), ], + // Region boundary toggle (only show when borders available) + if (appState.regionBorders.isNotEmpty) ...[ + _buildControlDivider(), + _buildControlButton( + icon: Icons.fence, + tooltip: _showRegionBorders + ? 'Hide Region Boundary' + : 'Show Region Boundary', + onPressed: () => _toggleRegionBorders(appState), + isActive: _showRegionBorders, + ), + ], _buildControlDivider(), // Center on position / toggle auto-follow _buildControlButton( @@ -4380,6 +4397,30 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + void _toggleRegionBorders(AppStateProvider appState) { + setState(() { + _showRegionBorders = !_showRegionBorders; + }); + if (_showRegionBorders) { + _refreshRegionBorders(appState); + } else { + _removeRegionBorders(); + } + } + + void _removeRegionBorders() { + if (_mapController == null) return; + try { + _mapController!.removeLayer(_regionBorderLabelLayerId); + } catch (_) {} + try { + _mapController!.removeLayer(_regionBorderLineLayerId); + } catch (_) {} + try { + _mapController!.removeSource(_regionBorderSourceId); + } catch (_) {} + } + void _toggleNorthMode() { final appState = context.read(); setState(() { From 95f02d8ed95e272e4b0bdc8f78ced009af8bbe1c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 21 May 2026 09:15:00 -0400 Subject: [PATCH 119/360] Shortened the "DUPLICATE ID" label on focus mode lines to "DUP" so it actually renders on shorter lines instead of being hidden or unreadable --- lib/widgets/map_widget.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index ee8e205b..2f6dfa74 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3533,7 +3533,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusLinesAmbiguousLabelId, const SymbolLayerProperties( symbolPlacement: 'line', - textField: 'DUPLICATE ID', + textField: 'DUP', textSize: 11, textColor: '#F59E0B', textHaloColor: '#FFFFFF', From 043220adda91df5baab87afda40ea281c61f4f87 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 21 May 2026 13:13:59 -0400 Subject: [PATCH 120/360] - Multi-hop TX echoes are now grouped under their parent TX ping instead of showing as anonymous RX observations. Log tab, noise floor graph, and map popup show "Direct Repeats" and "Multi-hop Repeats" sections. TX markers are green (direct echo), RX-colored (multi-hop only), or red (no response). --- lib/models/log_entry.dart | 31 +- lib/models/noise_floor_session.dart | 10 + lib/models/ping_data.dart | 2 + lib/providers/app_state_provider.dart | 95 ++- lib/screens/log_screen.dart | 157 ++++- lib/services/meshcore/tx_tracker.dart | 154 +++-- lib/services/meshcore/unified_rx_handler.dart | 10 +- lib/services/ping_service.dart | 83 ++- lib/widgets/map_widget.dart | 546 ++++++++++++------ lib/widgets/noise_floor_chart.dart | 223 ++++--- 10 files changed, 974 insertions(+), 337 deletions(-) diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 9afe5650..0f5a6320 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -5,7 +5,8 @@ class TxLogEntry { final double latitude; final double longitude; final double power; // Power in watts (0.3, 0.6, 1.0, 2.0) - final List events; // Repeaters that heard this ping + final List events; // Direct 1-hop repeaters that heard this ping + final List multiHopEvents; // Multi-hop echoes (2+ hops) TxLogEntry({ required this.timestamp, @@ -13,6 +14,7 @@ class TxLogEntry { required this.longitude, required this.power, required this.events, + this.multiHopEvents = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -36,7 +38,32 @@ class TxLogEntry { ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)') .join(','); - return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; + final multiHopStr = multiHopEvents.isEmpty + ? '' + : ',MH:${multiHopEvents.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(',')}'; + return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr$multiHopStr'; + } +} + +/// Multi-hop echo event (repeater that relayed a TX ping via 2+ hops) +class MultiHopEchoEvent { + final String repeaterId; + final double? snr; + final int? rssi; + final List pathHops; + + MultiHopEchoEvent({ + required this.repeaterId, + this.snr, + this.rssi, + this.pathHops = const [], + }); + + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) return SnrSeverity.poor; + if (snr! <= 5) return SnrSeverity.fair; + return SnrSeverity.good; } } diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index c2af3531..8bdeb735 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -42,6 +42,9 @@ enum PingEventType { @HiveField(6) traceFail, // Grey: Trace no response + + @HiveField(7) + txMultiHopOnly, // RX color: TX got multi-hop echoes but no direct } /// Repeater info for graph markers @@ -61,11 +64,16 @@ class MarkerRepeaterInfo extends HiveObject { @HiveField(3) final String? pubkeyHex; + /// Hop path for multi-hop echoes. Null = direct 1-hop echo, non-null = multi-hop. + @HiveField(4) + final List? pathHops; + MarkerRepeaterInfo({ required this.repeaterId, required this.snr, required this.rssi, this.pubkeyHex, + this.pathHops, }); } @@ -108,6 +116,7 @@ class PingEventMarker extends HiveObject { PingEventType.discFail => PingColors.discFail, PingEventType.traceSuccess => PingColors.traceSuccess, PingEventType.traceFail => PingColors.noResponse, + PingEventType.txMultiHopOnly => PingColors.rx, }; /// Get a display label for this event type @@ -119,6 +128,7 @@ class PingEventMarker extends HiveObject { PingEventType.discFail => 'DISC Fail', PingEventType.traceSuccess => 'Trace Success', PingEventType.traceFail => 'Trace Fail', + PingEventType.txMultiHopOnly => 'TX Multi-hop', }; } diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 9cd326ee..09defe62 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -120,12 +120,14 @@ class HeardRepeater { final double? snr; // Best SNR observed (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) final int seenCount; // How many times this repeater was heard + final List? pathHops; // null = direct echo, non-null = multi-hop const HeardRepeater({ required this.repeaterId, this.snr, this.rssi, this.seenCount = 1, + this.pathHops, }); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f0f31153..0ca6c386 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2253,10 +2253,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: lastEntry.longitude, power: lastEntry.power, events: existingEvents, + multiHopEvents: lastEntry.multiHopEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog( - '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + '[APP] Updated TxLogEntry with ${existingEvents.length} direct, ' + '${lastEntry.multiHopEvents.length} multi-hop events (real-time)'); _updateTopRepeaters( existingEvents @@ -2278,6 +2280,52 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }; + _pingService!.onMultiHopEchoReceived = + (txPing, repeaterId, snr, rssi, pathHops, isNew) { + debugLog( + '[APP] Multi-hop echo: $repeaterId, hops=${pathHops.length}, isNew=$isNew'); + + if (_txLogEntries.isNotEmpty) { + final lastEntry = _txLogEntries.last; + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + if (timeDiff <= 10) { + final multiHopEvents = + List.from(lastEntry.multiHopEvents); + final newEvent = MultiHopEchoEvent( + repeaterId: repeaterId, + snr: snr, + rssi: rssi, + pathHops: pathHops, + ); + + if (isNew) { + multiHopEvents.add(newEvent); + _audioService.playReceiveSound(); + _pingStats = + _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); + } else { + final idx = multiHopEvents + .indexWhere((e) => e.repeaterId == repeaterId); + if (idx >= 0) { + multiHopEvents[idx] = newEvent; + } + } + + _txLogEntries[_txLogEntries.length - 1] = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: lastEntry.events, + multiHopEvents: multiHopEvents, + ); + + notifyListeners(); + } + } + }; + _pingService!.onPingProgressChanged = notifyListeners; _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { @@ -2318,31 +2366,52 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }; - _pingService!.onTxWindowComplete = (success) { + _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { double? lat; double? lon; - List? repeaters; + List? allRepeaters; if (_txLogEntries.isNotEmpty) { final lastTx = _txLogEntries.last; lat = lastTx.latitude; lon = lastTx.longitude; - if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events - .map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )) - .toList(); + + final directRepeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); + + final multiHopRepeaters = multiHopEchoes + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + pathHops: e.pathHops, + )) + .toList(); + + if (directRepeaters.isNotEmpty || multiHopRepeaters.isNotEmpty) { + allRepeaters = [...directRepeaters, ...multiHopRepeaters]; } } + final PingEventType eventType; + if (directSuccess) { + eventType = PingEventType.txSuccess; + } else if (multiHopEchoes.isNotEmpty) { + eventType = PingEventType.txMultiHopOnly; + } else { + eventType = PingEventType.txFail; + } + recordPingEvent( - success ? PingEventType.txSuccess : PingEventType.txFail, + eventType, latitude: lat, longitude: lon, - repeaters: repeaters, + repeaters: allRepeaters, ); }; diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index d84db19f..951eba47 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -355,6 +355,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { return true; } } + for (final event in tx.multiHopEvents) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } + } return false; case PingLogType.rx: final rx = entry.asRx; @@ -391,7 +398,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { switch (entry.type) { case PingLogType.tx: return entry.asTx.events - .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)) || + entry.asTx.multiHopEvents + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); case PingLogType.rx: return _isAmbiguousId(entry.asRx.repeaterId, repeaters); case PingLogType.disc: @@ -697,11 +706,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), - // Repeaters table + // Direct echoes table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), _buildRepeaterTable(context, entry.events, widget.repeaters), - ] else ...[ + ] else if (entry.multiHopEvents.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'No direct repeats heard', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + // Multi-hop echoes section + if (entry.multiHopEvents.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildMultiHopSection( + context, entry.multiHopEvents, widget.repeaters), + ], + // No echoes at all + if (entry.events.isEmpty && entry.multiHopEvents.isEmpty) ...[ const SizedBox(height: 8), Text( 'No repeaters heard', @@ -786,6 +813,130 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } + // --------------------------------------------------------------------------- + // Multi-hop Echo Section (inside TX Card) + // --------------------------------------------------------------------------- + + Widget _buildMultiHopSection(BuildContext context, + List events, List repeaters) { + var maxIdLen = 0; + for (final e in events) { + if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; + } + final nodeWidth = _nodeColumnWidthForLength(maxIdLen); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon(Icons.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), + Text( + 'Multi-hop Repeats', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Table header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: nodeWidth, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + final isAmbiguous = _isAmbiguousId(event.repeaterId, repeaters); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, event.repeaterId), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip( + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth, + isAmbiguous: isAmbiguous), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null + ? '${event.rssi}' + : '-', + rssiColor))), + ], + ), + ), + ), + if (event.pathHops.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(left: 10, right: 10, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.route, + size: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + Expanded( + child: RxPathChain( + hops: event.pathHops, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ); + }), + ], + ), + ); + } + // --------------------------------------------------------------------------- // RX Card // --------------------------------------------------------------------------- diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index b4090e26..92226f2f 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -17,21 +17,29 @@ class TxTracker { int? expectedChannelHash; Uint8List? channelKey; - /// Map of repeaterId (hex) -> RepeaterEcho + /// Map of repeaterId (hex) -> RepeaterEcho (direct 1-hop echoes) final Map repeaters = {}; + /// Map of repeaterId (hex) -> RepeaterEcho (multi-hop 2+ hop echoes) + final Map multiHopRepeaters = {}; + Timer? _windowTimer; /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped /// to report the underlying repeater with null SNR/RSSI String? carpeaterPrefix; - /// Callback fired when a new echo is received (for real-time UI updates) + /// Callback fired when a direct echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + /// Callback fired when a multi-hop echo is received (for real-time UI updates) + void Function( + String repeaterId, double? snr, int? rssi, List pathHops, bool isNew)? + onMultiHopEchoReceived; + /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -69,6 +77,7 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); + multiHopRepeaters.clear(); // Start window timer _windowTimer?.cancel(); @@ -81,7 +90,8 @@ class TxTracker { /// Stop tracking echoes void stopTracking() { debugLog( - '[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} direct, ' + '${multiHopRepeaters.length} multi-hop repeaters)'); isListening = false; _windowTimer?.cancel(); @@ -91,15 +101,22 @@ class TxTracker { if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { debugLog( - '[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + '[TX LOG] Final direct: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + } + } + if (multiHopRepeaters.isNotEmpty) { + for (final entry in multiHopRepeaters.entries) { + debugLog( + '[TX LOG] Final multi-hop: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, ' + 'hops=${entry.value.pathHops.length}, seen=${entry.value.seenCount}x'); } } } /// Handle incoming packet, check if it's an echo - /// Returns true if packet was an echo and tracked - Future handlePacket(PacketMetadata metadata) async { - if (!isListening) return false; + /// Returns TxEchoResult indicating whether packet was consumed and how + Future handlePacket(PacketMetadata metadata) async { + if (!isListening) return TxEchoResult.notEcho; final originalPayload = sentPayload; final expectedHash = expectedChannelHash; @@ -112,17 +129,16 @@ class TxTracker { if (!metadata.isGroupText) { debugLog('[TX LOG] Ignoring: header validation failed ' '(header=0x${metadata.header.toRadixString(16).padLeft(2, '0')})'); - return false; + return TxEchoResult.notEcho; } debugLog( '[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) - // Moved before RSSI check so we can log the repeater ID on carpeater drops if (metadata.pathHashCount == 0) { debugLog( '[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); - return false; + return TxEchoResult.notEcho; } // Extract first hop (first repeater) for use in validation and logging @@ -137,7 +153,7 @@ class TxTracker { PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); - return false; + return TxEchoResult.notEcho; } // Multi-hop: strip CARpeater, report underlying repeater (second hop) final underlyingHex = metadata.getHopHex(1)!; @@ -149,28 +165,31 @@ class TxTracker { reportedRssi = null; } - // Only direct (1-hop) echoes yield a meaningful SNR/RSSI for the repeater - // that heard our TX: the radio reports last-hop link quality, so for any - // multi-hop relay the metrics describe a different link entirely. - if (!carpeaterStripped && metadata.pathHashCount > 1) { + // Determine if this is a multi-hop echo (2+ hops, not CARpeater-stripped) + final bool isMultiHop = !carpeaterStripped && metadata.pathHashCount > 1; + + // For multi-hop: use lastHop as reporting repeater, build display path + List displayHops = const []; + if (isMultiHop) { + pathHex = metadata.lastHopHex!; + displayHops = [ + for (var i = 0; i < metadata.pathHashCount; i++) metadata.getHopHex(i)!, + ]; debugLog( - '[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' - 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); - return false; + '[TX LOG] Multi-hop echo (pathHashCount=${metadata.pathHashCount}): ' + 'reporting repeater=$pathHex, path=${displayHops.join(' → ')}'); } - // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user - // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) + // VALIDATION STEP 2: Check user carpeater filter if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { debugLog( '[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); - return false; + return TxEchoResult.notEcho; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - // Skip for CARpeater pass-through (user explicitly identified their CARpeater) if (carpeaterStripped) { debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { @@ -184,7 +203,7 @@ class TxTracker { '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call( pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); - return false; // Mark as handled (dropped) + return TxEchoResult.notEcho; } else { debugLog( '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); @@ -194,7 +213,7 @@ class TxTracker { if (metadata.encryptedPayload.length < 3) { debugLog( '[TX LOG] Ignoring: payload too short to contain channel hash'); - return false; + return TxEchoResult.notEcho; } final packetChannelHash = metadata.channelHash!; @@ -204,35 +223,30 @@ class TxTracker { if (packetChannelHash != expectedHash) { debugLog('[TX LOG] Ignoring: channel hash mismatch'); - return false; + return TxEchoResult.notEcho; } debugLog( '[TX LOG] Channel hash match confirmed - this is a message on our channel'); - // VALIDATION STEP 3: Message content verification + // VALIDATION STEP 4: Message content verification if (channelKey != null && originalPayload != null) { debugLog( '[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); try { - // Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] - // Skip first 3 bytes to get the encrypted message final encryptedMessage = metadata.encryptedPayload.sublist(3); final decryptedBytes = CryptoService.decryptChannelMessage( encryptedMessage, channelKey!, ); - // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] - // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { debugLog( '[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); - return false; + return TxEchoResult.notEcho; } final messageBytes = decryptedBytes.sublist(5); - // Convert bytes to string and strip null terminators var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); decryptedMessage = @@ -245,8 +259,6 @@ class TxTracker { debugLog( '[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); - // Check if our expected message is contained in the decrypted text - // This handles both exact matches and messages with sender prefixes final messageMatches = decryptedMessage == originalPayload || decryptedMessage.contains(originalPayload); @@ -255,7 +267,7 @@ class TxTracker { '[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); debugLog( '[MESSAGE_CORRELATION] This is a different message on the same channel'); - return false; + return TxEchoResult.notEcho; } if (decryptedMessage == originalPayload) { @@ -269,7 +281,7 @@ class TxTracker { } catch (e) { debugLog( '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); - return false; + return TxEchoResult.notEcho; } } else { debugWarn( @@ -278,20 +290,22 @@ class TxTracker { '[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); } - // Path length and first hop already validated/extracted earlier (before RSSI check) + // --- Validation passed, store the echo --- + + final targetMap = isMultiHop ? multiHopRepeaters : repeaters; + final echoType = isMultiHop ? 'multi-hop' : 'direct'; debugLog( - '[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + '[PING] Repeater echo accepted ($echoType): repeater=$pathHex, SNR=$reportedSnr, ' 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; - if (repeaters.containsKey(pathHex)) { - final existing = repeaters[pathHex]!; - debugLog('[PING] Deduplication: path $pathHex already seen ' + if (targetMap.containsKey(pathHex)) { + final existing = targetMap[pathHex]!; + debugLog('[PING] Deduplication ($echoType): path $pathHex already seen ' '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); - // Keep the best (highest) SNR — null SNR never replaces non-null final shouldUpdate = reportedSnr != null && existing.snr != null ? reportedSnr > existing.snr! : reportedSnr != null && existing.snr == null; @@ -299,48 +313,56 @@ class TxTracker { debugLog( '[PING] Deduplication decision: updating path $pathHex with better SNR: ' '${existing.snr} -> $reportedSnr'); - repeaters[pathHex] = RepeaterEcho( + targetMap[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, rssi: reportedRssi, seenCount: existing.seenCount + 1, + isMultiHop: isMultiHop, + pathHops: displayHops, ); } else { debugLog( '[PING] Deduplication decision: keeping existing SNR for path $pathHex ' '(existing ${existing.snr} >= new $reportedSnr)'); - // Still increment seen count existing.seenCount++; } } else { - // New repeater isNewRepeater = true; debugLog( - '[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); - repeaters[pathHex] = RepeaterEcho( + '[PING] Adding new $echoType echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + targetMap[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, rssi: reportedRssi, seenCount: 1, + isMultiHop: isMultiHop, + pathHops: displayHops, ); } - // Notify callback for real-time UI updates - final bestSnr = repeaters[pathHex]!.snr; - final bestRssi = repeaters[pathHex]!.rssi; - debugLog( - '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); - if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); - debugLog('[TX LOG] onEchoReceived callback invoked successfully'); + // Notify appropriate callback + final best = targetMap[pathHex]!; + if (isMultiHop) { + debugLog( + '[TX LOG] Invoking onMultiHopEchoReceived callback'); + onMultiHopEchoReceived?.call( + pathHex, best.snr, best.rssi, displayHops, isNewRepeater); + } else { + debugLog( + '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + if (onEchoReceived != null) { + onEchoReceived!(pathHex, best.snr, best.rssi, isNewRepeater); + debugLog('[TX LOG] onEchoReceived callback invoked successfully'); + } } - debugLog('[TX LOG] ✅ Echo tracked successfully'); - return true; + debugLog('[TX LOG] ✅ Echo tracked successfully ($echoType)'); + return isMultiHop ? TxEchoResult.multiHopEcho : TxEchoResult.directEcho; } catch (error, stackTrace) { debugError('[TX LOG] Error processing rx_log entry: $error'); debugError('[TX LOG] Stack trace: $stackTrace'); - return false; + return TxEchoResult.notEcho; } } @@ -350,17 +372,33 @@ class TxTracker { } } +/// Result of TxTracker.handlePacket() +enum TxEchoResultType { notEcho, directEcho, multiHopEcho } + +class TxEchoResult { + final TxEchoResultType type; + + const TxEchoResult._(this.type); + static const notEcho = TxEchoResult._(TxEchoResultType.notEcho); + static const directEcho = TxEchoResult._(TxEchoResultType.directEcho); + static const multiHopEcho = TxEchoResult._(TxEchoResultType.multiHopEcho); +} + /// Repeater echo data class RepeaterEcho { final String repeaterId; // Hex string double? snr; // Best SNR seen (null for CARpeater pass-through) int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) int seenCount; // Times observed + final bool isMultiHop; + final List pathHops; RepeaterEcho({ required this.repeaterId, this.snr, this.rssi, this.seenCount = 1, + this.isMultiHop = false, + this.pathHops = const [], }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 2f5e71df..84949b05 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -95,9 +95,13 @@ class UnifiedRxHandler { // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); - final wasEcho = await txTracker.handlePacket(metadata); - if (wasEcho) { - debugLog('[UNIFIED RX] Packet was TX echo, done'); + final result = await txTracker.handlePacket(metadata); + if (result.type == TxEchoResultType.directEcho) { + debugLog('[UNIFIED RX] Packet was direct TX echo, done'); + return; + } + if (result.type == TxEchoResultType.multiHopEcho) { + debugLog('[UNIFIED RX] Packet was multi-hop TX echo, grouped with TX'); return; } } diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index dd55976a..13fc4955 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -156,10 +156,14 @@ class PingService { void Function(RxPing)? onRxPing; void Function(PingStats)? onStatsUpdated; - /// Called in real-time when each echo is received during tracking window + /// Called in real-time when each direct echo is received during tracking window /// Parameters: (TxPing txPing, HeardRepeater repeater, bool isNew) void Function(TxPing, HeardRepeater, bool isNew)? onEchoReceived; + /// Called in real-time when each multi-hop echo is received during tracking window + void Function(TxPing txPing, String repeaterId, double? snr, int? rssi, + List pathHops, bool isNew)? onMultiHopEchoReceived; + /// Callback for discovery events (Passive Mode) /// Fires immediately when disc ping is created (like onTxPing) void Function(DiscLogEntry)? onDiscPing; @@ -170,8 +174,11 @@ class PingService { onDiscNodeDiscovered; /// Callback when TX window ends (for noise floor graph) - /// Parameters: (bool success) - true if any repeaters heard, false if none - void Function(bool success)? onTxWindowComplete; + /// Parameters: (bool directSuccess, List multiHopEchoes) + void Function( + bool directSuccess, + List<({String repeaterId, double? snr, int? rssi, List pathHops})> + multiHopEchoes)? onTxWindowComplete; /// Callback when discovery window ends (for noise floor graph) /// Parameters: (bool success) - true if any nodes discovered, false if none @@ -650,6 +657,37 @@ class PingService { } }; + txTracker.onMultiHopEchoReceived = + (repeaterId, snr, rssi, pathHops, isNew) { + debugLog( + '[PING] Multi-hop echo: $repeaterId, SNR=$snr, hops=${pathHops.length}, isNew=$isNew'); + final txPing = _lastTxPing; + if (txPing != null) { + final repeater = HeardRepeater( + repeaterId: repeaterId, + snr: snr, + rssi: rssi, + seenCount: + txTracker.multiHopRepeaters[repeaterId]?.seenCount ?? 1, + pathHops: pathHops, + ); + + if (isNew) { + txPing.heardRepeaters.add(repeater); + } else { + final idx = txPing.heardRepeaters + .indexWhere((r) => r.repeaterId == repeaterId && + r.pathHops != null); + if (idx >= 0) { + txPing.heardRepeaters[idx] = repeater; + } + } + + onMultiHopEchoReceived?.call( + txPing, repeaterId, snr, rssi, pathHops, isNew); + } + }; + txTracker.startTracking( payload: pingMessage, channelIdx: channelIndex, @@ -758,11 +796,25 @@ class PingService { debugLog('[PING] No repeater echoes detected during listening window'); } + // Collect multi-hop echo data for the onTxWindowComplete callback + final multiHopEchoes = + <({String repeaterId, double? snr, int? rssi, List pathHops})>[]; + if (txTracker != null && txTracker.multiHopRepeaters.isNotEmpty) { + for (final entry in txTracker.multiHopRepeaters.entries) { + final echo = entry.value; + multiHopEchoes.add(( + repeaterId: echo.repeaterId, + snr: echo.snr, + rssi: echo.rssi, + pathHops: echo.pathHops, + )); + } + } + // Notify about TX window completion for noise floor graph - onTxWindowComplete?.call(txSuccess); + onTxWindowComplete?.call(txSuccess, multiHopEchoes); // Queue TX entry with heard_repeats AFTER RX window ends - // Reference: enqueueTX() called after RX window in wardrive.js final txTimestamp = _pendingTxTimestamp; if (txTimestamp != null) { _apiQueue.enqueueTx( @@ -776,6 +828,27 @@ class PingService { ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); + // Queue multi-hop echoes as individual RX API entries + if (multiHopEchoes.isNotEmpty) { + for (final echo in multiHopEchoes) { + final rxHeardRepeats = echo.snr != null + ? '${echo.repeaterId}(${echo.snr!.toStringAsFixed(2)})' + : '${echo.repeaterId}(null)'; + _apiQueue.enqueueRx( + latitude: txPosition.latitude, + longitude: txPosition.longitude, + heardRepeats: rxHeardRepeats, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + repeaterId: echo.repeaterId, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), + ); + } + debugLog( + '[PING] Queued ${multiHopEchoes.length} multi-hop echoes as RX'); + } + // Clear pending TX context _pendingTxTimestamp = null; _pendingTxNoiseFloor = null; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 2f6dfa74..77fd3dfd 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3989,6 +3989,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { PingEventType.discFail => (type: 'disc', success: false), PingEventType.traceSuccess => (type: 'trace', success: true), PingEventType.traceFail => (type: 'trace', success: false), + PingEventType.txMultiHopOnly => (type: 'tx', success: true), }; /// Compute a version hash of all data that affects the marker list. @@ -5773,6 +5774,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { switch (marker.type) { case PingEventType.txSuccess: case PingEventType.txFail: + case PingEventType.txMultiHopOnly: _showTxPingDetails(TxPing( latitude: lat, longitude: lon, @@ -5954,7 +5956,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; - // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) + // Resolve all repeaters (direct + multi-hop) for focus-mode lines final resolved = heardRepeaters.isNotEmpty ? _resolveRepeatersByHexIds( heardRepeaters.map((r) => r.repeaterId).toList(), @@ -6088,210 +6090,390 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), const SizedBox(height: 16), - // Repeaters section header - Text( - heardRepeaters.isEmpty - ? 'No repeaters heard' - : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), + // Split repeaters into direct and multi-hop + ..._buildTxRepeaterSections(context, ping, heardRepeaters, + resolved, hasAmbiguous), + ], + ), + ), + ), + ), + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } + + List _buildTxRepeaterSections( + BuildContext context, + TxPing ping, + List allRepeaters, + List<_ResolvedRepeater> resolved, + bool hasAmbiguous, + ) { + final directRepeaters = + allRepeaters.where((r) => r.pathHops == null).toList(); + final multiHopRepeaters = + allRepeaters.where((r) => r.pathHops != null).toList(); + + if (directRepeaters.isEmpty && multiHopRepeaters.isEmpty) { + return [ + Text( + 'No repeaters heard', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ]; + } + + final widgets = []; + + // --- Direct echoes section --- + widgets.add(Text( + directRepeaters.isEmpty + ? 'No direct repeats heard' + : 'Direct Repeats (${directRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontStyle: + directRepeaters.isEmpty ? FontStyle.italic : FontStyle.normal, + ), + )); + + if (hasAmbiguous) { + widgets.add(GestureDetector( + onTap: () => _showDuplicateRepeaterPopup( + resolved, + fromLatLng: (lat: ping.latitude, lon: ping.longitude), + ), + child: const Padding( + padding: EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 14, color: Color(0xFFF59E0B)), + SizedBox(width: 4), + Flexible( + child: Text( + 'Duplicate repeater ID — lines shown to all possible matches', + style: TextStyle(fontSize: 11, color: Color(0xFFF59E0B)), ), + ), + SizedBox(width: 4), + Icon(Icons.info_outline, size: 14, color: Color(0xFFF59E0B)), + ], + ), + ), + )); + } - if (hasAmbiguous) - GestureDetector( - onTap: () => _showDuplicateRepeaterPopup( - resolved, - fromLatLng: ( - lat: ping.latitude, - lon: ping.longitude, - ), - ), - child: const Padding( - padding: EdgeInsets.only(top: 6), + if (directRepeaters.isNotEmpty) { + widgets.add(const SizedBox(height: 12)); + widgets.add(_buildRepeaterTable(context, ping, directRepeaters)); + } + + // --- Multi-hop echoes section --- + if (multiHopRepeaters.isNotEmpty) { + widgets.add(const SizedBox(height: 16)); + widgets.add(Row( + children: [ + Icon(Icons.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), + Text( + 'Multi-hop Repeats (${multiHopRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ], + )); + widgets.add(const SizedBox(height: 12)); + widgets.add(_buildMultiHopRepeaterTable( + context, ping, multiHopRepeaters)); + } + + return widgets; + } + + Widget _buildRepeaterTable( + BuildContext context, TxPing ping, List repeaters) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = + repeaters.any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text('Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...repeaters.map((repeater) { + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; + final lacksLocation = _hexIdLacksLocation(repeater.repeaterId); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, child: Row( children: [ - Icon(Icons.warning_amber_rounded, - size: 14, color: Color(0xFFF59E0B)), - SizedBox(width: 4), - Flexible( - child: Text( - 'Duplicate repeater ID — lines shown to all possible matches', - style: TextStyle( - fontSize: 11, - color: Color(0xFFF59E0B), - ), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), ), - ), - SizedBox(width: 4), - Icon(Icons.info_outline, - size: 14, color: Color(0xFFF59E0B)), ], ), ), - ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ); + } - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // If any heard repeater is missing GPS, reserve a sliver of - // node-column width for the inline `location_off` indicator - // so SNR/RSSI columns stay aligned row-to-row. - Builder(builder: (context) { - final chipWidth = _nodeColumnWidth(); - final anyLacksLocation = heardRepeaters - .any((hr) => _hexIdLacksLocation(hr.repeaterId)); - final iconReserve = anyLacksLocation ? 18.0 : 0.0; - final nodeColWidth = chipWidth + iconReserve; - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( + Widget _buildMultiHopRepeaterTable( + BuildContext context, TxPing ping, List repeaters) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = + repeaters.any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text('Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, color: Theme.of(context) .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( + .onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...repeaters.map((repeater) { + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; + final lacksLocation = _hexIdLacksLocation(repeater.repeaterId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), + SizedBox( + width: nodeColWidth, child: Row( children: [ - SizedBox( - width: nodeColWidth, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), ], ), ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null - ? PingColors.snrColor(repeater.snr!) - : Colors.grey; - final rssiColor = repeater.rssi != null - ? PingColors.rssiColor(repeater.rssi!) - : Colors.grey; - final lacksLocation = - _hexIdLacksLocation(repeater.repeaterId); - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup( - context, repeater.repeaterId, fromLatLng: ( - lat: ping.latitude, - lon: ping.longitude - )), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - child: Row( - children: [ - // Repeater ID + optional no-location icon, - // pinned to the node column width. - SizedBox( - width: nodeColWidth, - child: Row( - children: [ - RepeaterIdChip( - repeaterId: repeater.repeaterId, - fontSize: 13, - width: chipWidth), - if (lacksLocation) - Padding( - padding: const EdgeInsets.only( - left: 4), - child: _noLocationIndicator(), - ), - ], - ), - ), - // SNR - Expanded( - child: Center( - child: _buildStatChip( - value: - repeater.snr?.toStringAsFixed(1) ?? - '-', - color: snrColor, - ), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: repeater.rssi != null - ? '${repeater.rssi}' - : '-', - color: rssiColor, - ), - ), - ), - ], - ), + Expanded( + child: Center( + child: _buildStatChip( + value: + repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, ), - ); - }), + ), + ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', + color: rssiColor, + ), + ), + ), ], ), - ); - }), - ], + ), + ), + if (repeater.pathHops != null && repeater.pathHops!.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 12, right: 12, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.route, + size: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + const SizedBox(width: 6), + Expanded( + child: RxPathChain( + hops: repeater.pathHops!, fontSize: 11), + ), + ], + ), + ), ], - ), - ), - ), + ); + }), + ], ), - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); + ); } /// Show RX ping details popup diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 77510bc3..312d6a13 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -6,6 +6,7 @@ import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; +import 'rx_path_chain.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan class InteractiveNoiseFloorChart extends StatefulWidget { @@ -239,6 +240,7 @@ class InteractiveNoiseFloorChartState final eventTypeLabel = switch (marker.type) { PingEventType.txSuccess => 'TX Success', PingEventType.txFail => 'TX Failed', + PingEventType.txMultiHopOnly => 'TX Multi-hop', PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', @@ -249,6 +251,8 @@ class InteractiveNoiseFloorChartState final eventDescription = switch (marker.type) { PingEventType.txSuccess => 'Ping was heard by repeater(s)', PingEventType.txFail => 'Ping was not heard by any repeater', + PingEventType.txMultiHopOnly => + 'Ping was heard via multi-hop only (no direct)', PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', @@ -357,79 +361,10 @@ class InteractiveNoiseFloorChartState ], ), - // Repeaters section (table format like TX log) + // Repeaters section — split into direct and multi-hop if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...marker.repeaters! - .map((r) => _buildRepeaterRow(context, r)), - ], - ), - ), + ..._buildMarkerRepeaterSections(context, marker.repeaters!), ], // View on Map button @@ -525,6 +460,152 @@ class InteractiveNoiseFloorChartState ); } + /// Build direct and multi-hop repeater sections for the marker detail sheet + List _buildMarkerRepeaterSections( + BuildContext context, List repeaters) { + final directRepeaters = + repeaters.where((r) => r.pathHops == null).toList(); + final multiHopRepeaters = + repeaters.where((r) => r.pathHops != null).toList(); + + final widgets = []; + + Widget buildTableHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 50, + child: Text('Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + ], + ), + ); + } + + // Direct echoes table + if (directRepeaters.isNotEmpty) { + widgets.addAll([ + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + buildTableHeader(), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...directRepeaters.map((r) => _buildRepeaterRow(context, r)), + ], + ), + ), + ]); + } + + // Multi-hop echoes section + if (multiHopRepeaters.isNotEmpty) { + widgets.addAll([ + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon(Icons.route, size: 12, color: PingColors.rx), + const SizedBox(width: 6), + Text('Multi-hop Repeats', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + buildTableHeader(), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...multiHopRepeaters.map((r) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRepeaterRow(context, r), + if (r.pathHops != null && r.pathHops!.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, right: 10, bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.route, + size: 10, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + const SizedBox(width: 4), + Expanded( + child: RxPathChain( + hops: r.pathHops!, + fontSize: 9, + ), + ), + ], + ), + ), + ], + )), + ], + ), + ), + ]); + } + + return widgets; + } + /// Build a table row for a repeater (matching TX log style) Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { final snrColor = PingColors.snrColor(repeater.snr); From fd619b70a786b75f790feeb45446a8498d0ebc6b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 26 May 2026 10:42:41 -0400 Subject: [PATCH 121/360] Enhance map widget: add multi-hop only icon and regional boundary toggle --- lib/widgets/map_widget.dart | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 77fd3dfd..d51825e2 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3240,6 +3240,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } else { // TX pings for (final ping in appState.txPings) { + final hasDirectEcho = + ping.heardRepeaters.any((r) => r.pathHops == null); + final hasMultiHopOnly = + !hasDirectEcho && ping.heardRepeaters.isNotEmpty; await syncOne( type: 'tx', lat: ping.latitude, @@ -3247,6 +3251,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ts: ping.timestamp, success: ping.heardRepeaters.isNotEmpty, idForMetadata: ping.timestamp.millisecondsSinceEpoch, + iconImageOverride: + hasMultiHopOnly ? _MapImages.coverage('rx', true) : null, ); } @@ -3989,7 +3995,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { PingEventType.discFail => (type: 'disc', success: false), PingEventType.traceSuccess => (type: 'trace', success: true), PingEventType.traceFail => (type: 'trace', success: false), - PingEventType.txMultiHopOnly => (type: 'tx', success: true), + PingEventType.txMultiHopOnly => (type: 'rx', success: true), }; /// Compute a version hash of all data that affects the marker list. @@ -4885,6 +4891,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { description: 'Prevent accidental rotation of the map', ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.fence, + label: 'Region Boundary', + description: + 'Toggle the regional boundary outline and labels on the map', + ), Divider( height: 1, color: Theme.of(context) From 804fa9f4d8bb434c4b82176032909dc5a3f45208 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 26 May 2026 12:57:12 -0400 Subject: [PATCH 122/360] Refactor map widget to improve tile load failure handling and sync focus lines --- lib/services/meshcore/trace_tracker.dart | 8 ++- lib/widgets/map_widget.dart | 91 ++++++++++++++++-------- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart index 265c6e42..6d2ddcdf 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -214,8 +214,12 @@ class TraceTracker { onWindowComplete?.call(result); } - /// Dispose of resources + /// Dispose of resources — fires onWindowComplete if a window was active void dispose() { - stopTracking(); + if (isListening) { + _endWindow(); + } else { + stopTracking(); + } } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d51825e2..5d04a09d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -443,7 +443,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. - bool _tileLoadFailed = false; + // Only surfaces the banner after 3 consecutive timeouts to avoid confusing + // users with transient failures. + int _consecutiveTileLoadFailures = 0; + static const _tileLoadFailureThreshold = 3; /// Tracks the last-applied mapTilesEnabled value so we can detect changes /// in _buildMap and call setOffline() without a full style reload. @@ -490,8 +493,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // style-reload path can drop stale names from the map's image cache if ever // needed. Right now we just re-addImage on each sync (idempotent). final Set _registeredDistanceLabelImages = {}; + final Map _registeredDistanceLabelImageSizes = {}; Symbol? _gpsSymbol; // single GPS marker + // When true, _syncAllAnnotations skips _updateFocusLines and + // _syncDistanceLabels so the 500ms zoom-to-fit animation runs without + // contention from heavy native platform calls. The deferred work runs + // after the animation settles via _activatePingFocus's delayed callback. + bool _focusSyncDeferred = false; + // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) static const _repeaterSourceId = 'repeaters-source'; static const _repeaterIndividualLayerId = 'repeaters-individual'; @@ -1177,6 +1187,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugError('[MAP] _syncAllAnnotations failed: $e'); } finally { _syncInFlight = false; + if (mounted) { + final freshVersion = _computeMarkerDataVersion(appState); + if (freshVersion != _lastMarkerDataVersion) { + setState(() {}); + } + } } }); } @@ -1259,9 +1275,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildCollapsibleMapControls(appState), ), - // Tile load failure banner — appears if base tiles haven't finished - // loading within ${_tileLoadTimeoutSeconds}s after style load. - if (_tileLoadFailed) + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) Positioned( top: topPadding, left: 0, @@ -2027,6 +2041,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _distanceLabelImageSize.clear(); _distanceLabelRepeaterPos.clear(); _registeredDistanceLabelImages.clear(); + _registeredDistanceLabelImageSizes.clear(); // Mark cluster layers as not-ready until _setupRepeaterClusterLayers // creates them on the new style. This gates build()-driven post-frame // syncs from racing ahead of source creation. @@ -2085,18 +2100,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Ensure MapLibre offline mode matches the user's preference. _setOfflineIfSupported(!tilesEnabled); if (tilesEnabled) { - _tileLoadFailed = false; _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { - if (mounted && !_tileLoadFailed) { - debugWarn( - '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); - setState(() => _tileLoadFailed = true); + if (!mounted) return; + _consecutiveTileLoadFailures++; + debugWarn( + '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s ($_consecutiveTileLoadFailures/$_tileLoadFailureThreshold)'); + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { + appState.logError( + 'Map tiles unavailable — check connection', + severity: ErrorSeverity.warning, + autoSwitch: false, + ); + setState(() {}); } }); } else { - // Cache-only mode — never show the tile-load warning - _tileLoadFailed = false; + _consecutiveTileLoadFailures = 0; } // First-load-only setup: center on GPS and register camera listener. @@ -2154,9 +2174,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// that the new tiles have rendered. void _onMapIdle() { _tileLoadTimeoutTimer?.cancel(); - if (_tileLoadFailed && mounted) { - debugLog('[MAP] Tiles recovered after earlier load failure'); - setState(() => _tileLoadFailed = false); + if (_consecutiveTileLoadFailures > 0 && mounted) { + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { + debugLog('[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); + } + _consecutiveTileLoadFailures = 0; + setState(() {}); } } @@ -3771,21 +3794,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final rendered = await _renderDistanceLabelPng(labelText); await _mapController!.addImage(imageName, rendered.bytes); _registeredDistanceLabelImages.add(imageName); + _registeredDistanceLabelImageSizes[imageName] = rendered.size; imageSize = rendered.size; } catch (e) { debugError('[MAP] render/addImage(distance label) failed: $e'); } } - // If we didn't just render (reuse case) we still need the size for - // collision tests. Re-render for measurement; this is cheap and rare. - if (imageSize == null) { - try { - final rendered = await _renderDistanceLabelPng(labelText); - imageSize = rendered.size; - } catch (_) { - imageSize = const Size(60, 18); - } - } + imageSize ??= _registeredDistanceLabelImageSizes[imageName] ?? + const Size(60, 18); _distanceLabelImageSize[key] = imageSize; _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); @@ -3940,8 +3956,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _syncRepeaterSymbols(appState); await _syncCoverageSymbols(appState); await _syncGpsSymbol(appState); - await _updateFocusLines(); - await _syncDistanceLabels(appState); + if (!_focusSyncDeferred) { + await _updateFocusLines(); + await _syncDistanceLabels(appState); + } } /// Fit camera to show all history session markers @@ -4015,6 +4033,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { for (final e in appState.discLogEntries) { discNodeTotal += e.discoveredNodes.length; } + int traceSuccessTotal = 0; + for (final t in appState.traceLogEntries) { + if (t.success) traceSuccessTotal++; + } return Object.hash( appState.txPings.length, @@ -4032,6 +4054,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.preferences.markerStyle, txEchoTotal, discNodeTotal, + traceSuccessTotal, appState.viewingHistorySession, appState.historySessionMarkers?.length ?? 0, ); @@ -5590,6 +5613,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusPanelMinimized = false; + // Defer focus lines and distance labels so the 500ms zoom-to-fit + // animation runs without contention from heavy native platform calls. + _focusSyncDeferred = true; + setState(() { _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; @@ -5607,12 +5634,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } }); - // Once the 500ms zoom-to-fit animation settles, re-flow the distance - // labels so any that collide on screen slide along their lines to a - // non-overlapping slot. 600ms gives the camera a bit of buffer beyond - // the animation duration. - Future.delayed(const Duration(milliseconds: 600), () { + // Once the 500ms zoom-to-fit animation settles, run the deferred focus + // line and distance label sync, then reflow labels for collisions. + final appState = context.read(); + Future.delayed(const Duration(milliseconds: 600), () async { if (!mounted || _focusedPingLocation == null) return; + _focusSyncDeferred = false; + await _updateFocusLines(); + await _syncDistanceLabels(appState); _reflowDistanceLabelsForCollisions(); }); } From 0bbb6fae5c89b6b18a5c250e6aaba6d4fdf755ca Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 11:41:50 -0400 Subject: [PATCH 123/360] feat: The app now sends your radio preset to the server on connect, and every ping is tagged with it. You can now filter region coverage by radio preset. --- ios/Flutter/ephemeral/flutter_lldb_helper.py | 32 ---------- ios/Flutter/ephemeral/flutter_lldbinit | 5 -- ios/Podfile.lock | 40 ------------ lib/providers/app_state_provider.dart | 16 +++++ lib/screens/connection_screen.dart | 43 +++++++++++++ lib/services/api_service.dart | 2 + lib/services/meshcore/connection.dart | 66 +++++++++++++++++--- lib/services/offline_session_service.dart | 15 +++++ 8 files changed, 135 insertions(+), 84 deletions(-) delete mode 100644 ios/Flutter/ephemeral/flutter_lldb_helper.py delete mode 100644 ios/Flutter/ephemeral/flutter_lldbinit delete mode 100644 ios/Podfile.lock diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py deleted file mode 100644 index a88caf99..00000000 --- a/ios/Flutter/ephemeral/flutter_lldb_helper.py +++ /dev/null @@ -1,32 +0,0 @@ -# -# Generated file, do not edit. -# - -import lldb - -def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): - """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" - base = frame.register["x0"].GetValueAsAddress() - page_len = frame.register["x1"].GetValueAsUnsigned() - - # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the - # first page to see if handled it correctly. This makes diagnosing - # misconfiguration (e.g. missing breakpoint) easier. - data = bytearray(page_len) - data[0:8] = b'IHELPED!' - - error = lldb.SBError() - frame.GetThread().GetProcess().WriteMemory(base, data, error) - if not error.Success(): - print(f'Failed to write into {base}[+{page_len}]', error) - return - -def __lldb_init_module(debugger: lldb.SBDebugger, _): - target = debugger.GetDummyTarget() - # Caveat: must use BreakpointCreateByRegEx here and not - # BreakpointCreateByName. For some reasons callback function does not - # get carried over from dummy target for the later. - bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") - bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) - bp.SetAutoContinue(True) - print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit deleted file mode 100644 index e3ba6fbe..00000000 --- a/ios/Flutter/ephemeral/flutter_lldbinit +++ /dev/null @@ -1,5 +0,0 @@ -# -# Generated file, do not edit. -# - -command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/ios/Podfile.lock b/ios/Podfile.lock deleted file mode 100644 index 3eb0bde7..00000000 --- a/ios/Podfile.lock +++ /dev/null @@ -1,40 +0,0 @@ -PODS: - - disk_space_plus (0.0.1): - - Flutter - - Flutter (1.0.0) - - flutter_background_service_ios (0.0.3): - - Flutter - - flutter_local_notifications (0.0.1): - - Flutter - - permission_handler_apple (9.3.0): - - Flutter - -DEPENDENCIES: - - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - - Flutter (from `Flutter`) - - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - -EXTERNAL SOURCES: - disk_space_plus: - :path: ".symlinks/plugins/disk_space_plus/ios" - Flutter: - :path: Flutter - flutter_background_service_ios: - :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_local_notifications: - :path: ".symlinks/plugins/flutter_local_notifications/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" - -SPEC CHECKSUMS: - disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e - -COCOAPODS: 1.16.2 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0ca6c386..5fa51982 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -459,6 +459,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; String? get firmwareVersionString => _firmwareVersionString; + + /// Human-readable radio config from the connected device's SelfInfo + /// (e.g. "910.525 MHz · 62.5 kHz · SF7 · CR5"); null on older firmware/no device. + String? get radioConfigDisplay => + _meshCoreConnection?.selfInfo?.radioConfigDisplay; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -1303,6 +1308,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1386,6 +1392,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -4204,6 +4211,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4276,6 +4284,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4431,6 +4440,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); @@ -4458,6 +4468,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); } @@ -4607,6 +4618,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: 'Offline Upload', + radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -4637,6 +4649,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: 'Offline Upload', + radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -5184,6 +5197,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); debugLog( '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); @@ -5962,6 +5976,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -6018,6 +6033,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 14721329..798ae70c 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -660,6 +660,12 @@ class _ConnectionScreenState extends State const SizedBox(height: 4), _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + // Radio config row (freq/bandwidth/SF/CR), under Power Level + if (appState.radioConfigDisplay != null) ...[ + const SizedBox(height: 4), + _buildRadioRow(context, appState.radioConfigDisplay!), + ], + // Public key row if (appState.devicePublicKey != null) ...[ const SizedBox(height: 8), @@ -805,6 +811,43 @@ class _ConnectionScreenState extends State ); } + /// Radio configuration row (frequency/bandwidth/SF/CR), shown under Power Level. + /// Read-only — the app only reports the device's radio settings, never changes them. + Widget _buildRadioRow(BuildContext context, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const SizedBox( + width: 120, + child: Text( + 'Radio', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.radio, size: 16, color: Colors.blue.shade400), + const SizedBox(width: 4), + Flexible( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + /// Small detail chip with icon + text Widget _buildDetailChip(BuildContext context, IconData icon, String text) { final theme = Theme.of(context); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index d6ba1e2b..f451813d 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -318,6 +318,7 @@ class ApiService { double? power, String? iataCode, String? model, + String? radioFreq, double? lat, double? lon, double? accuracyMeters, @@ -359,6 +360,7 @@ class ApiService { } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; + if (radioFreq != null) payload['radio_freq'] = radioFreq; payload['coords'] = { 'lat': lat, 'lng': lon, // Convert lon → lng for API diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 7bdecd5c..9d3f014f 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -40,12 +40,24 @@ class SelfInfo { final Uint8List publicKey; final String name; + /// Radio configuration reported in the SelfInfo response (newer firmware only; + /// null on older firmware that omits the radio block). Units are as sent by the + /// MeshCore companion protocol: frequency and bandwidth in Hz, SF/CR raw. + final int? radioFreqHz; + final int? radioBwHz; + final int? radioSf; + final int? radioCr; + const SelfInfo({ required this.type, required this.txPower, required this.maxTxPower, required this.publicKey, required this.name, + this.radioFreqHz, + this.radioBwHz, + this.radioSf, + this.radioCr, }); /// Get public key as hex string @@ -53,6 +65,37 @@ class SelfInfo { .map((b) => b.toRadixString(16).padLeft(2, '0')) .join('') .toUpperCase(); + + /// Whether the device reported a usable radio configuration. + bool get hasRadioConfig => radioFreqHz != null && radioFreqHz! > 0; + + /// Compact radio config for the API: "freqMHz,bwKHz,SF,CR" (e.g. "910.525,62.5,7,5"). + /// Frequency Hz→MHz, bandwidth Hz→kHz. Null when the device didn't report radio params. + String? get radioConfigApi { + if (!hasRadioConfig) return null; + final freq = _trimNum(radioFreqHz! / 1e6); + final bw = _trimNum((radioBwHz ?? 0) / 1e3); + return '$freq,$bw,${radioSf ?? 0},${radioCr ?? 0}'; + } + + /// Human-readable radio config for the UI: "910.525 MHz · 62.5 kHz · SF7 · CR5". + /// Null when unavailable. + String? get radioConfigDisplay { + if (!hasRadioConfig) return null; + final freq = _trimNum(radioFreqHz! / 1e6); + final bw = _trimNum((radioBwHz ?? 0) / 1e3); + return '$freq MHz · $bw kHz · SF${radioSf ?? 0} · CR${radioCr ?? 0}'; + } + + /// Format a number with up to 3 decimals, trimming trailing zeros and a trailing dot + /// (910.525 → "910.525", 62.5 → "62.5", 915.0 → "915"). + static String _trimNum(double v) { + var s = v.toStringAsFixed(3); + if (s.contains('.')) { + s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } + return s; + } } /// MeshCore connection manager @@ -586,17 +629,22 @@ class MeshCoreConnection { final maxTxPower = reader.readByte(); final publicKey = reader.readBytes(32); - // Skip additional fields added in newer firmware versions - // These fields exist between publicKey and name + // Additional fields added in newer firmware versions, between publicKey and name. + // radioFreq/radioBw are uint32 Hz; radioSf/radioCr are single bytes (MeshCore + // companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. + int? radioFreqHz; + int? radioBwHz; + int? radioSf; + int? radioCr; if (reader.remainingBytesCount >= 22) { reader.readInt32LE(); // advLat reader.readInt32LE(); // advLon reader.readBytes(3); // reserved reader.readByte(); // manualAddContacts - reader.readUInt32LE(); // radioFreq - reader.readUInt32LE(); // radioBw - reader.readByte(); // radioSf - reader.readByte(); // radioCr + radioFreqHz = reader.readUInt32LE(); // radioFreq (Hz) + radioBwHz = reader.readUInt32LE(); // radioBw (Hz) + radioSf = reader.readByte(); // radioSf + radioCr = reader.readByte(); // radioCr } // Read name from remaining bytes @@ -608,11 +656,15 @@ class MeshCoreConnection { maxTxPower: maxTxPower, publicKey: publicKey, name: name, + radioFreqHz: radioFreqHz, + radioBwHz: radioBwHz, + radioSf: radioSf, + radioCr: radioCr, ); _selfInfo = selfInfo; debugLog( - '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); + '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}..., radio=${selfInfo.radioConfigApi ?? "n/a"}'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index f2958595..b7ba554b 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -13,6 +13,7 @@ class OfflineSession { final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display final String? contactUri; // Signed contact URI for registration during upload + final String? radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") final bool uploaded; // Track upload status OfflineSession({ @@ -23,6 +24,7 @@ class OfflineSession { this.devicePublicKey, this.deviceName, this.contactUri, + this.radioConfig, this.uploaded = false, }); @@ -36,6 +38,7 @@ class OfflineSession { devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, contactUri: json['contactUri'] as String?, + radioConfig: json['radioConfig'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -50,6 +53,7 @@ class OfflineSession { 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, 'contactUri': contactUri, + 'radioConfig': radioConfig, 'uploaded': uploaded, }; } @@ -68,6 +72,7 @@ class OfflineSession { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, uploaded: uploaded ?? this.uploaded, ); } @@ -161,6 +166,7 @@ class OfflineSessionService { String? devicePublicKey, String? deviceName, String? contactUri, + String? radioConfig, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -178,6 +184,7 @@ class OfflineSessionService { 'pings': pings, if (devicePublicKey != null) 'device_public_key': devicePublicKey, if (deviceName != null) 'device_name': deviceName, + if (radioConfig != null) 'radio_config': radioConfig, }; final session = OfflineSession( @@ -188,6 +195,7 @@ class OfflineSessionService { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -205,6 +213,7 @@ class OfflineSessionService { String? devicePublicKey, String? deviceName, String? contactUri, + String? radioConfig, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to auto-save, skipping'); @@ -220,6 +229,10 @@ class OfflineSessionService { final updatedData = Map.from(existing.data); updatedData['pings'] = pings; updatedData['ping_count'] = pings.length; + final effectiveRadioConfig = radioConfig ?? existing.radioConfig; + if (effectiveRadioConfig != null) { + updatedData['radio_config'] = effectiveRadioConfig; + } _sessions[index] = OfflineSession( filename: existing.filename, @@ -229,6 +242,7 @@ class OfflineSessionService { devicePublicKey: devicePublicKey ?? existing.devicePublicKey, deviceName: deviceName ?? existing.deviceName, contactUri: contactUri ?? existing.contactUri, + radioConfig: effectiveRadioConfig, ); await _saveSessions(); debugLog( @@ -247,6 +261,7 @@ class OfflineSessionService { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, ); // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { From 3cb8b174c0b96c91c390f32db66841e10d9205a9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 12:46:12 -0400 Subject: [PATCH 124/360] Fixed formatting of radio preset in app --- ios/Podfile.lock | 40 +++++++++++++++++++++++++++ lib/screens/connection_screen.dart | 23 ++++++++------- lib/services/meshcore/connection.dart | 34 +++++++++++++---------- 3 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 ios/Podfile.lock diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 00000000..3eb0bde7 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,40 @@ +PODS: + - disk_space_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_background_service_ios (0.0.3): + - Flutter + - flutter_local_notifications (0.0.1): + - Flutter + - permission_handler_apple (9.3.0): + - Flutter + +DEPENDENCIES: + - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) + - Flutter (from `Flutter`) + - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + +EXTERNAL SOURCES: + disk_space_plus: + :path: ".symlinks/plugins/disk_space_plus/ios" + Flutter: + :path: Flutter + flutter_background_service_ios: + :path: ".symlinks/plugins/flutter_background_service_ios/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + +SPEC CHECKSUMS: + disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e + flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 798ae70c..4f8898b6 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -813,12 +813,19 @@ class _ConnectionScreenState extends State /// Radio configuration row (frequency/bandwidth/SF/CR), shown under Power Level. /// Read-only — the app only reports the device's radio settings, never changes them. + /// The value ("910.525 MHz · 62.5 kHz · SF7 · CR5") is split onto two tidy lines — + /// freq + bandwidth, then SF + CR — so it never wraps mid-value in the narrow card. Widget _buildRadioRow(BuildContext context, String value) { + final parts = value.split(' · '); + final line1 = parts.length >= 2 ? parts.take(2).join(' · ') : value; + final line2 = parts.length > 2 ? parts.skip(2).join(' · ') : ''; + const valueStyle = TextStyle(fontWeight: FontWeight.w500); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox( width: 120, @@ -827,18 +834,14 @@ class _ConnectionScreenState extends State style: TextStyle(fontWeight: FontWeight.w500), ), ), + Icon(Icons.radio, size: 16, color: Colors.blue.shade400), + const SizedBox(width: 4), Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.radio, size: 16, color: Colors.blue.shade400), - const SizedBox(width: 4), - Flexible( - child: Text( - value, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ), + Text(line1, style: valueStyle), + if (line2.isNotEmpty) Text(line2, style: valueStyle), ], ), ), diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 9d3f014f..355c328b 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -41,9 +41,10 @@ class SelfInfo { final String name; /// Radio configuration reported in the SelfInfo response (newer firmware only; - /// null on older firmware that omits the radio block). Units are as sent by the - /// MeshCore companion protocol: frequency and bandwidth in Hz, SF/CR raw. - final int? radioFreqHz; + /// null on older firmware that omits the radio block). Encoding as sent by the device: + /// frequency in kHz, bandwidth in Hz, SF/CR raw. (The companion-protocol wiki documents + /// freq as Hz, but real hardware reports kHz — a 910.525 MHz radio sends 910525.) + final int? radioFreqKHz; final int? radioBwHz; final int? radioSf; final int? radioCr; @@ -54,7 +55,7 @@ class SelfInfo { required this.maxTxPower, required this.publicKey, required this.name, - this.radioFreqHz, + this.radioFreqKHz, this.radioBwHz, this.radioSf, this.radioCr, @@ -67,13 +68,13 @@ class SelfInfo { .toUpperCase(); /// Whether the device reported a usable radio configuration. - bool get hasRadioConfig => radioFreqHz != null && radioFreqHz! > 0; + bool get hasRadioConfig => radioFreqKHz != null && radioFreqKHz! > 0; /// Compact radio config for the API: "freqMHz,bwKHz,SF,CR" (e.g. "910.525,62.5,7,5"). - /// Frequency Hz→MHz, bandwidth Hz→kHz. Null when the device didn't report radio params. + /// Frequency kHz→MHz (÷1000), bandwidth Hz→kHz (÷1000). Null when no radio params. String? get radioConfigApi { if (!hasRadioConfig) return null; - final freq = _trimNum(radioFreqHz! / 1e6); + final freq = _trimNum(radioFreqKHz! / 1e3); final bw = _trimNum((radioBwHz ?? 0) / 1e3); return '$freq,$bw,${radioSf ?? 0},${radioCr ?? 0}'; } @@ -82,7 +83,7 @@ class SelfInfo { /// Null when unavailable. String? get radioConfigDisplay { if (!hasRadioConfig) return null; - final freq = _trimNum(radioFreqHz! / 1e6); + final freq = _trimNum(radioFreqKHz! / 1e3); final bw = _trimNum((radioBwHz ?? 0) / 1e3); return '$freq MHz · $bw kHz · SF${radioSf ?? 0} · CR${radioCr ?? 0}'; } @@ -629,10 +630,11 @@ class MeshCoreConnection { final maxTxPower = reader.readByte(); final publicKey = reader.readBytes(32); - // Additional fields added in newer firmware versions, between publicKey and name. - // radioFreq/radioBw are uint32 Hz; radioSf/radioCr are single bytes (MeshCore - // companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. - int? radioFreqHz; + // Additional fields added in newer firmware versions, between publicKey and name + // (MeshCore companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. + // Encoding note: the wiki documents radioFreq as uint32 Hz, but real hardware reports + // it in kHz (a 910.525 MHz radio sends 910525); radioBw is uint32 Hz; SF/CR are bytes. + int? radioFreqKHz; int? radioBwHz; int? radioSf; int? radioCr; @@ -641,7 +643,7 @@ class MeshCoreConnection { reader.readInt32LE(); // advLon reader.readBytes(3); // reserved reader.readByte(); // manualAddContacts - radioFreqHz = reader.readUInt32LE(); // radioFreq (Hz) + radioFreqKHz = reader.readUInt32LE(); // radioFreq (kHz on real hardware) radioBwHz = reader.readUInt32LE(); // radioBw (Hz) radioSf = reader.readByte(); // radioSf radioCr = reader.readByte(); // radioCr @@ -656,7 +658,7 @@ class MeshCoreConnection { maxTxPower: maxTxPower, publicKey: publicKey, name: name, - radioFreqHz: radioFreqHz, + radioFreqKHz: radioFreqKHz, radioBwHz: radioBwHz, radioSf: radioSf, radioCr: radioCr, @@ -665,6 +667,10 @@ class MeshCoreConnection { _selfInfo = selfInfo; debugLog( '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}..., radio=${selfInfo.radioConfigApi ?? "n/a"}'); + // Raw radio values straight off the device — surfaces the actual encoding in the + // downloadable debug log (diagnoses any future unit questions). + debugLog( + '[CONN] Radio raw: freqKHz=$radioFreqKHz bwHz=$radioBwHz sf=$radioSf cr=$radioCr → ${selfInfo.radioConfigApi ?? "n/a"}'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; From a8805dd5050515785c2bfdf0878b89043568cfa1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 14:11:46 -0400 Subject: [PATCH 125/360] Privacy: your location is no longer broadcast over the air - TX pings now send a short opaque tag (e.g. "MM:zpCFQwc") on the #wardriving channel instead of your GPS coordinates. Your location still reaches the server over the API, it's just no longer readable off the air by anyone with the channel key. - New "Broadcast My Coordinates" toggle (Settings -> Ping Settings, off by default) lets you opt back into putting your real coordinates on the air, now sent as a compact "MM:lat,lon". - Very long sessions now end cleanly: when the per-session ping limit is reached the app uploads any pending pings, then disconnects with "Reached session limit, please reconnect." --- lib/models/api_queue_item.dart | 18 +++ lib/models/user_preferences.dart | 11 ++ lib/providers/app_state_provider.dart | 37 ++++++ lib/screens/settings_screen.dart | 9 ++ lib/services/api_queue_service.dart | 4 + lib/services/api_service.dart | 26 +++++ lib/services/meshcore/connection.dart | 16 ++- lib/services/meshcore/wire_tag_codec.dart | 132 ++++++++++++++++++++++ lib/services/ping_service.dart | 55 ++++++++- test/services/wire_tag_codec_test.dart | 82 ++++++++++++++ 10 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 lib/services/meshcore/wire_tag_codec.dart create mode 100644 test/services/wire_tag_codec_test.dart diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 3a197358..472c4c8f 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -56,6 +56,14 @@ class ApiQueueItem extends HiveObject { @HiveField(15) final double? power; + /// TX wire-tag ping counter (token mode only; null otherwise). + @HiveField(16) + final int? pingCounter; + + /// TX wire-tag body sent on the air, e.g. "MM:FlmLG4I" (token mode only; null otherwise). + @HiveField(17) + final String? wireTag; + ApiQueueItem({ required this.type, required this.latitude, @@ -68,6 +76,8 @@ class ApiQueueItem extends HiveObject { this.lastRetryAt, this.noiseFloor, this.power, + this.pingCounter, + this.wireTag, }); /// Create from TX ping @@ -80,6 +90,8 @@ class ApiQueueItem extends HiveObject { required bool externalAntenna, int? noiseFloor, double? power, + int? pingCounter, + String? wireTag, }) { return ApiQueueItem( type: 'TX', @@ -92,6 +104,8 @@ class ApiQueueItem extends HiveObject { externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); } @@ -269,6 +283,10 @@ class ApiQueueItem extends HiveObject { timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, + // Token-mode TX only. Their presence selects the server's validated path; + // absence (coords mode / RX) is the unchanged-from-today coords path. + if (pingCounter != null) 'ping_counter': pingCounter, + if (wireTag != null) 'wire_tag': wireTag, }; } diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index d26d4c23..eb8c2e2a 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -70,6 +70,10 @@ class UserPreferences { /// Anonymous mode: rename companion to "Anonymous" during wardriving final bool anonymousMode; + /// Broadcast coordinates: when true, TX pings put real GPS on the air (legacy + /// behavior). Default false = privacy-preserving wire tag; coords go via API only. + final bool broadcastCoords; + /// Discovery drop: count failed discoveries as failed pings and report to API final bool discDropEnabled; @@ -149,6 +153,7 @@ class UserPreferences { this.mapRotationLocked = false, this.disableRssiFilter = false, this.anonymousMode = false, + this.broadcastCoords = false, this.discDropEnabled = false, this.floodTrafficEnabled = false, this.deleteChannelOnDisconnect = true, @@ -195,6 +200,7 @@ class UserPreferences { mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, + broadcastCoords: (json['broadcastCoords'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, floodTrafficEnabled: (json['floodTrafficEnabled'] as bool?) ?? false, deleteChannelOnDisconnect: @@ -253,6 +259,7 @@ class UserPreferences { 'mapRotationLocked': mapRotationLocked, 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, + 'broadcastCoords': broadcastCoords, 'discDropEnabled': discDropEnabled, 'floodTrafficEnabled': floodTrafficEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, @@ -298,6 +305,7 @@ class UserPreferences { bool? mapRotationLocked, bool? disableRssiFilter, bool? anonymousMode, + bool? broadcastCoords, bool? discDropEnabled, bool? floodTrafficEnabled, bool? deleteChannelOnDisconnect, @@ -342,6 +350,7 @@ class UserPreferences { mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, + broadcastCoords: broadcastCoords ?? this.broadcastCoords, discDropEnabled: discDropEnabled ?? this.discDropEnabled, floodTrafficEnabled: floodTrafficEnabled ?? this.floodTrafficEnabled, deleteChannelOnDisconnect: @@ -419,6 +428,7 @@ class UserPreferences { other.mapRotationLocked == mapRotationLocked && other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && + other.broadcastCoords == broadcastCoords && other.discDropEnabled == discDropEnabled && other.floodTrafficEnabled == floodTrafficEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && @@ -463,6 +473,7 @@ class UserPreferences { mapRotationLocked, disableRssiFilter, anonymousMode, + broadcastCoords, discDropEnabled, floodTrafficEnabled, deleteChannelOnDisconnect, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 5fa51982..6b3f0b7c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2165,6 +2165,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.checkTxAllowed = () => txAllowed; _pingService!.getDiscDropEnabled = () => discDropEnabled; + // Wire-tag composition (privacy-preserving TX body by default). + _pingService!.getSessionId = () => _apiService.sessionId; + _pingService!.getWireKey = () => _apiService.wireKey; + _pingService!.getNextPingCounter = () => _apiService.nextPingCounter(); + _pingService!.getBroadcastCoords = () => _preferences.broadcastCoords; + _pingService!.getPingCounter = () => _apiService.pingCounter; + _pingService!.onSessionLimitReached = + () => handleSessionError('session_limit', null); + _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -5048,6 +5057,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Broadcast my coordinates: when true, TX pings put real GPS on the air + /// (legacy). When false (default), pings broadcast the privacy-preserving wire + /// tag and coords travel only via the API. + Future setBroadcastCoords(bool enabled) async { + _preferences = _preferences.copyWith(broadcastCoords: enabled); + await _savePreferences(); + debugLog('[PING] Broadcast coordinates ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { @@ -5102,6 +5121,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return serverMessage ?? 'Invalid request to API.'; case 'session_expired': return 'Session has expired. Please reconnect.'; + case 'session_limit': + return 'Reached session limit. Please reconnect to continue.'; case 'bad_session': return 'Invalid session. Please reconnect.'; case 'outofdate': @@ -5132,6 +5153,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future handleSessionError(String? reason, String? message) async { final userMessage = _getErrorMessage(reason, message); + // Session ping-counter exhausted (wire tag's 11-bit cap). The session is still + // valid here, so flush the queue under it BEFORE disconnecting: clearOnDisconnect() + // drops pending pings, and token-ping wire tags would fail validation if re-uploaded + // later under a new session. + if (reason == 'session_limit') { + debugError('[SESSION] $userMessage'); + logError(userMessage, severity: ErrorSeverity.warning); + try { + await _apiQueueService.flushQueue(); + } catch (e) { + debugError('[SESSION] Queue flush before session-limit disconnect failed: $e'); + } + await disconnect(); + return; + } + // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { debugWarn( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e5ba7299..8f1dab7e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -307,6 +307,15 @@ class _SettingsScreenState extends State { } }, ), + SwitchListTile( + secondary: const Icon(Icons.my_location), + title: const Text('Broadcast My Coordinates'), + subtitle: Text(prefs.broadcastCoords + ? 'Real GPS is sent on the air' + : 'Coordinates stay private (sent only to the server)'), + value: prefs.broadcastCoords, + onChanged: (value) => appState.setBroadcastCoords(value), + ), ListTile( leading: const Icon(Icons.timer), title: const Text('Auto-Ping Interval'), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 4ed600a4..e9ff635b 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -237,6 +237,8 @@ class ApiQueueService { required bool externalAntenna, int? noiseFloor, double? power, + int? pingCounter, + String? wireTag, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -246,6 +248,8 @@ class ApiQueueService { externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); // In offline mode, accumulate to offline pings list instead of queue diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index f451813d..47c8c341 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -39,6 +39,8 @@ class ApiService { bool _txAllowed = false; bool _rxAllowed = false; int? _sessionExpiresAt; + String? _wireKey; // TX wire-tag secret from /auth (null = un-keyed fallback) + int _pingCounter = 0; // per-session TX counter; resets on fresh /auth, not on heartbeat Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; @@ -153,6 +155,18 @@ class ApiService { /// Get session ID String? get sessionId => _sessionId; + /// TX wire-tag secret delivered by /auth (null when the server didn't send one). + String? get wireKey => _wireKey; + + /// Current per-session TX ping counter (peek without incrementing). + int get pingCounter => _pingCounter; + + /// Increment and return the next per-session TX ping counter (1-based). + /// Hard-capped at 2047 (the wire tag's 11-bit counter field) so the packed + /// token can never overflow into the session#/region bits. Reaching the cap + /// needs an ~8.5h continuous session at 15s; a fresh /auth resets it. + int nextPingCounter() => _pingCounter >= 2047 ? 2047 : ++_pingCounter; + /// Get session expiry timestamp int? get sessionExpiresAt => _sessionExpiresAt; @@ -418,6 +432,16 @@ class ApiService { _rxAllowed = data['rx_allowed'] == true; _sessionExpiresAt = data['expires_at'] as int?; + // TX wire-tag key + per-session ping counter (counter resets on a fresh /auth). + // Log only receipt + length — NEVER the raw key (debug logs are uploadable). + _wireKey = data['wire_key'] as String?; + _pingCounter = 0; + if (_wireKey != null) { + debugLog('[AUTH] wire-tag key received (len=${_wireKey!.length})'); + } else { + debugLog('[AUTH] no wire-tag key in auth response (un-keyed tag)'); + } + // Parse channels array from auth response final channelsData = data['channels']; if (channelsData is List) { @@ -837,6 +861,8 @@ class ApiService { /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; + _wireKey = null; + _pingCounter = 0; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 355c328b..9a593310 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -1105,20 +1105,18 @@ class MeshCoreConnection { ); } - /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON - /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead - /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon) async { + /// Send a pre-composed TX body to the #wardriving channel. + /// The caller composes the body (privacy wire tag "MM:..." by default, or the + /// legacy "@[MapperBot] LAT, LON" when the user opts into broadcasting coords) + /// so the exact same string is used for both TxTracker echo matching and the + /// actual transmission. + /// Power is not included in the mesh message — it is sent per-ping in the API payload. + Future sendPing(String message) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); } - // Format coordinates to 5 decimal places with comma separator - final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - final message = '@[MapperBot] $coordsStr'; - debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; await sendChannelTextMessage( diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart new file mode 100644 index 00000000..c11f8185 --- /dev/null +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Encodes/decodes the on-air TX "wire tag" that replaces plaintext GPS +/// coordinates on the `#wardriving` channel. +/// +/// A tag is `"MM:" + 7 base64url chars` (10 chars total → a single 16-byte AES +/// block once the channel layer encrypts it). It packs the origin region, the +/// session number, and the per-session ping counter into 40 bits, then runs a +/// keyed 4-round Feistel so it can only be decoded with the shared secret. +/// +/// The session *date* is intentionally NOT encoded — a decoder always holds a +/// receive timestamp (or, server-side, the full session_id), so the date is +/// supplied from context. The full session_id is reconstructed as +/// `(decoded region)-(date from context)-(decoded session#)`. +/// +/// This MUST stay byte-identical to the PHP `wireTagEncode`/`wireTagDecode` +/// twins — see `test/services/wire_tag_codec_test.dart` for the canonical +/// cross-language vectors. +/// +/// Web safety: this app runs on web (dart2js), where the bitwise operators +/// `<<`, `>>`, `&`, `|` truncate to 32 bits. Our packed value is up to 40 bits, +/// so anything that can exceed 31 bits uses plain arithmetic (`*`, `~/`, `%`), +/// which is exact for integers below 2^53. Bitwise ops are used only on the +/// two ≤20-bit Feistel halves, which are always < 2^31. +class WireTagCodec { + WireTagCodec._(); + + /// Marker prefix that distinguishes a MeshMapper tag from ordinary chatter. + static const String prefix = 'MM:'; + + static const int _pow11 = 2048; // 2^11 (counter field) + static const int _pow20 = 1048576; // 2^20 (Feistel half) + static const int _pow25 = 33554432; // 2^25 (region field shift) + static const int _mask20 = 0xFFFFF; // 20 bits — safe (< 2^31) + + static int _regionPack(String iata) { + final u = iata.toUpperCase(); + return (u.codeUnitAt(0) - 65) * 676 + + (u.codeUnitAt(1) - 65) * 26 + + (u.codeUnitAt(2) - 65); + } + + static String _regionUnpack(int n) => + String.fromCharCodes([n ~/ 676 + 65, (n % 676) ~/ 26 + 65, n % 26 + 65]); + + /// Feistel round function: first 3 bytes of SHA-256(secret ‖ round ‖ half), + /// masked to 20 bits. `half` is serialized big-endian as 3 bytes. + static int _f(List secret, int half, int round) { + final input = [ + ...secret, + round, + (half ~/ 65536) % 256, + (half ~/ 256) % 256, + half % 256, + ]; + final d = sha256.convert(input).bytes; + return (d[0] * 65536 + d[1] * 256 + d[2]) % _pow20; + } + + static int _feistel(List secret, int v, {required bool decrypt}) { + var l = v ~/ _pow20; // top 20 bits + var r = v % _pow20; // bottom 20 bits + for (final round in decrypt ? const [3, 2, 1, 0] : const [0, 1, 2, 3]) { + if (!decrypt) { + final newL = r; + r = (l ^ _f(secret, r, round)) & _mask20; + l = newL; + } else { + final newR = l; + l = (r ^ _f(secret, l, round)) & _mask20; + r = newR; + } + } + return l * _pow20 + r; + } + + static Uint8List _toBytes5(int v) { + final b = Uint8List(5); + var x = v; + for (var i = 4; i >= 0; i--) { + b[i] = x % 256; + x = x ~/ 256; + } + return b; + } + + static int _fromBytes5(List b) { + var v = 0; + for (final byte in b) { + v = v * 256 + byte; + } + return v; + } + + static String _b64url(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); + + static Uint8List _unb64url(String s) => + base64Url.decode(s + ('=' * ((4 - s.length % 4) % 4))); + + /// Encode `sessionId` (`IATA-YYYYMMDD-NNNN`) + `counter` into the wire body. + /// `key` is the shared secret from `/auth`; null/empty uses the un-keyed + /// fallback (still coord-free, just not key-protected). + static String encode(String sessionId, int counter, String? key) { + final parts = sessionId.split('-'); + final v = _regionPack(parts[0]) * _pow25 + + int.parse(parts[2]) * _pow11 + + counter; + final cipher = _feistel(utf8.encode(key ?? ''), v, decrypt: false); + return prefix + _b64url(_toBytes5(cipher)); + } + + /// Decode a wire body back to region / session# / counter using the key alone + /// (no database needed). The date is not recoverable from the tag. + static ({String region, int sessionNum, int counter}) decode( + String body, String? key) { + final token = + body.startsWith(prefix) ? body.substring(prefix.length) : body; + final v = + _feistel(utf8.encode(key ?? ''), _fromBytes5(_unb64url(token)), decrypt: true); + final region = v ~/ _pow25; + final rem = v % _pow25; + return ( + region: _regionUnpack(region), + sessionNum: rem ~/ _pow11, + counter: rem % _pow11, + ); + } +} diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 13fc4955..3a29815c 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -15,6 +15,7 @@ import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/wire_tag_codec.dart'; import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; @@ -96,6 +97,8 @@ class PingService { // TX ping context for queueing after RX window ends int? _pendingTxTimestamp; int? _pendingTxNoiseFloor; + int? _pendingTxPingCounter; // wire-tag ping counter (null in coords mode) + String? _pendingTxWireTag; // wire-tag body sent on air (null in coords mode) // Ping in progress guard (prevents concurrent BLE GATT errors) // Reference: state.pingInProgress in wardrive.js @@ -151,6 +154,19 @@ class PingService { /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; + /// Wire-tag composition (default privacy mode). Return the active session_id, + /// the wire-tag key from /auth, the next per-session ping counter, and whether + /// the user opted into broadcasting real coords on the air. + String? Function()? getSessionId; + String? Function()? getWireKey; + int Function()? getNextPingCounter; + bool Function()? getBroadcastCoords; + + /// Peek the current per-session ping counter, and react when it is exhausted + /// (wire tag's 11-bit cap) — AppStateProvider disconnects with a session-limit message. + int Function()? getPingCounter; + Future Function()? onSessionLimitReached; + /// Callback for ping events void Function(TxPing)? onTxPing; void Function(RxPing)? onRxPing; @@ -578,11 +594,34 @@ class PingService { } final txPowerDbm = _connection.deviceModel?.txPower ?? 22; - // Build ping message (same format used for TxTracker correlation) - // Power is no longer included in the mesh message — sent per-ping in API payload + // Build the on-air body ONCE (same string is used for TxTracker echo + // correlation AND the actual transmission). Power is sent per-ping in the API. + // + // Default (privacy): a keyed wire tag "MM:..." — coords go only via the API. + // Opt-in (Broadcast My Coordinates) OR no session yet: plaintext "MM:lat,lon". final coordsStr = - '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final pingMessage = '@[MapperBot] $coordsStr'; + '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; + final broadcastCoords = getBroadcastCoords?.call() ?? false; + final sessionId = getSessionId?.call(); + String pingMessage; + int? txPingCounter; + String? txWireTag; + if (!broadcastCoords && sessionId != null && sessionId.isNotEmpty) { + // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it + // is exhausted, end the session cleanly rather than repeating/corrupting tags. + if ((getPingCounter?.call() ?? 0) >= 2047) { + debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); + _pingInProgress = false; + onSessionLimitReached?.call(); + return false; + } + txPingCounter = getNextPingCounter?.call() ?? 1; + txWireTag = + WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); + pingMessage = txWireTag; + } else { + pingMessage = 'MM:$coordsStr'; + } // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -703,8 +742,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE (coordinates only — power is in API payload) - await _connection.sendPing(position.latitude, position.longitude); + // Send ping via BLE (pre-composed body — wire tag or legacy coords) + await _connection.sendPing(pingMessage); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -723,6 +762,8 @@ class PingService { // TX entry is queued AFTER RX window so heard_repeats can be populated _pendingTxTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; _pendingTxNoiseFloor = noiseFloor; + _pendingTxPingCounter = txPingCounter; // null in coords mode + _pendingTxWireTag = txWireTag; // null in coords mode // Start RX listening window (TX will be queued when window ends) _startRxListeningWindow(position); @@ -825,6 +866,8 @@ class PingService { externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, power: getPowerLevel?.call(), + pingCounter: _pendingTxPingCounter, // null in coords mode → server coords path + wireTag: _pendingTxWireTag, // null in coords mode → server coords path ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); diff --git a/test/services/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart new file mode 100644 index 00000000..bcf047cd --- /dev/null +++ b/test/services/wire_tag_codec_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/meshcore/wire_tag_codec.dart'; + +/// Canonical cross-language vectors. These MUST stay byte-identical to the PHP +/// `wireTagEncode` and the Python reference oracle — they are the wire contract. +/// See docs / the TX Wire-Tag plan for how they were generated. +void main() { + group('WireTagCodec.encode (canonical vectors, key=TESTKEY)', () { + const key = 'TESTKEY'; + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:zpCFQwc', + ('JKG-20260611-0009', 1): 'MM:zJHa-B8', + ('AAR-20260611-0014', 1): 'MM:GD59I2Q', + ('AAR-20260611-0123', 1000): 'MM:x6laqiY', + ('YOW-20260504-0005', 1): 'MM:2Oj9Xyg', + ('ZZZ-20260101-9999', 2047): 'MM:ETfo5FI', + }; + + vectors.forEach((input, expected) { + test('${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, key), expected); + }); + }); + + test('body is always "MM:" + 7 base64url chars (10 chars, one AES block)', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(body.length, 10); + expect(RegExp(r'^MM:[A-Za-z0-9_-]{7}$').hasMatch(body), isTrue); + }); + }); + + group('WireTagCodec.encode (empty-key fallback)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:jHHz-gQ', + ('JKG-20260611-0009', 1): 'MM:ozT0SI8', + ('AAR-20260611-0014', 1): 'MM:4y-cINQ', + ('AAR-20260611-0123', 1000): 'MM:ATsK8_8', + ('YOW-20260504-0005', 1): 'MM:EiC-3p4', + ('ZZZ-20260101-9999', 2047): 'MM:_CI9Xfs', + }; + + vectors.forEach((input, expected) { + test('null key == "" key, ${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, null), expected); + expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); + }); + }); + }); + + group('WireTagCodec.decode (key only, no DB)', () { + test('recovers region/session#/counter from a known body', () { + final r = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); + expect(r.region, 'PAR'); + expect(r.sessionNum, 13); + expect(r.counter, 1); + }); + + test('decode with the wrong key yields a different region', () { + final right = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); + final wrong = WireTagCodec.decode('MM:zpCFQwc', 'nope'); + expect(right.region, 'PAR'); + expect(wrong.region, isNot('PAR')); + }); + }); + + group('round-trip exactness across ping 1..1000', () { + test('encode then decode recovers the exact triple, all unique', () { + const key = 'TESTKEY'; + const sid = 'AAR-20260611-0123'; + final bodies = {}; + for (var c = 1; c <= 1000; c++) { + final body = WireTagCodec.encode(sid, c, key); + bodies.add(body); + final d = WireTagCodec.decode(body, key); + expect(d.region, 'AAR'); + expect(d.sessionNum, 123); + expect(d.counter, c); + } + expect(bodies.length, 1000, reason: 'every ping must be unique on the wire'); + }); + }); +} From d74f7ac106393f540c30d2f77bd16af997713ca9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 12 Jun 2026 20:05:00 -0400 Subject: [PATCH 126/360] Added Vector Tile Support and Refreshing single tiles by injecting GeoJson tile(hack cause we cant invalidate a single tile with LibreMaps) --- DEVELOPMENT.md | 36 ++- lib/models/user_preferences.dart | 14 ++ lib/providers/app_state_provider.dart | 242 +++++++++++++++++-- lib/screens/settings_screen.dart | 62 +++++ lib/services/api_queue_service.dart | 9 +- lib/services/api_service.dart | 46 ++++ lib/utils/coverage_tile_palette.dart | 89 +++++++ lib/utils/mvt_cells.dart | 159 +++++++++++++ lib/widgets/map_widget.dart | 327 ++++++++++++++++++++++---- test/utils/mvt_cells_test.dart | 46 ++++ 10 files changed, 963 insertions(+), 67 deletions(-) create mode 100644 lib/utils/coverage_tile_palette.dart create mode 100644 lib/utils/mvt_cells.dart create mode 100644 test/utils/mvt_cells_test.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d7b96bbb..2e636b20 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -290,7 +290,41 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s - **Platform**: Android and iOS only (Web N/A — always requires active tab) - **File**: `lib/services/wakelock_service.dart` -## Critical Protocol Details +### Coverage Overlay (vector tiles) + +The MeshMapper coverage layer is rendered from the region server's vector tiles +(`vector_tile.php`, z7–14, overzoom beyond) as a MapLibre source+layer pair. The app is +vector-only — every region server must serve `vector_tile.php` (the legacy raster +`tiles.php` overlay was removed from the app 2026-06). Contract reference: +`MeshMapper_Server/docs/VECTOR_TILES.md`. + +- **Styling is client-side**: each cell carries an integer status category `st`; colours + come from `match` expressions built by `lib/utils/coverage_tile_palette.dart` (kept in + sync with the server's `dev/cvd_palettes.php`, including all colour-vision palettes). +- **Coverage Grid preference (`prefs.coverageGridSize`)**: Simplified (300 m, default) or + Detailed (100 m + blob), mirroring the web's Grid Mode; baked into the tile URL. The + grid is locked to the chosen preset at every zoom — cells never resize. +- **Post-wardrive live refresh**: on upload success the queue hands the uploaded items to + `AppStateProvider`; +7 s later the server re-renders the affected tiles at z11–14 + (`fresh=1`, incl. neighbouring tiles within ~0.005° — blob/border spill lands in the + next tile over), and the user's own cells are decoded from the fresh z14 bodies + (`lib/utils/mvt_cells.dart`) into a session **patch layer**: a GeoJSON source updated + in place above the base layer, with the base layer's copies hidden via `setFilter`. + The base source is never swapped — nothing visibly changes except the changed cells. + A second check runs at +10 s only when the first found no changes. Logged under + `[COVERAGE]`. +- **GOTCHA — never partial-update a fill layer**: `setLayerProperties` serializes with + `skipNulls: false`; any `FillLayerProperties` field left null is RESET to its + style-spec default on iOS/web (`fill-color` → black). Always resend the full colour + expressions with an opacity change (see `_applyCoverageOverlayOpacity`). +- **GOTCHA — feature ids don't survive Android's filter bridge**: the platform converter + parses filter JSON numbers as float32, which rounds the 42-bit cell ids. Filter on the + small-int `i`/`j` properties (as an `"i_j"` string) instead — see + `_applyBasePatchFilter`. +- **Files**: `lib/widgets/map_widget.dart` (`_addCoverageOverlay`, `_applyCoveragePatch`), + `lib/providers/app_state_provider.dart` (`_freshenAffectedVectorTiles`), + `lib/services/api_service.dart` (`freshenVectorTile`), + `lib/utils/coverage_tile_palette.dart`, `lib/utils/mvt_cells.dart`. ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index eb8c2e2a..2090262b 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -111,6 +111,12 @@ class UserPreferences { /// layer so users can see the base map underneath the coverage squares. final double coverageOverlayOpacity; + + /// Coverage grid preset, matching the web UI's Grid Mode: 300 = Simplified + /// (300 m cells, the default), 100 = Detailed (100 m cells + 3×3 blob, + /// applied server-side). Only these two values are valid. + final int coverageGridSize; + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) final bool disconnectAlertEnabled; @@ -165,6 +171,7 @@ class UserPreferences { this.colorVisionType = 'none', this.mapTilesEnabled = true, this.coverageOverlayOpacity = 0.7, + this.coverageGridSize = 300, this.disconnectAlertEnabled = false, this.customApiEnabled = false, this.customApiUrl, @@ -214,6 +221,10 @@ class UserPreferences { mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, coverageOverlayOpacity: (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, + coverageGridSize: switch ((json['coverageGridSize'] as num?)?.toInt()) { + 100 => 100, + _ => 300, + }, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, @@ -271,6 +282,7 @@ class UserPreferences { 'colorVisionType': colorVisionType, 'mapTilesEnabled': mapTilesEnabled, 'coverageOverlayOpacity': coverageOverlayOpacity, + 'coverageGridSize': coverageGridSize, 'disconnectAlertEnabled': disconnectAlertEnabled, 'customApiEnabled': customApiEnabled, 'customApiUrl': customApiUrl, @@ -317,6 +329,7 @@ class UserPreferences { String? colorVisionType, bool? mapTilesEnabled, double? coverageOverlayOpacity, + int? coverageGridSize, bool? disconnectAlertEnabled, bool? customApiEnabled, String? customApiUrl, @@ -365,6 +378,7 @@ class UserPreferences { mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, + coverageGridSize: coverageGridSize ?? this.coverageGridSize, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 6b3f0b7c..7c7f56c9 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; @@ -19,6 +20,7 @@ import '../models/remembered_device.dart'; import '../models/repeater.dart'; import '../models/user_preferences.dart'; import '../services/api_queue_service.dart'; +import '../utils/mvt_cells.dart'; import '../services/api_service.dart'; import '../services/audio_service.dart'; import '../services/background_service.dart'; @@ -285,9 +287,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; - // Tile refresh after upload - int _overlayCacheBust = 0; - Timer? _tileRefreshTimer; + // Post-wardrive tile refresh: coords of recently uploaded pings (with the + // zone they belong to) and the +7s fresh-fetch timer (one retry at +10s). + // See VECTOR_TILES.md "Flutter post-wardrive live refresh". + Timer? _vectorFreshTimer; + final List> _pendingFreshCoords = []; // [lat, lon] + String? _pendingFreshZone; + bool _vectorOverlayActive = false; + + // Session coverage patch: the user's own freshly-pinged cells, drawn by the + // MapWidget as a small GeoJSON layer ON TOP of the base overlay (whose + // copies of these ids are filtered out, so translucent fills never stack). + // The base source is never swapped/cache-busted during a session — nothing + // may visibly change except the cells that actually changed. Keyed by + // feature id, insertion-ordered, capped. + final Map _coveragePatchCells = {}; + int _coveragePatchVersion = 0; // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -591,7 +606,197 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; - int get overlayCacheBust => _overlayCacheBust; + + /// MapWidget handshake: whether the coverage overlay is currently on the + /// map. Gates the post-wardrive fresh-tile flow — no overlay, no fetches. + void reportVectorOverlayActive(bool active) { + _vectorOverlayActive = active; + } + + /// Bumped whenever the session coverage patch changes; the MapWidget + /// watches it and re-applies the patch GeoJSON + base-layer filter. + int get coveragePatchVersion => _coveragePatchVersion; + + /// The user's own freshly-pinged cells (feature id -> cell), authoritative + /// server state decoded from the fresh z14 tiles. + Map get coveragePatchCells => _coveragePatchCells; + + /// Drop the session patch — the cells belong to one region + grid preset + /// (called on zone change and when the Coverage Grid preference changes). + void clearCoveragePatch() { + _coveragePatchCells.clear(); + _coveragePatchVersion++; + } + + /// Post-wardrive live refresh: have the server re-render (`fresh=1`) the + /// tiles around the uploaded ping coords at z11-14, then patch the user's + /// own cells onto the map from the fresh z14 bodies (the base overlay is + /// never swapped — see _coveragePatchCells). z8-10 are skipped: a single + /// ping is sub-pixel there and those whole-region renders are the expensive + /// ones; they ride the server's longer TTL. Zooms above 14 overzoom from + /// the z14 tile. attempt 1 fires +7s after upload; attempt 2 (+10s) runs + /// only when attempt 1 saw no changed tiles (ingestion can lag the post). + Future _freshenAffectedVectorTiles({required int attempt}) async { + final zone = zoneCode; + if (zone == null || + zone != _pendingFreshZone || + _pendingFreshCoords.isEmpty) { + // Zone changed since the coords were queued: they belong to the OLD + // region's grid — freshening them against the new region's server + // would be pure wasted renders. + _pendingFreshCoords.clear(); + return; + } + // Snapshot: a new upload can append (and re-schedule the timer) while the + // fetches below are in flight; only this snapshot is processed and only + // it gets removed afterwards, so late arrivals keep their refresh. + final coords = List>.from(_pendingFreshCoords); + + // A ping's influence is wider than its own cell: blob dilation and cells + // straddling a tile border are emitted in the NEIGHBOURING tile too (the + // server pads its tile queries by ~0.005°). Freshen every tile within + // that margin of the ping, or the spilled part of a cell stays stale in + // the next tile over. + const pad = 0.005; + final tiles = >{}; // 'z/x/y' -> [z, x, y] + var capped = false; + for (final c in coords) { + for (var z = 11; z <= 14 && !capped; z++) { + final n = 1 << z; + int lonToX(double lon) { + final x = (((lon + 180.0) / 360.0) * n).floor(); + return x < 0 ? 0 : (x >= n ? n - 1 : x); + } + int latToY(double lat) { + final latRad = lat * math.pi / 180.0; + final sinhArg = math.tan(latRad); + final asinh = math.log(sinhArg + math.sqrt(sinhArg * sinhArg + 1)); + final y = ((1.0 - asinh / math.pi) / 2.0 * n).floor(); + return y < 0 ? 0 : (y >= n ? n - 1 : y); + } + + // Containing tile plus any neighbour the pad reaches into (≤4 per z). + for (final xt in {lonToX(c[1] - pad), lonToX(c[1] + pad)}) { + for (final yt in {latToY(c[0] + pad), latToY(c[0] - pad)}) { + if (tiles.length >= 56) { + capped = true; + break; + } + tiles['$z/$xt/$yt'] = [z, xt, yt]; + } + } + } + if (capped) break; + } + if (capped) { + debugLog('[COVERAGE] Fresh-tile fan-out capped at 56 tiles this batch'); + } + + debugLog( + '[COVERAGE] Fresh-tile check (attempt $attempt): ${tiles.length} tiles for ${coords.length} ping(s), z11-14 incl. spill neighbours'); + // Throttled to 4 concurrent renders: a full-burst Future.wait can pile up + // PHP workers and SQLite lock contention on the shared region host (each + // fresh=1 is a live render racing the wardrive INSERT). The whole batch + // still completes in a second or two. + final entries = tiles.entries.toList(); + final z14Bodies = []; + var anyChanged = false; + for (var i = 0; i < entries.length; i += 4) { + final chunk = entries.sublist(i, math.min(i + 4, entries.length)); + await Future.wait(chunk.map((e) async { + final result = await _apiService.freshenVectorTile( + zone: zone, + z: e.value[0], + x: e.value[1], + y: e.value[2], + gsize: _preferences.coverageGridSize); + if (result.changed == true) { + anyChanged = true; + debugLog('[COVERAGE] Retrieved new tile ${e.key}'); + } else if (result.changed == false) { + debugLog('[COVERAGE] No new tile ${e.key} (unchanged)'); + } else { + debugLog('[COVERAGE] Tile ${e.key} fresh check failed'); + } + if (e.value[0] == 14 && result.body != null) { + z14Bodies.add(result.body!); + } + })); + if (_isDisposed) return; + } + + // Patch ONLY the user's own cells onto the map. The base overlay is never + // swapped — the fresh renders above keep the SERVER cache hot (other + // viewers + MapLibre's own tile revalidation pick them up); the cells the + // user is watching update instantly through the patch layer. + final patched = _extractOwnCells(coords, z14Bodies); + if (patched.isNotEmpty) { + for (final cell in patched) { + _coveragePatchCells.remove(cell.id); // re-insert: newest-last ordering + _coveragePatchCells[cell.id] = cell; + } + while (_coveragePatchCells.length > 5000) { + _coveragePatchCells.remove(_coveragePatchCells.keys.first); + } + _coveragePatchVersion++; + debugLog( + '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt)'); + notifyListeners(); + } else { + debugLog( + '[COVERAGE] No cells for your position in the fresh tiles yet (attempt $attempt)'); + } + + if (attempt < 2 && !anyChanged) { + // Re-check at +10s only when the first sweep came back unchanged — + // ingestion can lag a few seconds behind the post. An ACTIVE timer here + // belongs to a newer upload (it re-armed the +7s timer while this run's + // fetches were in flight); let it own the next sweep — overwriting it + // would sweep the new batch seconds too early. + if (!(_vectorFreshTimer?.isActive ?? false)) { + debugLog('[COVERAGE] No changes yet — second fresh-tile check at +10s'); + _vectorFreshTimer = Timer(const Duration(seconds: 3), () { + _freshenAffectedVectorTiles(attempt: 2); + }); + } + } else { + _pendingFreshCoords.removeRange( + 0, math.min(coords.length, _pendingFreshCoords.length)); + } + } + + /// The cells this batch of pings actually touches — the ping's own cell + /// plus its blob reach (Detailed dilates 3×3) — looked up by grid index in + /// the freshly rendered z14 tiles so the patch carries the SERVER-resolved + /// status (priority merge with whatever was already in the cell). + List _extractOwnCells( + List> coords, List bodies) { + if (bodies.isEmpty) return const []; + final steps = kCoverageGridSteps[_preferences.coverageGridSize]; + if (steps == null) return const []; + final reach = _preferences.coverageGridSize == 100 ? 1 : 0; + final wanted = {}; + for (final c in coords) { + final ci = (c[0] / steps[0]).floor(); + final cj = (c[1] / steps[1]).floor(); + for (var di = -reach; di <= reach; di++) { + for (var dj = -reach; dj <= reach; dj++) { + wanted.add('${ci + di}_${cj + dj}'); + } + } + } + final out = []; + final seen = {}; + for (final body in bodies) { + for (final cell in decodeCoverageCells(body)) { + if (wanted.contains('${cell.i}_${cell.j}') && seen.add(cell.id)) { + out.add(cell); + } + } + } + return out; + } + int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -845,7 +1050,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }; - _apiQueueService.onUploadSuccess = (uploadedCount) { + _apiQueueService.onUploadSuccess = (uploadedCount, uploadedItems) { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); @@ -853,17 +1058,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); - // Schedule overlay tile refresh after server has time to regenerate tiles. - // The MapWidget watches _overlayCacheBust and calls _refreshCoverageOverlay() - // (remove + re-add raster source with new URL) when it changes. - // Use 30s debounce to avoid excessive tile refreshes during rapid auto-ping - // (especially on flaky networks where tiles may fail to load). - _tileRefreshTimer?.cancel(); - _tileRefreshTimer = Timer(const Duration(seconds: 30), () { - _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; - debugLog('[MAP] Refreshing overlay tiles'); - notifyListeners(); - }); + if (_vectorOverlayActive) { + // Queue the batch's coords for the +7s fresh-tile check; the user's + // own cells land on the map via the session patch (see + // _freshenAffectedVectorTiles). + _pendingFreshZone = zoneCode; + for (final item in uploadedItems) { + if (_pendingFreshCoords.length >= 16) break; + _pendingFreshCoords.add([item.latitude, item.longitude]); + } + _vectorFreshTimer?.cancel(); + _vectorFreshTimer = Timer(const Duration(seconds: 7), () { + _freshenAffectedVectorTiles(attempt: 1); + }); + } }; // Initialize offline session service @@ -7281,7 +7489,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _cancelZoneGraceTimers(); - _tileRefreshTimer?.cancel(); + _vectorFreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8f1dab7e..2ac78c91 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -249,6 +249,16 @@ class _SettingsScreenState extends State { trailing: Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.grid_on), + title: const Text('Coverage Grid'), + subtitle: Text(prefs.coverageGridSize == 100 + ? 'Detailed (100 m cells)' + : 'Simplified (300 m cells)'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showCoverageGridSelector(context, appState), + ), ListTile( leading: const Icon(Icons.visibility), title: const Text('Color Vision'), @@ -1295,6 +1305,58 @@ class _SettingsScreenState extends State { }; } + /// Coverage grid preset selector — mirrors the web UI's Grid Mode + /// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side). + void _showCoverageGridSelector( + BuildContext context, AppStateProvider appState) { + final options = [ + (300, 'Simplified', '300 m cells — the default, matches the web map'), + (100, 'Detailed', '100 m cells with blob smoothing — finer detail'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Coverage Grid', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.coverageGridSize, + onChanged: (v) { + if (v != null) { + appState.updatePreferences( + appState.preferences.copyWith(coverageGridSize: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.grid_on), + title: Text(label), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + void _showColorVisionSelector( BuildContext context, AppStateProvider appState) { final options = [ diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index e9ff635b..50ea4d16 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -43,8 +43,11 @@ class ApiQueueService { /// Callback for queue updates void Function(int queueSize)? onQueueUpdated; - /// Callback for successful uploads (passes count of items uploaded) - void Function(int uploadedCount)? onUploadSuccess; + /// Callback for successful uploads. Passes the count AND the uploaded items + /// so the listener can compute which coverage tiles the batch touched (the + /// post-wardrive vector tile refresh needs the ping coordinates). + void Function(int uploadedCount, List uploadedItems)? + onUploadSuccess; /// Callback when persistence fails (for user-visible error logging) void Function(String errorMessage)? onPersistenceError; @@ -607,7 +610,7 @@ class ApiQueueService { _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); - onUploadSuccess?.call(uploadedCount); + onUploadSuccess?.call(uploadedCount, items); // Fire-and-forget: forward to custom API endpoint customApiService?.forwardPings(pings); } else if (result == UploadResult.nonRetryable) { diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 47c8c341..12893dae 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -891,6 +892,51 @@ class ApiService { /// Callback for maintenance mode detection (while connected) void Function(String message, String? url)? onMaintenanceMode; + /// Force-rebuild one vector coverage tile on the region server + /// (`vector_tile.php?...&fresh=1`, see VECTOR_TILES.md). Used by the + /// post-wardrive live refresh: it keeps the server cache hot AND hands the + /// fresh tile bytes back so the caller can patch the user's own cells onto + /// the map without touching the rest of the overlay. + /// + /// `changed`: true/false from the X-Tile-Changed header; null on network + /// failure, non-2xx, or a server that doesn't implement fresh=1 yet. + /// `body`: the uncompressed MVT bytes on a 200, null otherwise (204 = tile + /// is empty; package:http has already gunzipped the response). + Future<({bool? changed, Uint8List? body})> freshenVectorTile({ + required String zone, + required int z, + required int x, + required int y, + int gsize = 300, + }) async { + final url = Uri.parse( + 'https://${zone.toLowerCase()}.meshmapper.net/vector_tile.php' + '?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1'); + final sw = Stopwatch()..start(); + debugLog( + '[API] GET /vector_tile.php?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1 (zone ${zone.toLowerCase()})'); + try { + final response = + await _client.get(url).timeout(const Duration(seconds: 8)); + final changed = response.headers['x-tile-changed']; + debugLog( + '[API] Tile $z/$x/$y response (${response.statusCode}) in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: ' + '${response.bodyBytes.length}B, X-Tile-Changed=${changed ?? 'absent'}'); + if (response.statusCode != 200 && response.statusCode != 204) { + return (changed: null, body: null); + } + final body = response.statusCode == 200 ? response.bodyBytes : null; + return ( + changed: changed == null ? null : changed == '1', + body: body, + ); + } catch (e) { + debugWarn( + '[API] Tile $z/$x/$y fresh fetch failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); + return (changed: null, body: null); + } + } + /// Upload batch of wardrive data /// Returns UploadResult indicating success, retryable failure, or non-retryable failure /// Triggers onSessionError callback for session-related errors diff --git a/lib/utils/coverage_tile_palette.dart b/lib/utils/coverage_tile_palette.dart new file mode 100644 index 00000000..7d90909e --- /dev/null +++ b/lib/utils/coverage_tile_palette.dart @@ -0,0 +1,89 @@ +/// Colour tables + MapLibre style expressions for the VECTOR coverage tiles +/// (`vector_tile.php`). The server emits only an integer status category per +/// cell (`st`); colour is applied client-side via a `match` expression, so the +/// rendered fills are exactly the colours the raster tiles used to bake. +/// +/// KEEP IN SYNC with `getCvdPaletteHex()` in MeshMapper_Server +/// `dev/cvd_palettes.php` — these are the server raster fill/border values, +/// NOT the lighter legend swatches in [PingColors] (those are tuned for how +/// the raster looks after layer opacity and stay legend-only). +/// +/// `st` enum (ascending priority, lower wins; see VECTOR_TILES.md): +/// 1=green (BIDIR), 2=cyan (DISC/TRACE), 3=orange (TX), 4=purple (RX), +/// 5=grey (dead), 6=red (fail). +class CoverageTilePalette { + CoverageTilePalette._(); + + /// cvd mode -> [st 1..6] -> [fill, border] hex. + static const Map>> _palettes = { + 'none': [ + ['#1e7e34', '#14522d'], // 1 green + ['#17a2b8', '#117a8b'], // 2 cyan + ['#fd7e14', '#d96b0c'], // 3 orange + ['#6f42c1', '#59359a'], // 4 purple + ['#6c757d', '#545b62'], // 5 grey + ['#bd2130', '#8b101b'], // 6 red + ], + 'protanopia': [ + ['#0072B2', '#00507D'], + ['#56B4E9', '#3C7EA3'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + // Server maps deuteranopia to the protanopia palette. + 'deuteranopia': [ + ['#0072B2', '#00507D'], + ['#56B4E9', '#3C7EA3'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + 'tritanopia': [ + ['#009E73', '#006F51'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + 'achromatopsia': [ + ['#E0E0E0', '#9D9D9D'], + ['#BDBDBD', '#848484'], + ['#9E9E9E', '#6F6F6F'], + ['#757575', '#525252'], + ['#616161', '#444444'], + ['#424242', '#2E2E2E'], + ], + }; + + static List> _paletteFor(String cvdMode) => + _palettes[cvdMode] ?? _palettes['none']!; + + /// Builds `['match', ['get','st'], 1, c1, ..., 5, c5, c6]` — st 6 doubles + /// as the match default so unknown future codes render as red, the same + /// fallthrough the server-side mapping uses. + static List _matchExpression(String cvdMode, int hexIndex) { + final pal = _paletteFor(cvdMode); + final expr = [ + 'match', + ['get', 'st'], + ]; + for (var st = 1; st <= 5; st++) { + expr.add(st); + expr.add(pal[st - 1][hexIndex]); + } + expr.add(pal[5][hexIndex]); // default (st 6 / unknown) + return expr; + } + + /// MapLibre `fill-color` expression for the user's colour-vision mode. + static List fillColorExpression(String cvdMode) => + _matchExpression(cvdMode, 0); + + /// MapLibre `fill-outline-color` expression (the raster's 1px cell border). + static List borderColorExpression(String cvdMode) => + _matchExpression(cvdMode, 1); +} diff --git a/lib/utils/mvt_cells.dart b/lib/utils/mvt_cells.dart new file mode 100644 index 00000000..1ded7855 --- /dev/null +++ b/lib/utils/mvt_cells.dart @@ -0,0 +1,159 @@ +import 'dart:typed_data'; + +/// Minimal decoder for MeshMapper's coverage vector tiles (vector_tile.php) — +/// just enough of the MVT/protobuf wire format to extract each cell's +/// `{id, i, j, st}`. Geometry is skipped entirely: a cell's rectangle is +/// reconstructed from its grid indices (i, j) and the grid step table, which +/// is byte-identical to what the server encoded (both compute corners from +/// indices). Contract reference: MeshMapper_Server/docs/VECTOR_TILES.md. +/// +/// WEB-SAFE BY DESIGN: feature ids run up to ~2^42 and dart2js truncates +/// bitwise ops to 32 bits, so varint/zigzag decoding here uses arithmetic +/// (`*`, `~/`, `%`) only — same rule as WireTagCodec. + +/// gsize (metres) -> [latStep, lonStep] degrees. KEEP IN SYNC with +/// `coverageGridPresets()` in MeshMapper_Server dev/coverage_cells.php +/// (the app only ever uses the 300/100 presets). +const Map> kCoverageGridSteps = { + 300: [0.0027, 0.00384], + 100: [0.0009, 0.00128], +}; + +/// One coverage cell from a decoded tile. `st` is the server's status +/// category (1=green 2=cyan 3=orange 4=purple 5=grey 6=red). +class CoverageCell { + final int id; + final int i; + final int j; + final int st; + const CoverageCell(this.id, this.i, this.j, this.st); +} + +class _Reader { + final Uint8List buf; + int pos = 0; + _Reader(this.buf); + + bool get done => pos >= buf.length; + + /// Varint as a Dart int via arithmetic (exact up to 2^53 — ids are < 2^53). + int varint() { + var value = 0; + var multiplier = 1; + while (true) { + final b = buf[pos++]; + value += (b % 128) * multiplier; + if (b < 128) return value; + multiplier *= 128; + } + } + + Uint8List bytes(int len) { + final out = Uint8List.sublistView(buf, pos, pos + len); + pos += len; + return out; + } +} + +int _unzigzag(int n) => n.isOdd ? -((n + 1) ~/ 2) : n ~/ 2; + +/// Decodes the cells of a single-layer coverage MVT (uncompressed bytes — +/// package:http has already gunzipped the response). Returns const [] for +/// anything that doesn't parse as expected: a tile we can't read must never +/// break the wardriving flow, the patch just skips it. +List decodeCoverageCells(Uint8List mvt) { + try { + final tile = _Reader(mvt); + while (!tile.done) { + final tag = tile.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 3 && wire == 2) { + return _decodeLayer(_Reader(tile.bytes(tile.varint()))); + } + _skip(tile, wire); + } + } catch (_) { + // Malformed/foreign tile — treated as empty below. + } + return const []; +} + +List _decodeLayer(_Reader layer) { + final keys = []; + final values = []; + final features = <_Reader>[]; + while (!layer.done) { + final tag = layer.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 2 && wire == 2) { + features.add(_Reader(layer.bytes(layer.varint()))); + } else if (field == 3 && wire == 2) { + keys.add(String.fromCharCodes(layer.bytes(layer.varint()))); + } else if (field == 4 && wire == 2) { + values.add(_decodeValue(_Reader(layer.bytes(layer.varint())))); + } else { + _skip(layer, wire); + } + } + + final cells = []; + for (final f in features) { + var id = 0; + final tags = []; + while (!f.done) { + final tag = f.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 1 && wire == 0) { + id = f.varint(); + } else if (field == 2 && wire == 2) { + final packed = _Reader(f.bytes(f.varint())); + while (!packed.done) { + tags.add(packed.varint()); + } + } else { + _skip(f, wire); + } + } + int? i, j, st; + for (var t = 0; t + 1 < tags.length; t += 2) { + final key = tags[t] < keys.length ? keys[tags[t]] : ''; + final value = tags[t + 1] < values.length ? values[tags[t + 1]] : 0; + if (key == 'i') i = value; + if (key == 'j') j = value; + if (key == 'st') st = value; + } + if (i != null && j != null && st != null) { + cells.add(CoverageCell(id, i, j, st)); + } + } + return cells; +} + +/// Value message: the server encodes all properties as sint64 (field 6). +int _decodeValue(_Reader value) { + while (!value.done) { + final tag = value.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 6 && wire == 0) return _unzigzag(value.varint()); + _skip(value, wire); + } + return 0; +} + +void _skip(_Reader r, int wire) { + if (wire == 0) { + r.varint(); + } else if (wire == 2) { + r.bytes(r.varint()); + } else if (wire == 5) { + r.pos += 4; + } else if (wire == 1) { + r.pos += 8; + } else { + throw const FormatException('unsupported wire type'); + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 5d04a09d..25f38750 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -17,7 +17,9 @@ import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; +import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; @@ -390,19 +392,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bool _focusPanelMinimized = false; dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry - // MapLibre style and overlay tracking - int _lastCacheBust = 0; + // MapLibre style and overlay tracking. // Tracks the zone code we last rendered the coverage overlay for. When the // zone check succeeds after the style has already loaded (e.g. first check // failed with gps_inaccurate and a later retry succeeded), _addCoverageOverlay - // would otherwise never re-run and the raster layer would stay missing. + // would otherwise never re-run and the coverage layer would stay missing. String? _lastOverlayZoneCode; // Last coverage overlay opacity we pushed into MapLibre. Compared against // the current preference in _buildMap to detect slider changes and apply // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). double? _lastAppliedCoverageOpacity; - // Guard flag that coalesces multiple overlay-refresh triggers (cache bust - // and zone change) in the same frame into a single post-frame callback. + // Guard flag that coalesces multiple overlay-refresh triggers (zone and + // pref changes) in the same frame into a single post-frame callback. // Without this, two watchers can schedule concurrent _refreshCoverageOverlay // runs whose remove/add calls interleave and produce "Source already exists" // errors in the native log. @@ -413,6 +414,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { String? _activeCoverageSourceId; String? _activeCoverageLayerId; int _coverageBufferCounter = 0; + // Grid size is baked into the tile URL and the CVD palette into the layer + // paint; a change to either rebuilds the overlay via the build watcher. + int? _lastAppliedGridSize; + String? _lastAppliedCvd; + // Session coverage patch: a GeoJSON layer carrying the user's own + // freshly-pinged cells ON TOP of the base overlay; the base layer's copies + // of those cells are hidden via setFilter so translucent fills never stack. + // The base source is never swapped during a session — only the patch + // updates, so nothing visibly changes except the changed cells. + static const String _patchSourceId = 'meshmapper-coverage-patch'; + static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; + bool _patchLayerReady = false; + int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -561,6 +575,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); _appLifecycleState = WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + // Coverage-patch updates are driven by a DIRECT provider listener, not the + // build() watchers: when the device sits still nothing else rebuilds this + // widget, and a patch that waits for the next rebuild only appears after + // the user happens to touch the map. + _patchProviderRef = context.read(); + _patchProviderRef!.addListener(_onCoveragePatchNotify); + } + + AppStateProvider? _patchProviderRef; + + void _onCoveragePatchNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + if (appState.coveragePatchVersion == _lastAppliedPatchVersion) return; + if (!_isMapReady || !_styleLoaded) return; + _lastAppliedPatchVersion = appState.coveragePatchVersion; + _applyCoveragePatch(appState); } @override @@ -599,6 +630,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _patchProviderRef?.removeListener(_onCoveragePatchNotify); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -1489,6 +1521,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _setOfflineIfSupported(!tilesEnabled); if (wasEnabled == true && !tilesEnabled) { await _removeCoverageOverlay(); + // No overlay on the map -> the post-wardrive fresh-tile flow must + // not keep rendering server tiles for it. + appState.reportVectorOverlayActive(false); } else if (wasEnabled == false && tilesEnabled && _styleLoaded) { await _addCoverageOverlay(appState); } @@ -1500,26 +1535,43 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds // cluster layers, re-adds the coverage overlay, and re-syncs annotations. - // Detect cache bust or zoneCode change → schedule a SINGLE coalesced - // refresh. Previously each watcher scheduled its own post-frame callback, - // which could race when both changed in the same frame (e.g. a zone - // transition that also rotates cache bust). The _coverageRefreshScheduled - // flag ensures at most one refresh is queued per frame. + // Detect zoneCode or overlay-pref changes → schedule a SINGLE coalesced + // refresh; the _coverageRefreshScheduled flag ensures at most one refresh + // is queued per frame even when both change together. // // The zoneCode watcher is needed because _addCoverageOverlay only runs // during _onStyleLoaded — if the first zone check failed with // gps_inaccurate, the style loads with zoneCode=null and the overlay is // skipped. When a later retry sets the zone, nothing else would trigger - // the raster layer. - final cacheBustChanged = appState.overlayCacheBust != _lastCacheBust && - _isMapReady && - _styleLoaded; + // the coverage layer. final zoneChanged = appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded; - if (cacheBustChanged || zoneChanged) { - if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; - if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; + // Grid size is baked into the tile URL and the CVD palette into the layer + // paint, so either change rebuilds the overlay. + final prefsForOverlay = appState.preferences; + final overlayPrefChanged = _isMapReady && + _styleLoaded && + ((_lastAppliedGridSize != null && + _lastAppliedGridSize != prefsForOverlay.coverageGridSize) || + (_lastAppliedCvd != null && + _lastAppliedCvd != prefsForOverlay.colorVisionType)); + + if (zoneChanged || overlayPrefChanged) { + if (zoneChanged) { + _lastOverlayZoneCode = appState.zoneCode; + // The session patch belongs to the old region's grid. + appState.clearCoveragePatch(); + } + if (overlayPrefChanged) { + // Patched cell ids/geometry are per grid preset; a palette-only + // change keeps them (the rebuild restyles the patch layer too). + if (_lastAppliedGridSize != prefsForOverlay.coverageGridSize) { + appState.clearCoveragePatch(); + } + _lastAppliedGridSize = prefsForOverlay.coverageGridSize; + _lastAppliedCvd = prefsForOverlay.colorVisionType; + } if (!_coverageRefreshScheduled) { _coverageRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -1546,6 +1598,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // (Session-patch application: direct provider listener + // _onCoveragePatchNotify — see initState for why it's not a build watcher.) + // Detect coverage overlay opacity change (user dragged the slider in // Settings → General) and push it to the live raster layer without // rebuilding the whole overlay. Skipped while ping focus mode is active — @@ -2213,48 +2268,77 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } - final cvdParam = appState.preferences.colorVisionType != 'none' - ? '&cvd=${appState.preferences.colorVisionType}' - : ''; - final url = - 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + final prefs = appState.preferences; + final zone = appState.zoneCode!.toLowerCase(); + final gridSize = prefs.coverageGridSize; final sourceId = _nextCoverageSourceId(); final layerId = _coverageLayerIdFor(sourceId); try { - await _mapController!.addSource( - sourceId, - RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), - ); // Target the bottom of the repeater cluster stack when it exists, so the - // raster lands beneath ALL marker layers (repeater clusters + symbol + // overlay lands beneath ALL marker layers (repeater clusters + symbol // annotations). Fallback to the symbol annotation layer if cluster layers // haven't been created yet. final belowLayer = _clusterLayersReady ? _repeaterIndividualLayerId : _symbolAnnotationLayerId(); - // While ping focus mode is active, force the newly added raster layer - // to opacity 0 so a cache-bust tile refresh (fires 5s after every API - // upload success — see AppStateProvider._tileRefreshTimer) doesn't + // While ping focus mode is active, force the newly added layer to + // opacity 0 so an overlay rebuild (zone/grid/palette change) doesn't // make the overlay pop back into view in the middle of focus mode. // Dismissing focus restores the preference value via // _applyCoverageOverlayOpacity in _dismissPingFocus. - final opacity = _focusedPingLocation != null - ? 0.0 - : appState.preferences.coverageOverlayOpacity; - await _mapController!.addRasterLayer( + final opacity = + _focusedPingLocation != null ? 0.0 : prefs.coverageOverlayOpacity; + + // The server emits an integer status category per cell (st); colour + // happens HERE via match expressions, so colour-vision palettes apply + // without any server param and a tile carries data, not pixels. + final url = + 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize'; + // minzoom 7 = the raster's old on-screen range (512px-convention vector + // tiles sit one display-zoom lower than 256px raster tiles). + await _mapController!.addSource( + sourceId, + VectorSourceProperties(tiles: [url], minzoom: 7, maxzoom: 14), + ); + await _mapController!.addFillLayer( sourceId, layerId, - RasterLayerProperties(rasterOpacity: opacity), + FillLayerProperties( + fillColor: + CoverageTilePalette.fillColorExpression(prefs.colorVisionType), + fillOutlineColor: + CoverageTilePalette.borderColorExpression(prefs.colorVisionType), + fillOpacity: opacity, + ), + sourceLayer: 'coverage', belowLayerId: belowLayer, ); _activeCoverageSourceId = sourceId; _activeCoverageLayerId = layerId; + + // The session patch rides directly above the base layer (same anchor, + // added later = higher) with identical styling. Creation is + // self-healing: if it fails here, the next patch update retries it. + // The base-layer filter is only applied once the patch layer exists — + // hiding cells the patch can't draw would punch holes in the overlay. + _patchLayerReady = false; + if (await _ensureCoveragePatchLayer(appState)) { + _lastAppliedPatchVersion = appState.coveragePatchVersion; + await _applyBasePatchFilter(appState, layerId); + } + _lastAppliedCoverageOpacity = opacity; + _lastAppliedGridSize = gridSize; + _lastAppliedCvd = prefs.colorVisionType; + appState.reportVectorOverlayActive(true); debugLog( - '[MAP] Coverage overlay added as $layerId (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + '[MAP] Coverage overlay added as $layerId (grid $gridSize, below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); } catch (e) { + // No overlay on the map — the post-wardrive fresh-tile flow must not + // keep fetching for it. + appState.reportVectorOverlayActive(false); debugLog('[MAP] Failed to add coverage overlay: $e'); } } @@ -2264,17 +2348,28 @@ class _MapWidgetState extends State with WidgetsBindingObserver { String _coverageLayerIdFor(String sourceId) => '$sourceId-layer'; - /// Apply a new coverage overlay opacity to the live raster layer without - /// removing/re-adding it. No-op if the layer doesn't exist yet. + /// Apply a new coverage overlay opacity to the live fill layers without + /// removing/re-adding them. No-op if the layer doesn't exist yet. + /// + /// setLayerProperties serializes with skipNulls:false — every property left + /// null is RESET to its style-spec default on iOS/web (fill-color → black), + /// so the full colour expressions must be resent alongside the new opacity, + /// never a partial FillLayerProperties. Future _applyCoverageOverlayOpacity(double opacity) async { - if (_mapController == null) return; + if (_mapController == null || !mounted) return; final layerId = _activeCoverageLayerId; if (layerId == null) return; try { - await _mapController!.setLayerProperties( - layerId, - RasterLayerProperties(rasterOpacity: opacity), + final cvd = context.read().preferences.colorVisionType; + final props = FillLayerProperties( + fillColor: CoverageTilePalette.fillColorExpression(cvd), + fillOutlineColor: CoverageTilePalette.borderColorExpression(cvd), + fillOpacity: opacity, ); + if (_patchLayerReady) { + await _mapController!.setLayerProperties(_patchLayerId, props); + } + await _mapController!.setLayerProperties(layerId, props); _lastAppliedCoverageOpacity = opacity; debugLog( '[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); @@ -2324,6 +2419,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (layerId != null && sourceId != null) { await _removeCoverageLayerById(layerId, sourceId); } + await _removeCoveragePatchLayer(); } /// Remove a specific coverage source+layer pair without touching the @@ -2339,10 +2435,148 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (_) {} } - /// Refresh coverage overlay by removing the current layer and adding a fresh - /// one with updated cache-bust URL. Accepts a brief visual gap between - /// remove and add — imperceptible during driving and quick when stationary - /// (tiles load from cache for same viewport). + /// Remove the session-patch source+layer (vector overlay companion). + Future _removeCoveragePatchLayer() async { + _patchLayerReady = false; + if (_mapController == null) return; + try { + await _mapController!.removeLayer(_patchLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_patchSourceId); + } catch (_) {} + } + + /// GeoJSON FeatureCollection of the session patch: one rectangle per cell, + /// corners computed from grid indices exactly like the server's tiles. + Map _buildPatchGeoJson(AppStateProvider appState) { + final steps = + kCoverageGridSteps[appState.preferences.coverageGridSize] ?? + kCoverageGridSteps[300]!; + final features = >[]; + for (final cell in appState.coveragePatchCells.values) { + final lat0 = cell.i * steps[0]; + final lon0 = cell.j * steps[1]; + // No feature id: nothing reads it on the patch layer, and 42-bit ids + // don't survive Android's float32 JSON bridge anyway (see + // _applyBasePatchFilter). + features.add({ + 'type': 'Feature', + 'properties': {'st': cell.st}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ + [ + [lon0, lat0], + [lon0 + steps[1], lat0], + [lon0 + steps[1], lat0 + steps[0]], + [lon0, lat0 + steps[0]], + [lon0, lat0], + ] + ], + }, + }); + } + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Hide the BASE layer's copies of patched cells — the patch layer owns + /// them now; without the filter the translucent fills would stack and the + /// patched cells would render darker than their neighbours. + /// + /// Matched on an "i_j" string built from the small-int grid properties, NOT + /// on the feature id: Android's filter converter parses every JSON number + /// as float32, which silently rounds the 42-bit ids so an id-based `in` + /// never matches. Strings cross the bridge untouched on every platform. + Future _applyBasePatchFilter( + AppStateProvider appState, String baseLayerId) async { + if (_mapController == null || appState.coveragePatchCells.isEmpty) return; + final keys = [ + for (final cell in appState.coveragePatchCells.values) + '${cell.i}_${cell.j}' + ]; + try { + await _mapController!.setFilter(baseLayerId, [ + '!', + [ + 'in', + [ + 'concat', + ['to-string', ['get', 'i']], + '_', + ['to-string', ['get', 'j']], + ], + ['literal', keys], + ], + ]); + } catch (e) { + debugLog('[MAP] Coverage base filter failed: $e'); + } + } + + /// Idempotently (re)create the patch source+layer. Self-healing: if the + /// creation during overlay add failed, or a style change replaced the + /// native layers, the next patch application rebuilds it here. + Future _ensureCoveragePatchLayer(AppStateProvider appState) async { + if (_patchLayerReady) return true; + if (_mapController == null || _activeCoverageLayerId == null) return false; + try { + final prefs = appState.preferences; + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + final opacity = + _focusedPingLocation != null ? 0.0 : prefs.coverageOverlayOpacity; + await _removeCoveragePatchLayer(); + await _mapController! + .addGeoJsonSource(_patchSourceId, _buildPatchGeoJson(appState)); + await _mapController!.addFillLayer( + _patchSourceId, + _patchLayerId, + FillLayerProperties( + fillColor: + CoverageTilePalette.fillColorExpression(prefs.colorVisionType), + fillOutlineColor: + CoverageTilePalette.borderColorExpression(prefs.colorVisionType), + fillOpacity: opacity, + ), + belowLayerId: belowLayer, + ); + _patchLayerReady = true; + debugLog('[MAP] Coverage patch layer created'); + return true; + } catch (e) { + debugLog('[MAP] Coverage patch layer create failed: $e'); + return false; + } + } + + /// Push the latest session patch into the map: update the GeoJSON source + /// (flash-free, in-place) and extend the base-layer filter. Nothing else on + /// the map changes — this is the live-update path for the user's own pings. + Future _applyCoveragePatch(AppStateProvider appState) async { + if (_mapController == null) return; + final baseLayerId = _activeCoverageLayerId; + if (baseLayerId == null) { + debugLog('[MAP] Coverage patch skipped: no active coverage layer'); + return; + } + if (!await _ensureCoveragePatchLayer(appState)) return; + try { + await _mapController! + .setGeoJsonSource(_patchSourceId, _buildPatchGeoJson(appState)); + await _applyBasePatchFilter(appState, baseLayerId); + debugLog( + '[MAP] Coverage patch applied: ${appState.coveragePatchCells.length} cell(s)'); + } catch (e) { + debugLog('[MAP] Coverage patch apply failed: $e'); + } + } + + /// Rebuild the coverage overlay (remove + re-add). Only runs on real + /// transitions — zone change, grid/palette pref change, style reload — + /// never as a live-refresh path (that's the session patch's job), so the + /// brief remove/add gap is acceptable. Future _refreshCoverageOverlay(AppStateProvider appState) async { await _removeCoverageOverlay(); await _addCoverageOverlay(appState); @@ -4424,6 +4658,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _addCoverageOverlay(context.read()); } else { _removeCoverageOverlay(); + context.read().reportVectorOverlayActive(false); } } diff --git a/test/utils/mvt_cells_test.dart b/test/utils/mvt_cells_test.dart new file mode 100644 index 00000000..cf293d15 --- /dev/null +++ b/test/utils/mvt_cells_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/mvt_cells.dart'; + +/// Fixtures generated by the SERVER's dev/mvt_encoder.php — the decoder must +/// stay in lockstep with it (see MeshMapper_Server/docs/VECTOR_TILES.md). +/// +/// `_knownTile`: layer "coverage", extent 4096, two features: +/// id=42 {i:-5, j:7, st:1} and id=43 {i:3, j:7, st:6}. +/// `_realTile`: an actual rendered z12 coverage tile (29 cells). +const _knownTile = + 'GmUKCGNvdmVyYWdlEhkIKhIGAAABAQICGAMiCwkAABoUAAAUEwAPEhsIKxIGAAMBAQIEGAMiDQnIAZADGhQAABQTAA8aAWkaAWoaAnN0IgIwCSICMA4iAjACIgIwBiICMAwogCB4Ag=='; +const _realTile = + 'GuoJCghjb3ZlcmFnZRIjCM2lwuWVPxIGAAABAQICGAMiEAmMPJAgGuYCAAC6AuUCAA8SIwjOpcLllT8SBgAAAQMCAhgDIhAJ8j6QIBrmAgAAugLlAgAPEiMIzKXC5JU/EgYABAEFAgIYAyIQCaY5yiIa5gIAALoC5QIADxIjCM6lwuSVPxIGAAQBAwICGAMiEAnyPsoiGuYCAAC6AuUCAA8SIwjNpcLjlT8SBgAGAQECBxgDIhAJjDyEJRrmAgAAugLlAgAPEiMIzKXC4pU/EgYACAEFAgIYAyIQCaY5vica5gIAALoC5QIADxIjCM2lwuKVPxIGAAgBAQICGAMiEAmMPL4nGuYCAAC6AuUCAA8SIwjDpcLhlT8SBgAJAQoCCxgDIhAJkCD4KRrmAgAAugLlAgAPEiMIy6XC4ZU/EgYACQEMAgsYAyIQCcA2+Cka5gIAALoC5QIADxIjCMOlwuCVPxIGAA0BCgIHGAMiEAmQILIsGuYCAAC6AuUCAA8SIwjEpcLglT8SBgANAQ4CCxgDIhAJ9iKyLBrmAgAAugLlAgAPEiMIy6XC4JU/EgYADQEMAg8YAyIQCcA2siwa5gIAALoC5QIADxIjCM2lwuCVPxIGAA0BAQILGAMiEAmMPLIsGuYCAAC6AuUCAA8SIwjNpcLflT8SBgAQAQECCxgDIhAJjDzsLhrmAgAAugLlAgAPEiMIyqXC3pU/EgYAEQESAg8YAyIQCdozpjEa5gIAALoC5QIADxIjCMylwt6VPxIGABEBBQILGAMiEAmmOaYxGuYCAAC6AuUCAA8SIwjNpcLelT8SBgARAQECBxgDIhAJjDymMRrmAgAAugLlAgAPEiMIyqXC3ZU/EgYAEwESAgIYAyIQCdoz4DMa5gIAALoC5QIADxIjCMylwt2VPxIGABMBBQILGAMiEAmmOeAzGuYCAAC6AuUCAA8SIwjJpcLclT8SBgAUARUCCxgDIhAJ9DCaNhrmAgAAugLlAgAPEiMIyqXC3JU/EgYAFAESAgsYAyIQCdozmjYa5gIAALoC5QIADxIjCMulwtyVPxIGABQBDAILGAMiEAnANpo2GuYCAAC6AuUCAA8SIwjGpcLblT8SBgAWARcCCxgDIhAJwijUOBrmAgAAugLlAgAPEiMIx6XC25U/EgYAFgEYAgcYAyIQCagr1Dga5gIAALoC5QIADxIjCMilwtuVPxIGABYBGQILGAMiEAmOLtQ4GuYCAAC6AuUCAA8SIwjKpcLblT8SBgAWARICDxgDIhAJ2jPUOBrmAgAAugLlAgAPEiMIy6XC25U/EgYAFgEMAgsYAyIQCcA21Dga5gIAALoC5QIADxIjCMqlwtmVPxIGABoBEgICGAMiEAnaM8g9GuYCAAC6AuUCAA8SIwjJpcLYlT8SBgAbARUCAhgDIhAJ9DCCQBrmAgAAugLlAgAPGgFpGgFqGgJzdCIEMLXUASIEMJrLBCICMAwiBDCcywQiBDC31AEiBDCYywQiBDC51AEiAjAIIgQwu9QBIgQwvdQBIgQwhssEIgIwBCIEMJbLBCIEML/UASIEMIjLBCICMAIiBDDB1AEiBDDD1AEiBDCUywQiBDDF1AEiBDDH1AEiBDCSywQiBDDJ1AEiBDCMywQiBDCOywQiBDCQywQiBDDN1AEiBDDP1AEogCB4Ag=='; + +void main() { + test('decodes the known two-feature tile exactly', () { + final cells = decodeCoverageCells(base64Decode(_knownTile)); + expect(cells.length, 2); + expect(cells[0].id, 42); + expect(cells[0].i, -5); + expect(cells[0].j, 7); + expect(cells[0].st, 1); + expect(cells[1].id, 43); + expect(cells[1].i, 3); + expect(cells[1].j, 7); + expect(cells[1].st, 6); + }); + + test('decodes a real rendered tile with valid ids and categories', () { + final cells = decodeCoverageCells(base64Decode(_realTile)); + expect(cells.length, 29); + for (final cell in cells) { + expect(cell.st, inInclusiveRange(1, 6)); + // id = ((i + 2^20) << 21) | (j + 2^20), computed arithmetically here + // because dart2js truncates >32-bit bitwise ops (web build). + final expectedId = (cell.i + 1048576) * 2097152 + (cell.j + 1048576); + expect(cell.id, expectedId); + } + }); + + test('garbage input decodes to empty, never throws', () { + expect(decodeCoverageCells(base64Decode('aGVsbG8gd29ybGQ=')), isEmpty); + }); +} From af44cf9992ddca08c39af92e2c7d618332c23d48 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 13 Jun 2026 23:44:16 -0400 Subject: [PATCH 127/360] perf: stop the map rebuilding on every notifyListeners (wardrive overheat fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of phones overheating during wardriving: the MapLibre MapWidget (the most expensive subtree) did context.watch() and so rebuilt on EVERY one of the provider's ~166 notifyListeners() calls — including high-frequency UI-only notifies (noise floor every 5s, battery, live stats) and the dense-mesh passive-RX pin storm (10-20x/sec). This kept the CPU/GPU pinned. Present in the weeks-old build too, which matched the reports. Fix (Tier B): - AppStateProvider gains `mapRevision` plus `_notifyMapNow()` (immediate) and `_notifyMapThrottled()` (250ms leading+trailing coalescing). Only map-relevant mutations (TX/RX/disc/trace markers, echoes, GPS position, repeater load, history view, marker/log clears, marker-style prefs) bump mapRevision; the RX-pin/echo storm goes through the throttle (~4/sec cap). UI-only notifies (noise floor, battery, stats) leave mapRevision untouched. - MapWidget is wrapped in a Selector keyed on (mapRevision, focus, history, padding, controls) in home_screen.dart, so it is cached across all UI-only notifies, and its top-level subscription switches from context.watch to read. Result: the map rebuilds only when map data/layout actually changes, at a bounded rate, instead of continuously. Investigated and found NOT to be causes (no change): - 500ms countdown timer: already isolated into its own ChangeNotifier. - graph_screen 2s live timer: it's a pushed MaterialPageRoute (disposed on pop), not an offscreen IndexedStack child — no continuous offscreen cost. - _saveLastPosition: already throttled to 30s; checkDistanceTriggers gates at 25m. - _computeMarkerDataVersion heard/discovered sums: they drive marker color transitions, so they must stay; Tier B makes the O(N) loop run rarely. flutter analyze: clean. flutter test: 39/39 pass. --- lib/providers/app_state_provider.dart | 88 +++++++++++++++++++++------ lib/screens/home_screen.dart | 30 +++++++-- lib/widgets/map_widget.dart | 7 ++- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7c7f56c9..d8a44440 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1191,9 +1191,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; - notifyListeners(); + // Position change is map-relevant (auto-follow camera) — bump the map. + _notifyMapNow(); - // Save last position for next app launch + // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); // Check zone on first GPS lock (when _inZone is null) @@ -2395,7 +2396,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); - notifyListeners(); + _notifyMapNow(); }; _pingService!.onRxPing = (ping) { @@ -2415,7 +2416,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); _updateRxOverlaySlot(ping.repeaterId, ping.snr); - notifyListeners(); + _notifyMapThrottled(); }; _pingService!.onStatsUpdated = (stats) { @@ -2493,7 +2494,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); - notifyListeners(); + _notifyMapThrottled(); debugLog('[APP] notifyListeners() completed'); } else { debugLog( @@ -2545,7 +2546,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { multiHopEvents: multiHopEvents, ); - notifyListeners(); + _notifyMapThrottled(); } } }; @@ -2587,7 +2588,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(), OverlayPingType.disc); - notifyListeners(); + _notifyMapThrottled(); }; _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { @@ -2709,7 +2710,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { localRssi: result.localRssi, success: true, ); - notifyListeners(); + _notifyMapNow(); } } @@ -2943,7 +2944,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), ], ); - notifyListeners(); + _notifyMapThrottled(); } else { debugLog( '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); @@ -3066,8 +3067,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, ); - // Update UI - notifyListeners(); + // Update UI (throttled — dense mesh RX must not churn the map) + _notifyMapThrottled(); } catch (e, stackTrace) { debugError('[APP] Error in finalized RX entry callback: $e'); debugError('[APP] Stack trace: $stackTrace'); @@ -4140,7 +4141,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _traceLogEntries.clear(); _clearOverlayState(); _pingService?.resetStats(); - notifyListeners(); + _notifyMapNow(); } /// Clear log entries @@ -4151,7 +4152,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _traceLogEntries.clear(); _errorLogEntries.clear(); _clearOverlayState(); - notifyListeners(); + _notifyMapNow(); } /// Add a discovery log entry (from Passive Mode) @@ -4162,7 +4163,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog( '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); - notifyListeners(); + _notifyMapNow(); } /// Add a trace log entry (from Trace Mode) @@ -4183,7 +4184,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { OverlayPingType.trace); } - notifyListeners(); + _notifyMapNow(); } /// Log a user-facing error message @@ -5076,7 +5077,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; - notifyListeners(); + // Marker-style / GPS-marker prefs can change here — bump the map. + _notifyMapNow(); _savePreferences(); } @@ -6519,7 +6521,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _repeatersLoaded = true; _repeatersLoadedForIata = iata; debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + _notifyMapNow(); } else { debugWarn( '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); @@ -7462,6 +7464,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectRestoreGeneration++; } + // ============================================ + // Map rebuild isolation (overheating fix) + // ============================================ + // + // The MapWidget (MapLibre GL) is by far the most expensive subtree. It used + // to rebuild on EVERY notifyListeners() — including high-frequency UI-only + // notifies (noise floor every 5s, battery, live stats) and the dense-mesh + // RX-pin storm (10-20x/sec) — which kept the GPU/CPU pinned and overheated + // the phone. Now the map is wrapped in a Selector keyed on [mapRevision] + // (see home_screen.dart `_buildLayout`), so it only rebuilds when a + // map-relevant change bumps the revision. UI-only notifies leave + // [mapRevision] untouched, so the map stays cached. + int _mapRevision = 0; + + /// Monotonic counter bumped whenever map-rendered data changes (markers, + /// position, history view). The map's Selector watches this; UI-only + /// notifies (noise floor, battery, stats) intentionally do not bump it. + int get mapRevision => _mapRevision; + + Timer? _mapThrottleTimer; + bool _mapThrottlePending = false; + static const Duration _mapThrottleWindow = Duration(milliseconds: 250); + + /// Bump the map revision and notify immediately. Use for low-frequency + /// map-relevant changes (TX ping added, ping window finalized, GPS position, + /// history view, marker/log clears, marker-style preference changes). + void _notifyMapNow() { + _mapRevision++; + notifyListeners(); + } + + /// Bump the map revision but coalesce notifications to ~4/sec. Use for + /// high-frequency map-relevant changes (passive RX pins, echo bursts) so a + /// dense mesh cannot force the map to rebuild 10-20x/sec. The pin data is + /// updated immediately; only the rebuild signal is throttled. + void _notifyMapThrottled() { + _mapRevision++; + if (_mapThrottleTimer != null) { + _mapThrottlePending = true; + return; + } + notifyListeners(); + _mapThrottleTimer = Timer(_mapThrottleWindow, () { + _mapThrottleTimer = null; + if (_mapThrottlePending) { + _mapThrottlePending = false; + notifyListeners(); + } + }); + } + @override @override void notifyListeners() { @@ -7490,6 +7543,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneRefreshTimer?.cancel(); _cancelZoneGraceTimers(); _vectorFreshTimer?.cancel(); + _mapThrottleTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7960b018..8be9aa35 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -423,10 +423,32 @@ class _HomeScreenState extends State { children: [ if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( - child: MapWidget( - bottomPaddingPixels: isLandscape || appState.isFocusModeActive || appState.viewingHistorySession ? 0 : _getControlPanelHeight(), - mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, - onMapControlsToggle: isLandscape ? _toggleMapControls : null, + // The MapLibre map is the most expensive subtree in the app. + // Isolate it from the provider's high-frequency UI-only notifies + // (noise floor, battery, live stats) and the dense-mesh RX-pin + // storm by rebuilding it ONLY when a map-relevant value changes: + // AppStateProvider.mapRevision (markers/position/history) or the + // map's own layout inputs (focus/history/padding/controls). This + // Selector caches the map subtree across all other notifies and is + // the core of the overheating fix. + child: Selector( + selector: (_, p) => ( + rev: p.mapRevision, + focus: p.isFocusModeActive, + history: p.viewingHistorySession, + padH: isLandscape || + p.isFocusModeActive || + p.viewingHistorySession + ? 0.0 + : _getControlPanelHeight(), + ctrl: isLandscape ? _mapControlsExpanded : null, + ), + builder: (_, s, __) => MapWidget( + bottomPaddingPixels: s.padH, + mapControlsExpanded: s.ctrl, + onMapControlsToggle: isLandscape ? _toggleMapControls : null, + ), ), ), ], diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 25f38750..24c37b67 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -979,7 +979,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - final appState = context.watch(); + // NOTE: read (not watch). This widget is rebuilt by a Selector in + // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), + // so it must NOT subscribe to the whole provider — that was the overheating + // root cause (the map rebuilt on every notifyListeners). build() runs + // whenever the Selector rebuilds it, at which point read gives fresh state. + final appState = context.read(); // Load saved map toggle preferences once, after Hive has finished loading if (!_prefsApplied && appState.preferencesLoaded) { From b80bc1a536adf781fee5288ab2cd906809990eab Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 13 Jun 2026 23:51:28 -0400 Subject: [PATCH 128/360] docs: document map rebuild isolation (mapRevision) architecture Add the "Map Rebuild Isolation" section to DEVELOPMENT.md and CLAUDE.md, and a new Critical Rule 9 in CLAUDE.md: map-rendered state must bump mapRevision via _notifyMapNow()/_notifyMapThrottled() (not plain notifyListeners()) so the map, now isolated behind a Selector + context.read, still updates. --- DEVELOPMENT.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2e636b20..a0569c23 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -90,6 +90,30 @@ The app uses a layered service architecture with clear separation of concerns: - `AppStateProvider`: Single ChangeNotifier for all app state using Provider pattern - All UI updates happen via `notifyListeners()` after state mutations +### Map Rebuild Isolation + +The MapLibre `MapWidget` is by far the most expensive subtree. It is therefore +**not** subscribed to the whole provider — that previously made it rebuild on +every `notifyListeners()` (including noise-floor/battery/stats every few seconds +and the dense-mesh passive-RX pin storm at 10–20×/sec), which pinned the CPU/GPU +and overheated the device during wardriving. + +Instead the map is isolated: +- `AppStateProvider` exposes `mapRevision`, an integer bumped only when + **map-rendered** state changes (TX/RX/disc/trace markers, echoes, GPS + position, zone repeater load, history view, marker/log clears, marker-style + prefs). +- Two helpers drive it: `_notifyMapNow()` (bump + immediate notify, for + low-frequency changes) and `_notifyMapThrottled()` (bump + ~250 ms + leading+trailing coalescing, for the high-frequency RX/echo storm — caps map + rebuilds at ~4/sec while pin data updates immediately). +- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildLayout`) + keyed on `(mapRevision, focus, history, padding, controls)` and uses + `context.read` internally, so it is cached across all UI-only notifies. +- UI-only state (noise floor, battery, live stats) calls plain + `notifyListeners()` and leaves `mapRevision` untouched, so the status bar + updates without rebuilding the map. + ### 9-Step Connection Workflow Critical safety: The connection sequence MUST complete in order. From 489064d1e86b028f93d7831c5eb2cb2e662c5811 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 14 Jun 2026 20:55:46 -0400 Subject: [PATCH 129/360] perf: make the mode-button glow static (stop the all-session GPU frame pump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Active/Hybrid/Passive mode buttons ran a repeating 1200ms pulse AnimationController (_pulseController.repeat) the entire time a mode was running. A repeating AnimationController keeps Flutter's vsync ticker alive, so the GPU/compositor never idled — on a 120Hz device the app rendered ~120fps for the whole wardriving session for a purely cosmetic breathing glow, even parked. Profile-mode measurement (SchedulerBinding.addTimingsCallback FPS logger): during active Hybrid mode the app sat at ~113-129 fps continuously; with the pulse removed it drops to ~25-28 fps (renders only when something actually changes). The button still reads as active via color, the indicator dot, the "Active" text, and the live countdown — only the animated breathing is gone. Removes the AnimationController / AnimatedBuilder from _ActionButton, _LandscapeIconButton, and _CompactActionButton; the active-state background opacity is now a static value. flutter analyze: clean. flutter test: 39/39 pass. --- lib/widgets/ping_controls.dart | 157 ++++----------------------------- 1 file changed, 17 insertions(+), 140 deletions(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index b79c981c..a43174af 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -380,45 +380,7 @@ class _ActionButton extends StatefulWidget { State<_ActionButton> createState() => _ActionButtonState(); } -class _ActionButtonState extends State<_ActionButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - // Pulse opacity from 0.3 to 0.6 for a subtle glow effect - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_ActionButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _ActionButtonState extends State<_ActionButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -428,14 +390,13 @@ class _ActionButtonState extends State<_ActionButton> final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; + // Static active-state background opacity. This was a repeating pulse + // animation, removed because a .repeat() AnimationController kept the GPU + // rendering at the display refresh rate for the entire wardriving session. + // The button still reads as "active" via color, the dot, and the text. + final bgOpacity = widget.isActive ? 0.25 : 0.12; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - // Use animated opacity for active state background - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( + return Material( color: Colors.transparent, child: InkWell( onTap: widget.enabled ? widget.onPressed : null, @@ -534,8 +495,6 @@ class _ActionButtonState extends State<_ActionButton> ), ), ); - }, - ); } } @@ -1769,57 +1728,18 @@ class _LandscapeIconButton extends StatefulWidget { State<_LandscapeIconButton> createState() => _LandscapeIconButtonState(); } -class _LandscapeIconButtonState extends State<_LandscapeIconButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_LandscapeIconButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _LandscapeIconButtonState extends State<_LandscapeIconButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + // Static active-state opacity (continuous pulse animation removed — see + // _ActionButton; it kept the GPU rendering all session). + final bgOpacity = widget.isActive ? 0.25 : 0.10; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.10; - - return Tooltip( + return Tooltip( message: widget.tooltip, child: Material( color: Colors.transparent, @@ -1892,8 +1812,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> ), ), ); - }, - ); } } @@ -1926,44 +1844,7 @@ class _CompactActionButton extends StatefulWidget { State<_CompactActionButton> createState() => _CompactActionButtonState(); } -class _CompactActionButtonState extends State<_CompactActionButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_CompactActionButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _CompactActionButtonState extends State<_CompactActionButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -1972,13 +1853,11 @@ class _CompactActionButtonState extends State<_CompactActionButton> showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); + // Static active-state opacity (continuous pulse animation removed — see + // _ActionButton; it kept the GPU rendering all session). + final bgOpacity = widget.isActive ? 0.25 : 0.12; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( + return Material( color: Colors.transparent, child: InkWell( onTap: widget.enabled ? widget.onPressed : null, @@ -2069,7 +1948,5 @@ class _CompactActionButtonState extends State<_CompactActionButton> ), ), ); - }, - ); } } From 3c2782063c8952e6ef40b4aea37a3d4735316fed Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 14 Jun 2026 22:08:30 -0400 Subject: [PATCH 130/360] perf: stop the map relayouting on every GPS tick (wardrive overheat fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MapWidget's Selector was being silently defeated on every HomeScreen rebuild. HomeScreen.build() uses context.watch, so it rebuilds on every notifyListeners() — including the ~2Hz GPS notify. Each rebuild constructed a fresh inline Selector instance, and provider's Selector invalidates its cache whenever `oldWidget != widget` (selector.dart:77), so the cached MapWidget was rebuilt BEFORE the value comparison ran — relayouting the iOS platform view (~24ms) on every GPS position update. Measured on-device (iPhone, profile mode): GPS-driven map builds dropped from 2-3x/sec @ ~24ms to 0/sec; per-frame build time fell ~24ms -> ~1ms while the camera still follows in real time. Two changes: - Decouple GPS from mapRevision: the GPS listener now calls plain notifyListeners(); camera-follow, derived heading and the GPS puck are driven from a direct provider listener (_onPositionNotify -> _handleGpsPosition) via the native controller (animateCamera / updateSymbol), preserving the real-time nav feel with no widget rebuild. - Memoize the map Selector (home_screen _buildMapSelector) so its widget identity is stable across HomeScreen's per-notify rebuilds, letting the Selector's value comparison actually gate the map. --- DEVELOPMENT.md | 27 +++- lib/providers/app_state_provider.dart | 14 +- lib/screens/home_screen.dart | 74 +++++++---- lib/widgets/map_widget.dart | 179 +++++++++++++++----------- 4 files changed, 188 insertions(+), 106 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a0569c23..6005f94c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -100,20 +100,39 @@ and overheated the device during wardriving. Instead the map is isolated: - `AppStateProvider` exposes `mapRevision`, an integer bumped only when - **map-rendered** state changes (TX/RX/disc/trace markers, echoes, GPS - position, zone repeater load, history view, marker/log clears, marker-style - prefs). + **map-rendered** state changes (TX/RX/disc/trace markers, echoes, zone + repeater load, history view, marker/log clears, marker-style prefs). - Two helpers drive it: `_notifyMapNow()` (bump + immediate notify, for low-frequency changes) and `_notifyMapThrottled()` (bump + ~250 ms leading+trailing coalescing, for the high-frequency RX/echo storm — caps map rebuilds at ~4/sec while pin data updates immediately). -- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildLayout`) +- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildMapSelector`) keyed on `(mapRevision, focus, history, padding, controls)` and uses `context.read` internally, so it is cached across all UI-only notifies. - UI-only state (noise floor, battery, live stats) calls plain `notifyListeners()` and leaves `mapRevision` untouched, so the status bar updates without rebuilding the map. +**GPS position does NOT bump `mapRevision`.** Position updates ~1–2×/sec while +driving; rebuilding the map that often relayouts the iOS platform view (~24 ms +each) — a dominant heat source. Instead, the GPS listener calls plain +`notifyListeners()`, and `MapWidget` drives camera-follow, derived heading, and +the GPS puck from a **direct provider listener** (`_onPositionNotify` → +`_handleGpsPosition`) that calls the native controller (`animateCamera` / +`updateSymbol`) every tick — real-time nav, no widget rebuild. The GPS-info +overlay rebuilds only when the map itself does. + +**The Selector MUST be memoized (identity-stable).** `HomeScreen.build()` uses +`context.watch`, so it rebuilds on every notify (incl. the 2 Hz GPS one). +provider's `Selector` invalidates its cache whenever `oldWidget != widget` +(`selector.dart:77`), so a fresh inline `Selector(...)` instance each build +forces `MapWidget` to rebuild **before** the value comparison ever runs — +silently defeating the isolation. `_buildMapSelector` therefore caches the +`Selector` instance, keyed only on the State fields its closures capture +(`isLandscape` / `_isControlsMinimized` / `_mapControlsExpanded`), so its +identity survives parent rebuilds and the value comparison actually gates the +map. + ### 9-Step Connection Workflow Critical safety: The connection sequence MUST complete in order. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d8a44440..91e6420b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1191,8 +1191,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; - // Position change is map-relevant (auto-follow camera) — bump the map. - _notifyMapNow(); + // Do NOT bump mapRevision here. Position drives the camera/puck/coords + // directly (MapWidget._onPositionNotify listener + a Selector on the + // GPS-info overlay) — all real-time — WITHOUT rebuilding the map, which + // would relayout the iOS platform view (~28ms) every GPS tick. A plain + // notifyListeners() reaches those position watchers; the map's Selector + // (keyed on mapRevision) stays cached. + notifyListeners(); // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); @@ -5291,7 +5296,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _mapNavigationTarget = (lat: latitude, lon: longitude); _mapNavigationTrigger++; // Increment to trigger listeners _requestMapTabSwitch = true; // Request tab switch - notifyListeners(); + // Map-relevant: the build()-side nav block reads mapNavigationTrigger, so + // the map must rebuild for the camera jump to fire (GPS no longer forces + // frequent rebuilds after the position/map-rebuild decouple). + _notifyMapNow(); } /// Clear the map tab switch request (called by main scaffold after switching) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8be9aa35..b974b8cf 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -29,6 +29,51 @@ class _HomeScreenState extends State { /// Landscape side panel width (220px gives more room for controls) static const double _landscapePanelWidth = 220.0; + // ── Map Selector memoization (overheating fix) ──────────────────────────── + // HomeScreen.build() runs context.watch, so it rebuilds on EVERY provider + // notify (incl. the 2Hz GPS notify). An inline `Selector(...)` is a NEW widget + // instance each build, and provider's Selector invalidates its cache whenever + // `oldWidget != widget` (selector.dart:77) — so a fresh instance forces the + // cached MapWidget to rebuild BEFORE the value comparison runs, relayouting the + // iOS platform view (~24ms) every GPS tick. Returning the SAME Selector instance + // (identity stable) makes Flutter short-circuit the child and lets the value + // comparison actually gate the map. The instance is keyed only on the State + // fields its closures capture (isLandscape / _isControlsMinimized / + // _mapControlsExpanded); provider values (mapRevision/focus/history) are read + // live inside the closures and handled by the Selector's value comparison. + Widget? _cachedMapSelector; + ({bool landscape, bool minimized, bool ctrlExpanded})? _cachedMapSelectorKey; + + Widget _buildMapSelector(bool isLandscape) { + final key = ( + landscape: isLandscape, + minimized: _isControlsMinimized, + ctrlExpanded: _mapControlsExpanded, + ); + if (_cachedMapSelector != null && _cachedMapSelectorKey == key) { + return _cachedMapSelector!; + } + _cachedMapSelectorKey = key; + _cachedMapSelector = Selector( + selector: (_, p) => ( + rev: p.mapRevision, + focus: p.isFocusModeActive, + history: p.viewingHistorySession, + padH: isLandscape || p.isFocusModeActive || p.viewingHistorySession + ? 0.0 + : _getControlPanelHeight(), + ctrl: isLandscape ? _mapControlsExpanded : null, + ), + builder: (_, s, __) => MapWidget( + bottomPaddingPixels: s.padH, + mapControlsExpanded: s.ctrl, + onMapControlsToggle: isLandscape ? _toggleMapControls : null, + ), + ); + return _cachedMapSelector!; + } + /// Toggle control panel in landscape mode (closes map controls if open) void _toggleControlPanel() { setState(() { @@ -428,28 +473,13 @@ class _HomeScreenState extends State { // (noise floor, battery, live stats) and the dense-mesh RX-pin // storm by rebuilding it ONLY when a map-relevant value changes: // AppStateProvider.mapRevision (markers/position/history) or the - // map's own layout inputs (focus/history/padding/controls). This - // Selector caches the map subtree across all other notifies and is - // the core of the overheating fix. - child: Selector( - selector: (_, p) => ( - rev: p.mapRevision, - focus: p.isFocusModeActive, - history: p.viewingHistorySession, - padH: isLandscape || - p.isFocusModeActive || - p.viewingHistorySession - ? 0.0 - : _getControlPanelHeight(), - ctrl: isLandscape ? _mapControlsExpanded : null, - ), - builder: (_, s, __) => MapWidget( - bottomPaddingPixels: s.padH, - mapControlsExpanded: s.ctrl, - onMapControlsToggle: isLandscape ? _toggleMapControls : null, - ), - ), + // map's own layout inputs (focus/history/padding/controls). The + // Selector is MEMOIZED (_buildMapSelector) so its widget identity + // is stable across HomeScreen's per-notify rebuilds — otherwise a + // fresh Selector instance defeats its own cache (selector.dart:77, + // `oldWidget != widget`) and the map rebuilds every GPS tick. This + // is the core of the overheating fix. + child: _buildMapSelector(isLandscape), ), ], ), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 24c37b67..eaad09a2 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -581,6 +581,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // the user happens to touch the map. _patchProviderRef = context.read(); _patchProviderRef!.addListener(_onCoveragePatchNotify); + // GPS position drives camera-follow / heading / puck via a DIRECT listener + // (not build()): the camera follows every tick WITHOUT rebuilding the map, + // which would relayout the iOS platform view (~28 ms/tick). See + // _handleGpsPosition. GPS notifies no longer bump mapRevision, so the map's + // Selector doesn't rebuild on position. + _patchProviderRef!.addListener(_onPositionNotify); } AppStateProvider? _patchProviderRef; @@ -594,6 +600,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _applyCoveragePatch(appState); } + /// Fires on every provider notify; drives real-time camera-follow + GPS puck + /// from the current position without rebuilding the map. Heavy work inside is + /// version-gated, so non-position notifies are a cheap no-op. + void _onPositionNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + _handleGpsPosition(appState); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { final wasBackground = _appLifecycleState != AppLifecycleState.resumed; @@ -631,6 +646,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); _patchProviderRef?.removeListener(_onCoveragePatchNotify); + _patchProviderRef?.removeListener(_onPositionNotify); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -977,24 +993,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { position.latitude + latOffset, position.longitude + lonOffset); } - @override - Widget build(BuildContext context) { - // NOTE: read (not watch). This widget is rebuilt by a Selector in - // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), - // so it must NOT subscribe to the whole provider — that was the overheating - // root cause (the map rebuilt on every notifyListeners). build() runs - // whenever the Selector rebuilds it, at which point read gives fresh state. - final appState = context.read(); - - // Load saved map toggle preferences once, after Hive has finished loading - if (!_prefsApplied && appState.preferencesLoaded) { - _prefsApplied = true; - _autoFollow = appState.preferences.mapAutoFollow; - _alwaysNorth = appState.preferences.mapAlwaysNorth; - _rotationLocked = appState.preferences.mapRotationLocked; - } - - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + /// Drives camera-follow, derived heading, one-time zooms, and the GPS-marker + /// puck from the current GPS position. Invoked on EVERY GPS tick by a direct + /// provider listener (`_onPositionNotify`) so the camera follows in real time + /// WITHOUT rebuilding `MapWidget` — a rebuild relayouts the iOS platform view + /// (~28 ms) every tick, which was the dominant wardriving CPU/heat cost. The + /// camera move itself is the same native controller call (`animateCamera`) as + /// before; only its trigger moved out of `build()`. Also called from `build()` + /// as an idempotent safety net (all heavy work is version-gated). + void _handleGpsPosition(AppStateProvider appState) { + // Map center — prefer current GPS, fallback to last known. LatLng center = _defaultCenter; if (appState.currentPosition != null) { center = LatLng( @@ -1008,8 +1016,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - // One-time zoom to last known position when GPS is not yet available - // This runs before GPS locks, so user sees their previous location instead of Ottawa + // One-time zoom to last known position when GPS is not yet available. if (appState.currentPosition == null && appState.lastKnownPosition != null && !_hasZoomedToLastKnown && @@ -1029,14 +1036,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } if (appState.currentPosition != null) { - // Recompute our derived heading for this frame. _computedHeading is - // updated as a side effect; use it below instead of reading - // currentPosition.heading directly (which is unreliable at low speeds). + // Recompute derived heading (more reliable than position.heading at low + // speeds); _computedHeading is updated as a side effect. _computeHeading(appState.currentPosition!); - // One-time initial zoom to GPS when we first get a position - // This happens even with auto-follow disabled so user sees their location - // Don't apply panel offset - center directly on GPS so pin is in middle of screen + // One-time initial zoom to GPS (even with auto-follow off, centered on GPS). if (!_hasInitialZoomed && _isMapReady && _canAnimateCamera) { _hasInitialZoomed = true; final initialPosition = center; @@ -1044,8 +1048,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { if (_autoFollow) { - // Auto-follow is on and panel may be open — apply panel offset so - // the marker appears centered in the visible map area. final adjustedPosition = _offsetPositionForPadding( initialPosition, widget.bottomPaddingPixels, @@ -1063,12 +1065,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }); } - // Auto-follow GPS position when enabled. When auto-follow is on we - // bundle pan, zoom, and bearing into a single animateCamera call so - // the three don't race each other. _autoFollowDesiredZoom is the - // zoom the camera is animating toward — using it instead of the - // (potentially interpolated) current zoom prevents drift during the - // initial zoom animation after tapping center-on-position. + // Auto-follow: bundle pan, zoom, and bearing into one animateCamera call. if (_autoFollow && _isMapReady && _cameraAnimationReady) { final newPosition = center; if (_lastGpsPosition == null || @@ -1082,9 +1079,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final double targetZoom = _autoFollowDesiredZoom ?? _mapController?.cameraPosition?.zoom ?? _defaultZoom; - // Track _lastHeading here too so the separate rotation block - // below (which runs when auto-follow is off) doesn't fire a - // redundant rotation animation on the next frame. if (!_alwaysNorth && _computedHeading != null) { _lastHeading = _computedHeading; } @@ -1107,19 +1101,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - // Handle map rotation based on heading when NOT auto-following. - // When auto-follow is on, rotation is bundled into the combined - // camera update above so we don't race two animateCamera calls. + // Heading-based rotation when NOT auto-following. if (!_autoFollow && !_alwaysNorth && _isMapReady && _computedHeading != null) { final heading = _computedHeading!; if (_lastHeading == null) { - // First heading after startup — store without rotating so the - // initial zoom animation can settle at rotation 0 (where the - // panel offset was computed). Heading mode will begin rotating - // on the next GPS update when heading changes. _lastHeading = heading; debugLog( '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); @@ -1133,12 +1121,79 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } } else { - // GPS lock lost — clear bearing state so reacquisition starts fresh - // instead of snapping the marker/map to a stale direction. + // GPS lock lost — clear bearing state so reacquisition starts fresh. _bearingAnchor = null; _computedHeading = null; } + // GPS marker puck — cheap updateSymbol, gated by gpsVersion (position + + // heading + style). Real-time every tick; does NOT go through the heavy + // _syncAllAnnotations pipeline. + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _cameraAnimationReady) { + final gpsVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + if (gpsVersion != _lastGpsSyncVersion) { + _lastGpsSyncVersion = gpsVersion; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + if (_gpsSyncInFlight) return; + _gpsSyncInFlight = true; + try { + await _syncGpsSymbol(appState); + } catch (e) { + debugError('[MAP] _syncGpsSymbol failed: $e'); + } finally { + _gpsSyncInFlight = false; + } + }); + } + } + } + + @override + Widget build(BuildContext context) { + // NOTE: read (not watch). This widget is rebuilt by a Selector in + // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), + // so it must NOT subscribe to the whole provider — that was the overheating + // root cause (the map rebuilt on every notifyListeners). build() runs + // whenever the Selector rebuilds it, at which point read gives fresh state. + final appState = context.read(); + + // Load saved map toggle preferences once, after Hive has finished loading + if (!_prefsApplied && appState.preferencesLoaded) { + _prefsApplied = true; + _autoFollow = appState.preferences.mapAutoFollow; + _alwaysNorth = appState.preferences.mapAlwaysNorth; + _rotationLocked = appState.preferences.mapRotationLocked; + } + + // Determine map center - prefer current GPS, fallback to last known, then Ottawa + LatLng center = _defaultCenter; + if (appState.currentPosition != null) { + center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + } else if (appState.lastKnownPosition != null) { + center = LatLng( + appState.lastKnownPosition!.lat, + appState.lastKnownPosition!.lon, + ); + } + + // Camera-follow / derived heading / one-time zooms / GPS-marker puck now + // run in _handleGpsPosition, invoked every GPS tick by _onPositionNotify + // (real-time follow WITHOUT rebuilding the map / relayouting the platform + // view). Called here too as an idempotent safety net — version-gated. + _handleGpsPosition(appState); + // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location if (_isMapReady && @@ -1235,37 +1290,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - // GPS marker has its own lightweight gate. Position/heading change every - // GPS tick during auto-follow, but updating the GPS symbol is one cheap - // updateSymbol call — it does not need the heavy _syncAllAnnotations - // pipeline, and routing it through there caused setGeoJsonSource on the - // repeater cluster source to fire every tick, which made MapLibre - // re-run its global symbol collision pass and flickered the base-style - // POI labels at high zoom. The gpsMarkerStyle pref is included so style - // changes (arrow → walk, etc.) re-render the marker's bitmap. - if (_isMapReady && _styleLoaded && _imagesRegistered && _cameraAnimationReady) { - final gpsVersion = Object.hash( - appState.currentPosition?.latitude, - appState.currentPosition?.longitude, - _computedHeading, - appState.preferences.gpsMarkerStyle, - ); - if (gpsVersion != _lastGpsSyncVersion) { - _lastGpsSyncVersion = gpsVersion; - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted) return; - if (_gpsSyncInFlight) return; - _gpsSyncInFlight = true; - try { - await _syncGpsSymbol(appState); - } catch (e) { - debugError('[MAP] _syncGpsSymbol failed: $e'); - } finally { - _gpsSyncInFlight = false; - } - }); - } - } + // (GPS marker puck sync moved into _handleGpsPosition — runs every tick.) final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; From d5b556d4a9c6fc8cb5ffb7eb0cb3e889978f015f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 16 Jun 2026 15:38:56 -0400 Subject: [PATCH 131/360] feat: bake repeater hex into Detailed-mode chip icons; rename "Coverage Grid" -> "Grid Mode" --- lib/providers/app_state_provider.dart | 2 +- lib/screens/settings_screen.dart | 6 +- lib/widgets/map_widget.dart | 312 ++++++++++++++++++++++---- 3 files changed, 269 insertions(+), 51 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 91e6420b..c8d526c8 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -622,7 +622,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Map get coveragePatchCells => _coveragePatchCells; /// Drop the session patch — the cells belong to one region + grid preset - /// (called on zone change and when the Coverage Grid preference changes). + /// (called on zone change and when the Grid Mode preference changes). void clearCoveragePatch() { _coveragePatchCells.clear(); _coveragePatchVersion++; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2ac78c91..83a8b088 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -252,7 +252,7 @@ class _SettingsScreenState extends State { if (prefs.mapTilesEnabled) ListTile( leading: const Icon(Icons.grid_on), - title: const Text('Coverage Grid'), + title: const Text('Grid Mode'), subtitle: Text(prefs.coverageGridSize == 100 ? 'Detailed (100 m cells)' : 'Simplified (300 m cells)'), @@ -1305,7 +1305,7 @@ class _SettingsScreenState extends State { }; } - /// Coverage grid preset selector — mirrors the web UI's Grid Mode + /// Grid Mode preset selector — mirrors the web UI's Grid Mode /// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side). void _showCoverageGridSelector( BuildContext context, AppStateProvider appState) { @@ -1324,7 +1324,7 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Coverage Grid', + child: Text('Grid Mode', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index eaad09a2..3714543c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -53,6 +53,15 @@ class _MapImages { static String repeater(String status, int hopBytes) => 'rep_${status}_$hopBytes'; + // Detailed-mode baked repeater CHIP bitmaps: status × hop_bytes × hex label. + // The hex is baked into the icon (no text-field) so overlapping un-clustered + // chips can't have a label detach onto a neighbour's box. One image per + // distinct (status, hop, hex); registered lazily + deduped. See + // _renderRepeaterChipPng / _ensureRepeaterChipImages. + // Names: repchip_active_2_A1B2, etc. + static String repeaterChip(String status, int hopBytes, String hex) => + 'repchip_${status}_${hopBytes}_$hex'; + static const repeaterStatuses = ['active', 'dead', 'new', 'dup']; static const repeaterHopBytes = [1, 2, 3]; @@ -158,6 +167,102 @@ Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( ); } +/// Bakes a complete repeater "chip" — the status-colored rounded box plus its +/// centered hex label — into a single PNG, so the label is part of the icon and +/// can never detach onto a neighbouring chip's box (the MapLibre symbol two-pass +/// "all icons, then all glyphs" overlap bug). Used ONLY in Detailed grid mode, +/// where repeaters are un-clustered and can overlap. Simplified mode keeps the +/// cheap shared-glyph text-field path (clustering guarantees ≥50px spacing). +/// +/// Variable width — sized to the measured hex like [_renderDistanceLabelPng]. +/// Baked at devicePixelRatio 3.0 to stay crisp on hi-DPI; rendered with +/// iconSize 1.0 + center anchor. Box visuals mirror [_RepeaterShapePainter] +/// (drop shadow, filled box, 2px white border) so chips match the Simplified +/// shape markers. +Future _renderRepeaterChipPng( + String hex, + Color fill, + double borderRadius, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 13.0; + const horizontalPad = 8.0; // inside the box, each side + const boxHeight = 26.0; + const shadowBlur = 4.0; + const margin = 5.0; // room around the box for the (blurred, +2px) shadow + + final textPainter = TextPainter( + text: TextSpan( + text: hex, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final boxWidth = textPainter.width + horizontalPad * 2; + final logicalWidth = boxWidth + margin * 2; + const logicalHeight = boxHeight + margin * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + final boxRect = Rect.fromLTWH(margin, margin, boxWidth, boxHeight); + final radius = Radius.circular(borderRadius); + + // Drop shadow (positioned 2px below the box). + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect.shift(const Offset(0, 2)), radius), + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur), + ); + + // Filled colored box. + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, radius), + Paint()..color = fill, + ); + + // White border (2px, drawn just inside the box edge). + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.deflate(1), + Radius.circular(borderRadius - 1), + ), + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0, + ); + + // Centered hex label. + textPainter.paint( + canvas, + Offset( + margin + (boxWidth - textPainter.width) / 2, + margin + (boxHeight - textPainter.height) / 2, + ), + ); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode repeater chip to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} + Future _renderPainterToPng( CustomPainter painter, Size size, { @@ -508,6 +613,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // needed. Right now we just re-addImage on each sync (idempotent). final Set _registeredDistanceLabelImages = {}; final Map _registeredDistanceLabelImageSizes = {}; + // Detailed-mode baked repeater chip image names already registered via + // addImage (deduped by "repchip_{status}_{hop}_{hex}"). Lazily grown by + // _ensureRepeaterChipImages; cleared on style reload (native drops images). + final Set _registeredChipImages = {}; Symbol? _gpsSymbol; // single GPS marker // When true, _syncAllAnnotations skips _updateFocusLines and @@ -1586,6 +1695,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _lastAppliedGridSize != prefsForOverlay.coverageGridSize) || (_lastAppliedCvd != null && _lastAppliedCvd != prefsForOverlay.colorVisionType)); + // A Grid Mode change ALSO toggles repeater clustering (Detailed = 100 is + // un-clustered). `cluster` is fixed at source creation, so the repeater + // source/layers must be rebuilt — not just the coverage overlay. + final gridChanged = _isMapReady && + _styleLoaded && + _lastAppliedGridSize != null && + _lastAppliedGridSize != prefsForOverlay.coverageGridSize; if (zoneChanged || overlayPrefChanged) { if (zoneChanged) { @@ -1596,7 +1712,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (overlayPrefChanged) { // Patched cell ids/geometry are per grid preset; a palette-only // change keeps them (the rebuild restyles the patch layer too). - if (_lastAppliedGridSize != prefsForOverlay.coverageGridSize) { + if (gridChanged) { appState.clearCoveragePatch(); } _lastAppliedGridSize = prefsForOverlay.coverageGridSize; @@ -1607,7 +1723,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) async { _coverageRefreshScheduled = false; if (!mounted) return; + // Rebuild the repeater source/layers with the new cluster flag BEFORE + // the coverage overlay refresh — coverage targets the bottom repeater + // layer as its belowLayerId, so those layers must exist first. Collapse + // any open spider so its (cluster-only) state doesn't leak across the + // rebuild. + if (gridChanged && _clusterLayersReady) { + _collapseSpider(); + _clusterLayersReady = false; + await _setupRepeaterClusterLayers( + clustered: appState.preferences.coverageGridSize != 100); + await _syncRepeaterSymbols(appState); + } await _refreshCoverageOverlay(appState); + // Region borders sit below the repeater stack; re-add them so they + // stay beneath the freshly-rebuilt repeater layers. + if (gridChanged && _showRegionBorders) { + await _refreshRegionBorders(appState); + } }); } } @@ -2127,6 +2260,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _distanceLabelRepeaterPos.clear(); _registeredDistanceLabelImages.clear(); _registeredDistanceLabelImageSizes.clear(); + // Detailed-mode baked repeater chips are dropped by the native side on + // style reload too — clear the cache so the next sync re-registers them. + _registeredChipImages.clear(); // Mark cluster layers as not-ready until _setupRepeaterClusterLayers // creates them on the new style. This gates build()-driven post-frame // syncs from racing ahead of source creation. @@ -2156,10 +2292,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _imagesRegistered = false; await _registerMapImages(appState); - // Set up the repeater cluster source + 3 layers. Must run AFTER images - // are registered, since the individual symbol layer's iconImage expression - // looks up names registered by _registerMapImages. - await _setupRepeaterClusterLayers(); + // Set up the repeater source + layers. Must run AFTER images are + // registered, since the individual symbol layer's iconImage expression + // looks up names registered by _registerMapImages. Clustering follows the + // Grid Mode pref: Detailed (gsize 100) renders every repeater individually. + await _setupRepeaterClusterLayers( + clustered: appState.preferences.coverageGridSize != 100); // Re-add coverage overlay AFTER cluster layers exist so _addCoverageOverlay // can target the bottom repeater layer as its belowLayerId reference. This @@ -2752,6 +2890,52 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return 'active'; } + /// Ensures every baked repeater-chip image referenced by [featureCollection] + /// (Detailed grid mode) is registered via addImage. Deduped by image name — + /// each distinct `repchip_{status}_{hop}_{hex}` chip is baked at most once and + /// cached in [_registeredChipImages] (cleared on style reload). Driven off the + /// FeatureCollection itself so the registered set can't drift from what the + /// layer actually references. The status/hop/hex are parsed back out of the + /// (controlled, underscore-free) image name to re-derive colour + radius. + Future _ensureRepeaterChipImages( + Map featureCollection) async { + if (_mapController == null) return; + final features = + (featureCollection['features'] as List?) ?? const []; + var baked = 0; + for (final f in features) { + final props = (f as Map)['properties'] as Map?; + final name = props?['iconImage'] as String?; + if (name == null || + !name.startsWith('repchip_') || + _registeredChipImages.contains(name)) { + continue; + } + // repchip_{status}_{hop}_{hex} — status/hex are underscore-free. + final parts = name.split('_'); + if (parts.length != 4) continue; + final status = parts[1]; + final hop = int.tryParse(parts[2]) ?? 1; + final hex = parts[3]; + try { + final bytes = await _renderRepeaterChipPng( + hex, + _repeaterStatusColor(status), + _repeaterBorderRadius(hop), + ); + await _mapController!.addImage(name, bytes); + _registeredChipImages.add(name); + baked++; + } catch (e) { + debugError('[MAP] render/addImage(repeater chip $name) failed: $e'); + } + } + if (baked > 0) { + debugLog('[MAP] Baked $baked new repeater chip image(s); ' + '${_registeredChipImages.length} total registered'); + } + } + /// Converts a Flutter [Color] to a `#RRGGBB` (or `#RRGGBBAA`) hex string /// for MapLibre symbol/line properties (which take CSS-style color strings). String _colorToHex(Color color, {bool includeAlpha = false}) { @@ -2776,6 +2960,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final duplicates = _getDuplicateRepeaterIds(visible); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + // Detailed (gsize 100) is un-clustered, so each feature references a baked + // chip image (hex baked in). Simplified reuses the 12 shared shape images + // + a text-field hex label. See _setupRepeaterClusterLayers. + final detailed = appState.preferences.coverageGridSize == 100; final focusActive = _focusedPingLocation != null; // While a spider is open, repeaters in the spread set are tagged with // `inSpider:true`. The individual symbol layer's filter excludes those @@ -2801,8 +2989,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : effectiveBytes == 2 ? 2 : 1; - final iconImage = _MapImages.repeater(statusKey, shapeBytes); final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + // Detailed: per-(status,hop,hex) baked chip (hex baked in); Simplified: + // shared shape image + a text-field hex label. _ensureRepeaterChipImages + // registers the chip lazily before the source is pushed. + final iconImage = detailed + ? _MapImages.repeaterChip(statusKey, shapeBytes, hex) + : _MapImages.repeater(statusKey, shapeBytes); final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); features.add({ @@ -2828,11 +3021,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return {'type': 'FeatureCollection', 'features': features}; } - /// Creates the cluster-enabled GeoJSON source and three rendering layers - /// (individual symbols, cluster bubble circles, cluster count text). Called - /// once per style load AFTER images are registered (the individual symbol - /// layer references the registered icon names via a data-driven expression). - Future _setupRepeaterClusterLayers() async { + /// Creates the repeater GeoJSON source and its rendering layers (individual + /// symbols, cluster bubble circles, cluster count text, spider line/symbols). + /// Called once per style load AFTER images are registered, and again whenever + /// Grid Mode changes (the `cluster` flag is fixed at source creation, so the + /// source must be rebuilt to toggle it). + /// + /// [clustered] follows Grid Mode: Simplified (gsize 300) clusters; Detailed + /// (gsize 100) does NOT — every repeater renders individually. The bubble / + /// count / spider layers are still created when un-clustered, but stay inert + /// (no `point_count` features ever exist, the spider source stays empty) so + /// the layer ids that other code paths reference — `queryRenderedFeatures`, + /// the region-border `belowLayerId` — remain valid in both modes. + /// + /// Styling differs by mode: clustered uses the cheap shared-glyph `textField` + /// hex label (clustering guarantees ≥50px spacing, so labels never overlap); + /// un-clustered uses the baked-in-icon chip (no `textField`) so an overlapping + /// chip's label can't detach onto a neighbour's box. + Future _setupRepeaterClusterLayers({required bool clustered}) async { if (_mapController == null) return; // Idempotent: tear down any existing source/layers from a previous style load. @@ -2855,8 +3061,39 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _mapController!.removeSource(_spiderSourceId); } catch (_) {} - // Empty source with cluster enabled. We'll push real data via setGeoJsonSource - // from _syncRepeaterSymbols whenever the marker data version changes. + // Shared symbol styling for the individual layer AND the spider symbol + // layer (the spider comment requires they look identical). Selected once by + // mode: Simplified keeps the shared-glyph text-field hex; Detailed bakes the + // hex into the chip image (no text-field, no iconColor — colour is baked in) + // so overlapping un-clustered chips can't have a label detach. iconSize 1.0 + // matches the distance-label baked-icon convention (DPR-3 PNG, centre + // anchor); the Simplified 48×28 shape keeps its existing 1.4 scale. + final SymbolLayerProperties repeaterSymbolProps = clustered + ? const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ) + : const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconSize: 1.0, + iconAllowOverlap: true, + iconIgnorePlacement: true, + ); + + // Empty source. We'll push real data via setGeoJsonSource from + // _syncRepeaterSymbols whenever the marker data version changes. `cluster` + // is fixed at creation, so a Grid Mode change rebuilds this whole source. // // IMPORTANT: pass `data` as a Dart Map (NOT jsonEncode-d string). The iOS // plugin's `buildShapeSource` assumes that if `data` is a String, it must be @@ -2865,12 +3102,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.addSource( _repeaterSourceId, - const GeojsonSourceProperties( - data: { + GeojsonSourceProperties( + data: const { 'type': 'FeatureCollection', 'features': [] }, - cluster: true, + // Simplified clusters; Detailed shows every repeater individually. + cluster: clustered, clusterRadius: 50, // Cluster at every reachable zoom (max user zoom is 17). Stacked // markers — those within `clusterRadius` pixels at the current @@ -2878,7 +3116,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // into a pile of overlapping individual symbols when the user is // zoomed all the way in. Tap a cluster → spiderfy. Markers far // enough apart that they exceed `clusterRadius` pixels at higher - // zooms still separate into individuals naturally on zoom. + // zooms still separate into individuals naturally on zoom. Inert + // when cluster is false. clusterMaxZoom: 17, ), ); @@ -2895,21 +3134,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _mapController!.addSymbolLayer( _repeaterSourceId, _repeaterIndividualLayerId, - const SymbolLayerProperties( - iconImage: ['get', 'iconImage'], - iconColor: ['get', 'color'], - iconSize: 1.4, - iconAllowOverlap: true, - iconIgnorePlacement: true, - textField: ['get', 'hex'], - textColor: '#FFFFFF', - textHaloColor: '#000000', - textHaloWidth: 1.5, - textSize: 13, - textAllowOverlap: true, - textIgnorePlacement: true, - textFont: _defaultFontStack, - ), + repeaterSymbolProps, filter: [ 'all', [ @@ -3022,21 +3247,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _mapController!.addSymbolLayer( _spiderSourceId, _spiderSymbolLayerId, - const SymbolLayerProperties( - iconImage: ['get', 'iconImage'], - iconColor: ['get', 'color'], - iconSize: 1.4, - iconAllowOverlap: true, - iconIgnorePlacement: true, - textField: ['get', 'hex'], - textColor: '#FFFFFF', - textHaloColor: '#000000', - textHaloWidth: 1.5, - textSize: 13, - textAllowOverlap: true, - textIgnorePlacement: true, - textFont: _defaultFontStack, - ), + repeaterSymbolProps, filter: [ '==', ['geometry-type'], @@ -3071,6 +3282,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } try { final geojson = _buildRepeaterFeatureCollection(appState); + // Detailed mode references baked per-chip images — make sure every one is + // registered before pushing, or the symbol layer would reference a + // missing icon. No-op in Simplified (features use the pre-registered + // shape images). Driven off the FeatureCollection, so it can't drift. + if (appState.preferences.coverageGridSize == 100) { + await _ensureRepeaterChipImages(geojson); + } await _mapController!.setGeoJsonSource(_repeaterSourceId, geojson); } catch (e) { debugError('[MAP] Failed to update repeater source: $e'); From 8eadf84b6156f97b2d099b76ce1505f410b63768 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 16 Jun 2026 22:31:41 -0400 Subject: [PATCH 132/360] fix: reword coverage mode, fix map draw from backgrounded --- lib/screens/settings_screen.dart | 8 ++++---- lib/widgets/map_widget.dart | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 83a8b088..7314dcf5 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -254,8 +254,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.grid_on), title: const Text('Grid Mode'), subtitle: Text(prefs.coverageGridSize == 100 - ? 'Detailed (100 m cells)' - : 'Simplified (300 m cells)'), + ? 'Detailed (More detailed cells, non grouped repeaters)' + : 'Simplified (Merged cells, grouped repeaters)'), trailing: const Icon(Icons.chevron_right), onTap: () => _showCoverageGridSelector(context, appState), ), @@ -1310,8 +1310,8 @@ class _SettingsScreenState extends State { void _showCoverageGridSelector( BuildContext context, AppStateProvider appState) { final options = [ - (300, 'Simplified', '300 m cells — the default, matches the web map'), - (100, 'Detailed', '100 m cells with blob smoothing — finer detail'), + (300, 'Simplified', 'Merged cells, grouped repeaters'), + (100, 'Detailed', 'More detailed cells, non grouped repeaters'), ]; showModalBottomSheet( context: context, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3714543c..5f12afb1 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -734,6 +734,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (mounted) { _cameraAnimationReady = true; _recoverCoverageOverlayIfNeeded(); + // Markers that arrived while backgrounded were skipped because the + // build() marker-sync is gated on _cameraAnimationReady (and left + // _lastMarkerDataVersion stale). Now that the gate is open, force a + // rebuild so build() detects the version diff and syncs the + // accumulated pins — otherwise they don't render until the next + // mapRevision bump (the next ping). + setState(() {}); } }); } From 5d7d3d3dae00dbdcc9e108c7b38150761f0ae968 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 17 Jun 2026 22:30:33 -0400 Subject: [PATCH 133/360] feat: in-app cell GRID SUMMARY + repeater popup; full-hex token resolution for cell MAX-DIST Brings the web's coverage "Cell Click" (GRID SUMMARY: per-status counts, AVG SNR/NOISE, MAX DIST, proportional bar graph) and "Repeater Click" (online status, fingerprint, hop bytes, schedule + clock-skew warning, first heard, max range, BIDIR/TX/RX/DISC/DEAD totals) into the app, with client-side aggregation mirroring the web. Coverage is fetched lazily via the key-gated app_coverage.php proxy. Cell MAX-DIST (GridSummary) resolves tokens via _repForToken (full-hex / narrowCandidates) instead of a bare id lookup, so it stays correct as the server normalizes coverage path tokens to a wider-than-id canonical hex. - lib/utils/coverage_summary.dart: GridSummary / RepeaterStats / RepeaterLookup / GridCell; _repForToken full-hex resolver. - lib/utils/repeater_format.dart, distance_formatter.dart: date / clock-skew / coverage-distance formatting. - lib/widgets/cell_summary_sheet.dart, map_widget.dart: cell-tap + repeater detail sheets. - lib/services/api_service.dart, providers/app_state_provider.dart: coverage fetchers. lib/models/repeater.dart: time_offset. - tests in test/utils/. --- lib/models/repeater.dart | 19 + lib/providers/app_state_provider.dart | 35 ++ lib/services/api_service.dart | 90 +++++ lib/utils/coverage_summary.dart | 523 ++++++++++++++++++++++++++ lib/utils/distance_formatter.dart | 14 + lib/utils/repeater_format.dart | 66 ++++ lib/widgets/cell_summary_sheet.dart | 300 +++++++++++++++ lib/widgets/map_widget.dart | 319 ++++++++++++++-- test/utils/coverage_summary_test.dart | 254 +++++++++++++ test/utils/repeater_format_test.dart | 68 ++++ 10 files changed, 1650 insertions(+), 38 deletions(-) create mode 100644 lib/utils/coverage_summary.dart create mode 100644 lib/utils/repeater_format.dart create mode 100644 lib/widgets/cell_summary_sheet.dart create mode 100644 test/utils/coverage_summary_test.dart create mode 100644 test/utils/repeater_format_test.dart diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index 8fafe936..8d5a6207 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -42,6 +42,11 @@ class Repeater { /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) final int hopBytes; + /// Repeater clock skew in seconds reported by the server (+ve = repeater + /// clock behind real time, -ve = ahead), or null when unknown. Drives the + /// "time is not set correctly" warning in the detail sheet. + final int? timeOffset; + const Repeater({ required this.id, required this.hexId, @@ -54,6 +59,7 @@ class Repeater { this.createdAt, this.staleTime, this.hopBytes = 1, + this.timeOffset, }); /// Parse from JSON object in repeaters.json @@ -76,6 +82,17 @@ class Repeater { staleTime = int.tryParse(rawStaleTime); } + // Parse time_offset (repeater clock skew, seconds) which may be int or String + int? timeOffset; + final rawTimeOffset = json['time_offset']; + if (rawTimeOffset is int) { + timeOffset = rawTimeOffset; + } else if (rawTimeOffset is num) { + timeOffset = rawTimeOffset.toInt(); + } else if (rawTimeOffset is String) { + timeOffset = int.tryParse(rawTimeOffset); + } + return Repeater( id: json['id'] as String, hexId: json['hex_id'] as String? ?? '', @@ -88,6 +105,7 @@ class Repeater { createdAt: createdAt, staleTime: staleTime, hopBytes: (json['hop_bytes'] as int?) ?? 1, + timeOffset: timeOffset, ); } @@ -104,6 +122,7 @@ class Repeater { 'created_at': createdAt, 'stale_time': staleTime, 'hop_bytes': hopBytes, + 'time_offset': timeOffset, }; } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c8d526c8..62ba7150 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -856,6 +856,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); + /// Lazy tap-to-inspect: fetch raw coverage points for a clicked map cell from + /// the current zone's app endpoint. Returns `[]` when there is no zone or on + /// failure. The caller aggregates these into a GRID SUMMARY (read-only — no + /// state mutation, so no `notifyListeners`). + Future>> fetchCellCoverage({ + required double lat, + required double lon, + required double radiusMeters, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + return Future.value(const >[]); + } + return _apiService.fetchMapData( + zone: zone, + lat: lat, + lon: lon, + radiusMeters: radiusMeters, + ); + } + + /// Lazy tap-to-inspect: fetch the coverage points referencing a repeater + /// (hex-prefix superset) from the current zone's app endpoint. Returns `[]` + /// when there is no zone or on failure. The caller aggregates these into the + /// repeater's BIDIR/TX/RX/DISC/DEAD totals + max range. + Future>> fetchRepeaterCoveragePoints({ + required String prefix, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + return Future.value(const >[]); + } + return _apiService.fetchRepeaterCoverage(zone: zone, prefix: prefix); + } + /// Regional boundary polygons loaded from the /border API. /// Each entry is a `{code: String, polygon: List>}` map where /// `polygon` holds `[lat, lon]` pairs in the server's original order. diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 12893dae..58c1c785 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1074,6 +1074,96 @@ class ApiService { } } + /// Fetch raw coverage points for a clicked map cell, for the tap-to-inspect + /// GRID SUMMARY. Posts to the region's app-facing endpoint + /// (`app_coverage.php` → `api.php` `map_data`); the app aggregates the points + /// client-side (see `coverage_summary.dart`). Returns `[]` on any failure. + Future>> fetchMapData({ + required String zone, + required double lat, + required double lon, + required double radiusMeters, + }) { + return _fetchCoveragePoints( + zone: zone, + label: 'map_data', + body: { + 'request': 'map_data', + 'lat': lat, + 'lon': lon, + 'radius': radiusMeters, + }, + ); + } + + /// Fetch the coverage points referencing a repeater (a hex-prefix superset), + /// for the repeater detail sheet's BIDIR/TX/RX/DISC/DEAD totals + max range. + /// Posts to `app_coverage.php` → `api.php` `repeater_coverage`. Returns `[]` + /// on any failure. + Future>> fetchRepeaterCoverage({ + required String zone, + required String prefix, + }) { + return _fetchCoveragePoints( + zone: zone, + label: 'repeater_coverage', + body: { + 'request': 'repeater_coverage', + 'prefix': prefix, + }, + ); + } + + /// Shared POST to `https://.meshmapper.net/app_coverage.php` with the app + /// key in the JSON body. Returns a list of point maps, or `[]` on any failure. + Future>> _fetchCoveragePoints({ + required String zone, + required String label, + required Map body, + }) async { + final z = zone.toLowerCase(); + final url = Uri.parse('https://$z.meshmapper.net/app_coverage.php'); + final sw = Stopwatch()..start(); + debugLog('[COVERAGE] POST /app_coverage.php ($label, zone $z)'); + try { + final response = await _client + .post( + url, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'key': apiKey, ...body}), + ) + .timeout(const Duration(seconds: 15)); + final secs = (sw.elapsedMilliseconds / 1000).toStringAsFixed(2); + + if (response.statusCode != 200) { + final snippet = response.body.isEmpty + ? '(empty)' + : (response.body.length > 200 + ? response.body.substring(0, 200) + : response.body); + debugWarn( + '[COVERAGE] $label HTTP ${response.statusCode} in ${secs}s: $snippet'); + return []; + } + + final decoded = json.decode(response.body); + if (decoded is! List) { + debugWarn('[COVERAGE] $label: unexpected response (not a JSON list)'); + return []; + } + final points = decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + debugLog('[COVERAGE] $label OK in ${secs}s: ${points.length} points'); + return points; + } catch (e) { + debugWarn( + '[COVERAGE] $label POST failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); + return []; + } + } + /// Submit wardrive data using an explicit session ID (for offline uploads) /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state Future?> submitWardriveDataWithSessionId( diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart new file mode 100644 index 00000000..f30d0650 --- /dev/null +++ b/lib/utils/coverage_summary.dart @@ -0,0 +1,523 @@ +import 'dart:math' as math; + +import '../models/repeater.dart'; + +// Client-side coverage aggregation, ported from the web client (dev/index.php) +// so the app's cell GRID SUMMARY and repeater totals stay in parity with the web. +// +// Both views fetch raw coverage points (map_data / repeater_coverage, via +// app_coverage.php) and aggregate here — exactly as generateSummaryContent +// (:13345) and renderRepeaterChart (:14063) do in the browser. +// +// Coverage status codes: 1=BIDIR, 2=TX, 5=RX, 3=DEAD, 0=DROP, 6|7=DISC. + +// --- token + coord helpers (ports of the web equivalents) ------------------- + +/// Matches `hex(?)(snr)[lat,lon]` repeater path tokens. Groups: 1=hex, 2=`?`, +/// 3=snr, 4=`lat,lon`. Mirrors the web regex used in both functions. +final RegExp _tokenRe = + RegExp(r'([a-f0-9]+)(\?)?(?:\((.*?)\))?(?:\[(.*?)\])?', caseSensitive: false); +final RegExp _hexOnlyRe = RegExp(r'[^a-fA-F0-9]'); +final RegExp _snrParenRe = RegExp(r'\(([\d.-]+)\)'); +final RegExp _coordBracketRe = RegExp(r'\[([\d.-]+),([\d.-]+)\]'); + +double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { + const r = 6371000.0; // same earth radius as Leaflet map.distance + coordsWithin100m + final dLat = (lat2 - lat1) * math.pi / 180; + final dLon = (lon2 - lon1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); +} + +/// Port of `coordsWithin100m` (`dev/index.php:6198`). +bool _within100m(double lat1, double lon1, double lat2, double lon2) => + _haversineMeters(lat1, lon1, lat2, lon2) <= 100; + +int _toInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? -999; + return -999; +} + +double? _toDouble(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; +} + +/// The coverage grid cell containing a coordinate, plus helpers to snap the +/// cell-summary fetch to the cell centre and filter pings to the cell — so the +/// summary is identical no matter where inside the cell the user taps (parity +/// with the web's `lazyShowPingsAt`). Steps come from `kCoverageGridSteps` +/// (mvt_cells.dart), byte-identical to the server grid (dev/coverage_cells.php). +class GridCell { + final int i; + final int j; + final double latStep; + final double lonStep; + const GridCell(this.i, this.j, this.latStep, this.lonStep); + + factory GridCell.containing( + double lat, double lon, double latStep, double lonStep) => + GridCell( + (lat / latStep).floor(), (lon / lonStep).floor(), latStep, lonStep); + + double get centerLat => (i + 0.5) * latStep; + double get centerLon => (j + 0.5) * lonStep; + + bool contains(double lat, double lon) => + (lat / latStep).floor() == i && (lon / lonStep).floor() == j; + + /// Keep only points whose own cell is this one (parses lat/lon from the raw map). + List> filter(List> points) { + return points.where((p) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + return la != null && lo != null && contains(la, lo); + }).toList(); + } +} + +// --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ + +class _RepInfo { + final String id; // short hex id, lowercased + final String hexId; // full hex, cleaned + lowercased + final double lat; + final double lon; + final int status; // enabled: 1 active, 2 ambiguous/excluded + final bool hidden; + const _RepInfo(this.id, this.hexId, this.lat, this.lon, this.status, this.hidden); +} + +/// Indexes the loaded repeaters the same three ways the web does: by id +/// (`repeaterLocs`), by full hex (`repeaterByFullHex`), and by short-hex bucket +/// (`repeaterByHex`), plus `narrowCandidates`/`prefixLen` for token resolution. +class RepeaterLookup { + final int prefixLen; + final Map _byId = {}; + final Map _byFullHex = {}; + final Map> _byShortHex = {}; + + RepeaterLookup._(this.prefixLen); + + /// [hopBytes] is the region path width (web `hopBytes`); `prefixLen = hopBytes*2`. + factory RepeaterLookup.fromRepeaters(Iterable repeaters, + {required int hopBytes}) { + final prefixLen = math.max(2, hopBytes * 2); + final lk = RepeaterLookup._(prefixLen); + for (final r in repeaters) { + // web adds enabled IN (1,2) to the lookup + if (r.enabled != 1 && r.enabled != 2) continue; + if (r.lat.isNaN || r.lon.isNaN) continue; + // Web parity (index.php:10231 `if (rep.lat && rep.lon && rep.id)`): a 0 + // lat/lon is the "location not published" sentinel — exclude it, or MAX + // DIST / max range would be computed to (0,0) (~8900 km from Ottawa). + if (r.lat == 0 || r.lon == 0 || r.id.isEmpty) continue; + final cleanHex = r.hexId.replaceAll(_hexOnlyRe, '').toLowerCase(); + final id = r.id.toLowerCase(); + // Web parity (index.php:10239): hidden only when the 🚫 marker is at the + // start or end of the name — NOT anywhere in the middle. + final hidden = r.name.startsWith('🚫') || r.name.endsWith('🚫'); + final info = _RepInfo(id, cleanHex, r.lat, r.lon, r.enabled, hidden); + lk._byId[id] = info; + final shortHex = + cleanHex.length >= prefixLen ? cleanHex.substring(0, prefixLen) : cleanHex; + if (shortHex.length >= 2) { + (lk._byShortHex[shortHex] ??= <_RepInfo>[]).add(info); + final firstByte = cleanHex.length >= 2 ? cleanHex.substring(0, 2) : ''; + if (firstByte.length == 2 && firstByte != shortHex) { + (lk._byShortHex[firstByte] ??= <_RepInfo>[]).add(info); + } + } + if (cleanHex.isNotEmpty) lk._byFullHex[cleanHex] = info; + } + return lk; + } + + /// Port of `narrowCandidates` (`dev/index.php:9709`): greedy narrowing on a + /// longer id, backward-compat widening on a shorter id. + List<_RepInfo> _narrowCandidates(List<_RepInfo> candidates, String id) { + final idLower = id.toLowerCase(); + if (candidates.length > 1 && id.length > prefixLen) { + final narrowed = candidates + .where((rep) => + rep.hexId.length >= id.length && + rep.hexId.substring(0, id.length) == idLower) + .toList(); + if (narrowed.isNotEmpty) return narrowed; + } + if (candidates.isEmpty && id.isNotEmpty && id.length < prefixLen) { + final results = <_RepInfo>[]; + _byShortHex.forEach((key, reps) { + if (key.length >= idLower.length && + key.substring(0, idLower.length) == idLower) { + for (final rep in reps) { + if (!results.contains(rep)) results.add(rep); + } + } + }); + if (results.isNotEmpty) return results; + } + return candidates; + } + + /// Resolve a token hex to candidate repeaters, mirroring the web order: + /// full-hex, else narrowed short-hex bucket, else `repeaterLocs[id]`. + List<_RepInfo> _candidatesFor(String rid) { + final full = _byFullHex[rid]; + if (full != null) return <_RepInfo>[full]; + final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; + final cands = _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + if (cands.isEmpty) { + final loc = _byId[rid]; + if (loc != null) return <_RepInfo>[loc]; + } + return cands; + } + + /// Resolve a token hex (any width — old narrow OR new 4-byte-capped) to its single repeater, + /// or null. Mirrors the web `repObjForToken`: full-hex, else a UNIQUE short-hex/narrowed match, + /// else the legacy id key. Tokens normalize WIDER than `repeater.id`, so a bare `_byId[token]` + /// lookup misses — the cell MAX-DIST must resolve by full-hex. + _RepInfo? _repForToken(String rid) { + final full = _byFullHex[rid]; + if (full != null) return full; + final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; + final cands = _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + if (cands.length == 1) return cands.first; + return _byId[rid]; + } +} + +// --- cell GRID SUMMARY ------------------------------------------------------- + +/// Aggregated stats for a clicked map cell. Port of `generateSummaryContent` +/// (`dev/index.php:13345-13521`). +class GridSummary { + final int total; + final int bidir; + final int tx; + final int rx; + final int disc; + final int dead; + final int drop; + final double? avgSnr; // null = N/A + final int? avgNoise; // rounded dBm; null = N/A + final double? maxDistMeters; // null = N/A + + const GridSummary({ + required this.total, + required this.bidir, + required this.tx, + required this.rx, + required this.disc, + required this.dead, + required this.drop, + required this.avgSnr, + required this.avgNoise, + required this.maxDistMeters, + }); + + /// Signal bucket driving the AVG SNR icon/colour: `good` (>5), `medium` (>=-1), + /// `bad` (<-1), or null when AVG SNR is N/A (web thresholds at `:13456`). + String? get snrBucket { + if (avgSnr == null) return null; + final v = double.parse(avgSnr!.toStringAsFixed(1)); + if (v > 5) return 'good'; + if (v >= -1) return 'medium'; + return 'bad'; + } + + factory GridSummary.fromPoints( + List> points, RepeaterLookup lookup) { + int bidir = 0, tx = 0, rx = 0, dead = 0, drop = 0, disc = 0; + double totalSnr = 0; + int countSnr = 0; + double totalNoise = 0; + int countNoise = 0; + double maxDist = 0; + + for (final p in points) { + final s = _toInt(p['status']); + if (s == 1) { + bidir++; + } else if (s == 2) { + tx++; + } else if (s == 5) { + rx++; + } else if (s == 3) { + dead++; + } else if (s == 0) { + drop++; + } else if (s == 6 || s == 7) { + disc++; + } + + // SNR: local_snr, else max SNR parsed from heard_repeats "(x)" tokens. + double? pingSnr = _toDouble(p['local_snr']); + if (pingSnr == null) { + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') { + double maxS = -999; + bool found = false; + for (final part in hr.split(',')) { + final m = _snrParenRe.firstMatch(part); + if (m != null) { + final v = double.tryParse(m.group(1)!); + if (v != null) { + if (v > maxS) maxS = v; + found = true; + } + } + } + if (found) pingSnr = maxS; + } + } + if (pingSnr != null && !pingSnr.isNaN) { + totalSnr += pingSnr; + countSnr++; + } + + // Noise. + final nf = _toDouble(p['noisefloor']); + if (nf != null && !nf.isNaN) { + totalNoise += nf; + countNoise++; + } + + // Max range: token coords matched to a repeater (by id) within 100 m. + final ids = []; + final pLat = _toDouble(p['lat']); + final pLon = _toDouble(p['lon']); + + void collectFrom(String text) { + for (final m in _tokenRe.allMatches(text)) { + if (m.group(2) == '?' || m.group(4) == null) continue; + final c = m.group(4)!.split(','); + if (c.length < 2) continue; + final tLat = double.tryParse(c[0]); + final tLon = double.tryParse(c[1]); + final rID = m.group(1)!.toLowerCase(); + final loc = lookup._repForToken(rID); + if (loc != null && + tLat != null && + tLon != null && + _within100m(loc.lat, loc.lon, tLat, tLon)) { + ids.add(m.group(1)!); + } + } + } + + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') collectFrom(hr); + + final via = p['via']; + if (via is String && + via.isNotEmpty && + via != 'Direct' && + !via.contains('Wardriving')) { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + collectFrom(cleanVia); + } + + // DISC full-id check via public_key. + if (s == 6 && p['public_key'] is String) { + final pkClean = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final targetRep = lookup._byFullHex[pkClean]; + if (targetRep != null) { + final hrStr = hr is String ? hr : ''; + final cm = _coordBracketRe.firstMatch(hrStr); + if (cm != null) { + final tLat = double.tryParse(cm.group(1)!); + final tLon = double.tryParse(cm.group(2)!); + if (tLat != null && + tLon != null && + _within100m(targetRep.lat, targetRep.lon, tLat, tLon)) { + ids.add(targetRep.id); + } + } + } + } + + for (final rID in ids) { + final loc = lookup._repForToken(rID.toLowerCase()); + if (loc == null) continue; + if (loc.status == 2 || loc.hidden) continue; + if (pLat != null && pLon != null) { + final d = _haversineMeters(pLat, pLon, loc.lat, loc.lon); + if (d > maxDist) maxDist = d; + } + } + } + + return GridSummary( + total: points.length, + bidir: bidir, + tx: tx, + rx: rx, + disc: disc, + dead: dead, + drop: drop, + avgSnr: countSnr > 0 ? totalSnr / countSnr : null, + avgNoise: countNoise > 0 ? (totalNoise / countNoise).round() : null, + maxDistMeters: maxDist > 0 ? maxDist : null, + ); + } +} + +// --- per-repeater totals ----------------------------------------------------- + +/// Per-repeater BIDIR/TX/RX/DISC/DEAD counts + max range. Port of the attribution +/// in `renderRepeaterChart` (`dev/index.php:14063-14322`). +class RepeaterStats { + final int bidir; + final int tx; + final int rx; + final int disc; + final int dead; + final double? maxRangeMeters; + + const RepeaterStats({ + required this.bidir, + required this.tx, + required this.rx, + required this.disc, + required this.dead, + required this.maxRangeMeters, + }); + + int get totalMatched => bidir + tx + rx + disc + dead; + + factory RepeaterStats.fromCoverage( + List> points, Repeater target, RepeaterLookup lookup, + {bool disableDupLogic = false}) { + final repId = target.id.toLowerCase(); + final repLat = target.lat; + final repLon = target.lon; + int bidir = 0, tx = 0, rx = 0, disc = 0, dead = 0; + double maxRange = 0; + + bool tokenMatchesTarget(RegExpMatch m) { + if (!disableDupLogic && m.group(2) == '?') return false; + final rID = m.group(1)!.toLowerCase(); + final candidates = lookup._candidatesFor(rID); + if (!candidates.any((c) => c.id == repId)) return false; + final coords = m.group(4); + if (coords != null) { + final c = coords.split(','); + if (c.length < 2) return false; + final cLat = double.tryParse(c[0]); + final cLon = double.tryParse(c[1]); + return cLat != null && + cLon != null && + _within100m(repLat, repLon, cLat, cLon); + } + return disableDupLogic; // no coords: only valid when dup-logic disabled + } + + for (final p in points) { + final pLat = _toDouble(p['lat']); + final pLon = _toDouble(p['lon']); + if (pLat == null || pLon == null) continue; + + final status = _toInt(p['status']); + final String type; + if (status == 1) { + type = 'BIDIR'; + } else if (status == 2) { + type = 'TX'; + } else if (status == 5) { + type = 'RX'; + } else if (status == 6 || status == 7) { + type = 'DISC'; + } else if (status == 3) { + type = 'DEAD'; + } else { + continue; // DROP / unknown -> skip (matches web) + } + + bool matched = false; + + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') { + for (final m in _tokenRe.allMatches(hr)) { + if (tokenMatchesTarget(m)) matched = true; + } + } + + final via = p['via']; + if (via is String && + via.isNotEmpty && + via != 'Direct' && + !via.contains('Wardriving')) { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + for (final m in _tokenRe.allMatches(cleanVia)) { + if (tokenMatchesTarget(m)) matched = true; + } + } + + if (status == 6 && p['public_key'] is String) { + final pk = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final tr = lookup._byFullHex[pk]; + if (tr != null && tr.id == repId) { + final hrStr = hr is String ? hr : ''; + final cm = _coordBracketRe.firstMatch(hrStr); + if (cm != null) { + final cLat = double.tryParse(cm.group(1)!); + final cLon = double.tryParse(cm.group(2)!); + if (cLat != null && + cLon != null && + _within100m(repLat, repLon, cLat, cLon)) { + matched = true; + } + } + } + } + + if (matched) { + switch (type) { + case 'BIDIR': + bidir++; + break; + case 'TX': + tx++; + break; + case 'RX': + rx++; + break; + case 'DISC': + disc++; + break; + case 'DEAD': + dead++; + break; + } + final d = _haversineMeters(pLat, pLon, repLat, repLon); + if (d > maxRange) maxRange = d; + } + } + + return RepeaterStats( + bidir: bidir, + tx: tx, + rx: rx, + disc: disc, + dead: dead, + maxRangeMeters: maxRange > 0 ? maxRange : null, + ); + } +} diff --git a/lib/utils/distance_formatter.dart b/lib/utils/distance_formatter.dart index 63cfc7da..d8fab1ae 100644 --- a/lib/utils/distance_formatter.dart +++ b/lib/utils/distance_formatter.dart @@ -11,6 +11,20 @@ String formatMeters(double meters, {bool isImperial = false}) { return '${meters.toStringAsFixed(0)}m'; } +/// Web-parity distance string for coverage popups (GRID SUMMARY "MAX DIST" and +/// the repeater "Max Range"): "123.26 km" / "150 m" (metric) or "76.55 mi" / +/// "492 ft" (imperial). Mirrors `formatDistance` (dev/index.php:6205) — note the +/// space and 2-decimal km/mi, which differ from [formatMeters]/[formatKilometers]. +String formatCoverageDistance(double meters, {bool isImperial = false}) { + if (isImperial) { + final feet = meters * 3.28084; + if (feet >= 5280) return '${(feet / 5280).toStringAsFixed(2)} mi'; + return '${feet.round()} ft'; + } + if (meters >= 1000) return '${(meters / 1000).toStringAsFixed(2)} km'; + return '${meters.round()} m'; +} + /// Format a distance in kilometers for display /// Returns string like "2.5km" (metric) or "1.6mi" (imperial) String formatKilometers(double kilometers, {bool isImperial = false}) { diff --git a/lib/utils/repeater_format.dart b/lib/utils/repeater_format.dart new file mode 100644 index 00000000..692fe651 --- /dev/null +++ b/lib/utils/repeater_format.dart @@ -0,0 +1,66 @@ +/// Date / clock-skew formatters ported from the web client (`dev/index.php`) so the +/// app's repeater detail sheet matches the web popup text exactly. +/// +/// The web stores timestamps as either Unix seconds or milliseconds; values below +/// 2e10 are treated as seconds (mirrors `formatDateOnly` / `calculateDaysAgo`). +library; + +DateTime? _toDateTime(num? ts) { + if (ts == null) return null; + var v = ts.toDouble(); + if (v <= 0) return null; + if (v < 20000000000) v = v * 1000; // seconds -> ms (web: `if (ts < 20000000000) ts *= 1000`) + return DateTime.fromMillisecondsSinceEpoch(v.toInt()); +} + +/// `MM/DD/YY` (e.g. `06/16/26`). Port of `formatDateOnly` (`dev/index.php:9907`). +String formatDateOnly(num? ts) { + final d = _toDateTime(ts); + if (d == null) return 'N/A'; + final mm = d.month.toString().padLeft(2, '0'); + final dd = d.day.toString().padLeft(2, '0'); + final yy = (d.year % 100).toString().padLeft(2, '0'); + return '$mm/$dd/$yy'; +} + +/// `Today` / `1 day ago` / `N days ago`. Port of `calculateDaysAgo` +/// (`dev/index.php:9931`) — both timestamps compared at local midnight. +String daysAgo(num? ts) { + final d = _toDateTime(ts); + if (d == null) return ''; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final that = DateTime(d.year, d.month, d.day); + final diff = today.difference(that).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return '1 day ago'; + return '$diff days ago'; +} + +/// `MM/DD/YY (Today)` style — the date plus its parenthesised age, matching the +/// web's `formatDateOnly(ts) + ' (' + calculateDaysAgo(ts) + ')'`. +String formatDateWithAgo(num? ts) { + if (ts == null) return 'N/A'; + final ago = daysAgo(ts); + return ago.isEmpty ? formatDateOnly(ts) : '${formatDateOnly(ts)} ($ago)'; +} + +/// Human-readable clock skew, e.g. `49.4 minutes ahead`. Port of the warning text +/// in `generateRepeaterPopup` (`dev/index.php:12757`). +/// +/// Returns `null` when the offset is null or within the ±120 s tolerance (no +/// warning shown). `offset > 0` ⇒ repeater clock is *behind*; `< 0` ⇒ *ahead*. +String? humanizeClockSkew(int? offsetSecs) { + if (offsetSecs == null) return null; + final abs = offsetSecs.abs(); + if (abs <= 120) return null; + final String mag; + if (abs >= 86400) { + mag = '${(abs / 86400).toStringAsFixed(1)} days'; + } else if (abs >= 3600) { + mag = '${(abs / 3600).toStringAsFixed(1)} hours'; + } else { + mag = '${(abs / 60).toStringAsFixed(1)} minutes'; + } + return '$mag ${offsetSecs > 0 ? 'behind' : 'ahead'}'; +} diff --git a/lib/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart new file mode 100644 index 00000000..90648da3 --- /dev/null +++ b/lib/widgets/cell_summary_sheet.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; + +import '../utils/coverage_summary.dart'; +import '../utils/distance_formatter.dart'; + +/// Bottom sheet showing the coverage "GRID SUMMARY" for a tapped map cell — the +/// 3×3 stat grid + the proportional bar graph with a separator line, mirroring +/// the web popup (`generateSummaryContent`, dev/index.php:13345). Deliberately +/// excludes the web's PING HISTORY list. +class CellSummarySheet extends StatelessWidget { + /// Resolves to the aggregated summary, or null on fetch failure. The caller + /// fetches the cell's points and computes [GridSummary] off the UI thread. + final Future summaryFuture; + final bool isImperial; + + const CellSummarySheet({ + super.key, + required this.summaryFuture, + required this.isImperial, + }); + + // Web GRID SUMMARY palette (dev/index.php getPalette 'none'). + static const Color _bidir = Color(0xFF1E7E34); + static const Color _tx = Color(0xFFFD7E14); + static const Color _rx = Color(0xFF6F42C1); + static const Color _disc = Color(0xFF17A2B8); + static const Color _dead = Color(0xFF6C757D); + static const Color _drop = Color(0xFFBD2130); + static const Color _maxDistBlue = Color(0xFF007BFF); + static const Color _snrGood = Color(0xFF1E7E34); + static const Color _snrMedium = Color(0xFF856404); + static const Color _snrBad = Color(0xFFBD2130); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 24 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'GRID SUMMARY', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 0.5), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 8), + FutureBuilder( + future: summaryFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); + } + final s = snap.data; + if (s == null) return _note(context, 'Could not load coverage data.'); + if (s.total == 0) return _note(context, 'No coverage data here.'); + return _buildSummary(context, s); + }, + ), + ], + ), + ); + } + + Widget _buildSummary(BuildContext context, GridSummary s) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Total Pings: ', + style: TextStyle( + fontSize: 12, color: theme.colorScheme.onSurfaceVariant), + ), + TextSpan( + text: '${s.total}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + _cell(context, value: '${s.bidir}', label: 'BIDIR', color: _bidir), + _cell(context, value: '${s.tx}', label: 'TX', color: _tx), + _cell(context, value: '${s.rx}', label: 'RX', color: _rx), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _cell(context, value: '${s.disc}', label: 'DISC', color: _disc), + _cell(context, value: '${s.dead}', label: 'DEAD', color: _dead), + _cell(context, value: '${s.drop}', label: 'DROP', color: _drop), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _snrCell(context, s), + _cell( + context, + value: s.maxDistMeters != null + ? formatCoverageDistance(s.maxDistMeters!, isImperial: isImperial) + : 'N/A', + label: 'MAX DIST', + color: _maxDistBlue, + ), + _cell( + context, + value: s.avgNoise != null ? '${s.avgNoise}' : 'N/A', + valueSuffix: s.avgNoise != null ? ' dBm' : null, + label: 'AVG NOISE', + ), + ], + ), + const SizedBox(height: 16), + _barGraph(context, s), + ], + ); + } + + Widget _cell( + BuildContext context, { + required String value, + required String label, + Color? color, + String? valueSuffix, + Widget? valueWidget, + }) { + final theme = Theme.of(context); + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + valueWidget ?? + Text.rich( + TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color ?? theme.colorScheme.onSurface, + ), + ), + if (valueSuffix != null) + TextSpan( + text: valueSuffix, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.3, + ), + ), + ], + ), + ); + } + + Widget _snrCell(BuildContext context, GridSummary s) { + final theme = Theme.of(context); + final bucket = s.snrBucket; + final Widget valueWidget; + if (bucket == null) { + valueWidget = Text( + 'N/A', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ); + } else { + final IconData icon; + final Color color; + switch (bucket) { + case 'good': + icon = Icons.signal_cellular_4_bar; + color = _snrGood; + break; + case 'medium': + icon = Icons.signal_cellular_alt; + color = _snrMedium; + break; + default: + icon = Icons.signal_cellular_alt_1_bar; + color = _snrBad; + } + valueWidget = SizedBox( + height: 24, + child: Icon(icon, size: 22, color: color), + ); + } + return _cell(context, value: '', label: 'AVG SNR', valueWidget: valueWidget); + } + + Widget _barGraph(BuildContext context, GridSummary s) { + final isDark = Theme.of(context).brightness == Brightness.dark; + // Success segments first (BIDIR, DISC, TX, RX), then a separator "line", + // then fail segments (DEAD, DROP) — flex-weighted by count (web order). + final success = <(Color, int, String)>[ + (_bidir, s.bidir, 'BIDIR'), + (_disc, s.disc, 'DISC/TRACE'), + (_tx, s.tx, 'TX'), + (_rx, s.rx, 'RX'), + ]; + final fail = <(Color, int, String)>[ + (_dead, s.dead, 'DEAD'), + (_drop, s.drop, 'DROP'), + ]; + final hasSuccess = success.any((e) => e.$2 > 0); + final hasFail = fail.any((e) => e.$2 > 0); + if (!hasSuccess && !hasFail) return const SizedBox.shrink(); + + final children = []; + void addSeg((Color, int, String) seg) { + if (seg.$2 <= 0) return; + children.add( + Expanded( + flex: seg.$2, + child: Tooltip( + message: '${seg.$3}: ${seg.$2}', + child: Container(color: seg.$1), + ), + ), + ); + } + + for (final seg in success) { + addSeg(seg); + } + if (hasSuccess && hasFail) { + children.add(Container( + width: 2, + color: isDark ? const Color(0xFFCCCCCC) : const Color(0xFF444444), + )); + } + for (final seg in fail) { + addSeg(seg); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox(height: 10, child: Row(children: children)), + ); + } + + Widget _note(BuildContext context, String msg) => Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Center( + child: Text( + msg, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 5f12afb1..acd2a8da 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -17,11 +17,14 @@ import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; +import '../utils/coverage_summary.dart'; import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; +import '../utils/repeater_format.dart'; +import 'cell_summary_sheet.dart'; import 'repeater_id_chip.dart'; import 'rx_path_chain.dart'; @@ -519,6 +522,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { String? _activeCoverageSourceId; String? _activeCoverageLayerId; int _coverageBufferCounter = 0; + // Guards against opening two GRID SUMMARY sheets when both the feature-tap and + // the onMapClick hit-test fire for the same coverage cell tap. + bool _cellSummaryShowing = false; // Grid size is baked into the tile URL and the CVD palette into the layer // paint; a change to either rebuilds the overlay via the build watcher. int? _lastAppliedGridSize; @@ -1906,7 +1912,81 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (!mounted) return; if (_spiderCenter != null) { _collapseSpider(); + return; // dismissing the spider shouldn't also open a cell summary } + // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer + // taps through onFeatureTapped, so query the coverage layer at the tap point. + _maybeShowCellSummaryAt(point, coordinates); + } + + /// Hit-test the active coverage fill layer at [point]; if a cell is there, + /// open its GRID SUMMARY. The onMapClick fallback to _handleFeatureTap. + Future _maybeShowCellSummaryAt( + math.Point point, LatLng coordinates) async { + final layerId = _activeCoverageLayerId; + if (layerId == null || _mapController == null) return; + try { + final features = + await _mapController!.queryRenderedFeatures(point, [layerId], null); + if (!mounted || features.isEmpty) return; + _showCellSummary(coordinates); + } catch (e) { + debugWarn('[COVERAGE] cell hit-test failed: $e'); + } + } + + /// Open the GRID SUMMARY bottom sheet for the coverage cell at [coordinates]. + /// Lazily fetches the cell's coverage points and aggregates them client-side, + /// mirroring the web cell-click popup (minus PING HISTORY). + void _showCellSummary(LatLng coordinates) { + if (!mounted || _cellSummaryShowing) return; + final appState = context.read(); + final zone = appState.zoneCode; + if (zone == null || zone.isEmpty) return; + + final gridSize = appState.preferences.coverageGridSize; + final radius = gridSize.toDouble(); + // Snap to the clicked CELL (same grid as the server/web) so the summary is + // identical anywhere in the cell and matches the web: fetch the cell centre, + // then filter the returned pings to that cell. + final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; + final cell = GridCell.containing( + coordinates.latitude, coordinates.longitude, steps[0], steps[1]); + final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, + hopBytes: appState.effectiveHopBytes); + final isImperial = appState.preferences.isImperial; + + debugLog('[COVERAGE] cell tap @ ' + '${coordinates.latitude.toStringAsFixed(5)},' + '${coordinates.longitude.toStringAsFixed(5)} ' + 'cell=${cell.i}/${cell.j} r=${radius.toStringAsFixed(0)}m'); + + final Future summaryFuture = appState + .fetchCellCoverage( + lat: cell.centerLat, + lon: cell.centerLon, + radiusMeters: radius, + ) + .then( + (points) => GridSummary.fromPoints(cell.filter(points), lookup)) + .catchError((Object e) { + debugWarn('[COVERAGE] cell summary failed: $e'); + return null; + }); + + _cellSummaryShowing = true; + showModalBottomSheet( + context: context, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => CellSummarySheet( + summaryFuture: summaryFuture, + isImperial: isImperial, + ), + ).whenComplete(() => _cellSummaryShowing = false); } /// Handles taps on custom layer features (repeater cluster bubbles and @@ -1995,6 +2075,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } + // Coverage cell: open the GRID SUMMARY. Coverage is a fill layer below the + // repeaters, so reaching it means no repeater/cluster was hit. Platforms that + // don't dispatch fill-layer taps here fall back to the queryRenderedFeatures + // hit-test in _onMapEmptyTap. + if (layerId == _activeCoverageLayerId) { + _showCellSummary(coordinates); + return; + } + // GPS marker tap: the GPS marker is a non-interactive symbol on the // annotation manager layer (which sits ON TOP of all custom layers in // paint order). Without intervention, taps on the GPS marker hit the @@ -7678,20 +7767,48 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); - // Determine status label and color + // Determine status label and color (web labels, generateRepeaterPopup). String statusLabel; Color statusColor; - if (repeater.isNew) { - statusLabel = 'New'; + if (repeater.enabled == 2) { + statusLabel = 'Ambiguous'; + statusColor = _repeaterDuplicateColor; + } else if (repeater.enabled == 0) { + statusLabel = 'Disabled'; + statusColor = _repeaterDeadColor; + } else if (repeater.isNew) { + statusLabel = 'New Repeater'; statusColor = _repeaterNewColor; } else if (repeater.isActive) { - statusLabel = 'Active'; + statusLabel = 'Repeater Online'; statusColor = _repeaterMarkerColor; } else { - statusLabel = 'Stale'; + statusLabel = 'Stale Repeater'; statusColor = _repeaterDeadColor; } + // Lazily fetch this repeater's coverage points for the BIDIR/TX/RX/DISC/DEAD + // totals + max range, then aggregate client-side (renderRepeaterChart parity). + final appState = context.read(); + final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, + hopBytes: appState.effectiveHopBytes); + final isImperial = appState.preferences.isImperial; + final Future statsFuture = appState + .fetchRepeaterCoveragePoints(prefix: repeater.id) + .then( + (pts) => RepeaterStats.fromCoverage(pts, repeater, lookup)) + .catchError((Object e) { + debugWarn('[COVERAGE] repeater stats failed: $e'); + return null; + }); + + final fingerprintShort = + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final fingerprintFull = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + final clockSkew = humanizeClockSkew(repeater.timeOffset); + showModalBottomSheet( context: context, useSafeArea: true, @@ -7786,55 +7903,181 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), child: Column( children: [ - // Location row - Row( - children: [ - Icon(Icons.location_on, - size: 16, - color: - Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', - style: TextStyle( + // Location + _repRow( + context, + Icons.location_on, + Text( + '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', + style: const TextStyle( + fontSize: 13, fontFamily: 'monospace'), + ), + ), + // Fingerprint (id / hex) + _repRow( + context, + Icons.fingerprint, + Text.rich(TextSpan(children: [ + TextSpan( + text: fingerprintShort, + style: const TextStyle( fontSize: 13, fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + fontWeight: FontWeight.w600), ), - ], - ), - const SizedBox(height: 10), - // Last heard row - Row( - children: [ - Icon(Icons.access_time, - size: 16, + TextSpan( + text: ' ($fingerprintFull)', + style: TextStyle( + fontSize: 12, color: - Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - repeater.lastHeardFormatted, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, - ), + Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ], + ])), + ), + // Hop bytes + _repRow( + context, + Icons.swap_horiz, + Text( + 'Hop Bytes: ${repeater.hopBytes} byte${repeater.hopBytes == 1 ? '' : 's'}', + style: const TextStyle(fontSize: 13), + ), + ), + // Last heard (schedule) + if (repeater.lastHeard > 0) + _repRow( + context, + Icons.schedule, + Text(formatDateWithAgo(repeater.lastHeard), + style: const TextStyle(fontSize: 13)), + ), + // Clock-skew warning + if (clockSkew != null) ...[ + _repRow( + context, + Icons.warning_amber_rounded, + const Text('Repeater time is not set correctly', + style: TextStyle(fontSize: 13)), + color: Colors.red.shade400, + ), + _repRow( + context, + null, + Text(clockSkew, style: const TextStyle(fontSize: 13)), + color: Colors.red.shade400, + ), + ], + // First heard + if (repeater.createdAt != null) + _repRow( + context, + Icons.event, + Text( + 'First Heard: ${formatDateWithAgo(repeater.createdAt)}', + style: const TextStyle(fontSize: 13)), + ), + // Max range (lazy) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = + snap.connectionState != ConnectionState.done; + final stats = snap.data; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : 'N/A'); + return _repRow( + context, + Icons.open_in_full, + Text('Max Range: $range', + style: const TextStyle(fontSize: 13)), + ); + }, ), ], ), ), + const SizedBox(height: 14), + // BIDIR/TX/RX/DISC/DEAD totals (lazy — filled after the fetch) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + String v(int? n) => loading ? '…' : '${n ?? 0}'; + return Row( + children: [ + _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), + const Color(0xFF1E7E34)), + _repeaterStatCell( + context, 'TX', v(stats?.tx), const Color(0xFFFD7E14)), + _repeaterStatCell( + context, 'RX', v(stats?.rx), const Color(0xFF6F42C1)), + _repeaterStatCell(context, 'DISC', v(stats?.disc), + const Color(0xFF17A2B8)), + _repeaterStatCell(context, 'DEAD', v(stats?.dead), + const Color(0xFF6C757D)), + ], + ); + }, + ), ], ), ), ); } + /// One labelled icon row in the repeater detail card. A null [icon] leaves the + /// icon gutter blank (used to indent the clock-skew magnitude under its warning). + Widget _repRow(BuildContext context, IconData? icon, Widget child, + {Color? color}) { + final iconColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + final textColor = color ?? Theme.of(context).colorScheme.onSurface; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + child: icon == null ? null : Icon(icon, size: 16, color: iconColor), + ), + const SizedBox(width: 8), + Expanded( + child: DefaultTextStyle.merge( + style: TextStyle(fontSize: 13, color: textColor), + child: child, + ), + ), + ], + ), + ); + } + + /// One cell of the repeater's BIDIR/TX/RX/DISC/DEAD totals row. + Widget _repeaterStatCell( + BuildContext context, String label, String value, Color color) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: color)), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], + ), + ); + } + /// Build a status chip matching the status bar theme /// Same styling as StatusBar._buildStatChip() Widget _buildStatChip({ diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart new file mode 100644 index 00000000..9cd2f171 --- /dev/null +++ b/test/utils/coverage_summary_test.dart @@ -0,0 +1,254 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/utils/coverage_summary.dart'; + +Repeater _rep(String id, String hexId, double lat, double lon, + {int enabled = 1, String name = 'R'}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: lat, + lon: lon, + lastHeard: 0, + enabled: enabled, + hopBytes: 2, + ); + +void main() { + // Target repeater at (45, -75); id 'ab12' (2 bytes), full hex 'ab12cd34'. + final target = _rep('ab12', 'ab12cd34', 45.0, -75.0); + final lookup = RepeaterLookup.fromRepeaters([target], hopBytes: 2); + + group('GridSummary.fromPoints', () { + final points = >[ + // BIDIR, snr 10, noise -100, 0.02° west of the repeater (~1573 m). + { + 'status': 1, + 'local_snr': 10, + 'noisefloor': -100, + 'lat': 45.0, + 'lon': -75.02, + 'heard_repeats': 'ab12(10)[45.0,-75.0]', + }, + // BIDIR, snr 4, noise -110, at the repeater (0 m). + { + 'status': 1, + 'local_snr': 4, + 'noisefloor': -110, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(4)[45.0,-75.0]', + }, + // TX — no local_snr, snr 6 parsed from the heard_repeats token. + { + 'status': 2, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(6)[45.0,-75.0]', + }, + {'status': 5, 'lat': 45.0, 'lon': -75.0}, // RX + {'status': 3, 'lat': 45.0, 'lon': -75.0}, // DEAD + {'status': 3, 'lat': 45.0, 'lon': -75.0}, // DEAD + {'status': 0, 'lat': 45.0, 'lon': -75.0}, // DROP + // DISC matched by full public_key + baked coord in heard_repeats. + { + 'status': 6, + 'public_key': 'ab12cd34', + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': '[45.0,-75.0]', + }, + ]; + + final s = GridSummary.fromPoints(points, lookup); + + test('per-status counts + total', () { + expect(s.total, 8); + expect(s.bidir, 2); + expect(s.tx, 1); + expect(s.rx, 1); + expect(s.dead, 2); + expect(s.drop, 1); + expect(s.disc, 1); + }); + + test('AVG SNR averages local_snr + parsed token SNR (10,4,6)', () { + expect(s.avgSnr, isNotNull); + expect(s.avgSnr!, closeTo(20 / 3, 0.001)); + expect(s.snrBucket, 'good'); // > 5 + }); + + test('AVG NOISE averages noisefloor (-100,-110)', () { + expect(s.avgNoise, -105); + }); + + test('MAX DIST is the farthest matched ping→repeater (~1573 m)', () { + expect(s.maxDistMeters, isNotNull); + expect(s.maxDistMeters!, closeTo(1573, 60)); + }); + + test('(0,0) no-location repeater is excluded from MAX DIST', () { + // A repeater at the (0,0) "location not published" sentinel, referenced by + // a baked [0,0] token, must NOT produce a ~8900 km distance to (0,0). + final lk = RepeaterLookup.fromRepeaters( + [target, _rep('00ff', '00ff0000', 0.0, 0.0)], + hopBytes: 2, + ); + final pts = >[ + { + 'status': 1, + 'lat': 45.27, + 'lon': -75.78, + 'heard_repeats': '00ff(5)[0,0]', + }, + ]; + final summary = GridSummary.fromPoints(pts, lk); + expect(summary.bidir, 1); + expect(summary.maxDistMeters, isNull); + }); + + test('MAX DIST resolves a WIDE (full-hex) token + ignores the marker', () { + // The new bake stores tokens WIDER than repeater.id (4-byte cap) + a {U/R} marker. + // 'ab12cd34' (full hex, wider than id 'ab12') must still resolve for MAX DIST — the + // old _byId[token] lookup would have missed it (and shown N/A). + final pts = >[ + { + 'status': 1, + 'lat': 45.0, + 'lon': -75.02, // ~1573 m west of the repeater + 'heard_repeats': 'ab12cd34(8)[45.0,-75.0]{U2}', + }, + ]; + final summary = GridSummary.fromPoints(pts, lookup); + expect(summary.bidir, 1); + expect(summary.maxDistMeters, isNotNull); + expect(summary.maxDistMeters!, closeTo(1573, 60)); + }); + + test('empty input yields a zeroed summary', () { + final e = GridSummary.fromPoints(const [], lookup); + expect(e.total, 0); + expect(e.bidir, 0); + expect(e.avgSnr, isNull); + expect(e.avgNoise, isNull); + expect(e.maxDistMeters, isNull); + expect(e.snrBucket, isNull); + }); + }); + + group('RepeaterStats.fromCoverage', () { + final points = >[ + // BIDIR via heard_repeats token (coords ~repeater); ping ~786 m away. + { + 'status': 1, + 'lat': 45.0, + 'lon': -75.01, + 'heard_repeats': 'ab12(7.5)[45.0,-75.0]', + }, + // TX via the `via` path token. + { + 'status': 2, + 'lat': 45.0, + 'lon': -75.0, + 'via': 'ab12(3.1)[45.0,-75.0]', + }, + { + 'status': 5, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(2.0)[45.0,-75.0]', + }, + { + 'status': 3, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(1.0)[45.0,-75.0]', + }, + // DISC by full public_key; ping ~3931 m away (the max). + { + 'status': 6, + 'public_key': 'ab12cd34', + 'lat': 45.0, + 'lon': -75.05, + 'heard_repeats': '[45.0,-75.0]', + }, + // Token without coords -> must NOT match (dup-logic enabled by default). + { + 'status': 1, + 'lat': 45.5, + 'lon': -75.0, + 'heard_repeats': 'ab12(9)', + }, + // References a different repeater -> must NOT match. + { + 'status': 1, + 'lat': 40.0, + 'lon': -70.0, + 'heard_repeats': 'ff99(9)[40.0,-70.0]', + }, + // DROP -> skipped. + {'status': 0, 'lat': 45.0, 'lon': -75.0}, + ]; + + final stats = RepeaterStats.fromCoverage(points, target, lookup); + + test('per-status counts attributed to the target', () { + expect(stats.bidir, 1); + expect(stats.tx, 1); + expect(stats.rx, 1); + expect(stats.disc, 1); + expect(stats.dead, 1); + expect(stats.totalMatched, 5); + }); + + test('max range is the farthest matched ping (~3931 m)', () { + expect(stats.maxRangeMeters, isNotNull); + expect(stats.maxRangeMeters!, closeTo(3931, 80)); + }); + + test('no matches yields zeros + null range', () { + final none = RepeaterStats.fromCoverage( + const [ + {'status': 1, 'lat': 40.0, 'lon': -70.0, 'heard_repeats': 'ff99(9)[40,-70]'}, + ], + target, + lookup, + ); + expect(none.totalMatched, 0); + expect(none.maxRangeMeters, isNull); + }); + }); + + group('GridCell', () { + // 100 m Detailed grid steps (kCoverageGridSteps[100]). + const latStep = 0.0009, lonStep = 0.00128; + + test('taps anywhere in a cell resolve to the same cell; centre is inside', () { + final cell = GridCell.containing(45.26970, -75.77795, latStep, lonStep); + final a = + GridCell.containing(cell.centerLat, cell.centerLon, latStep, lonStep); + final b = GridCell.containing(cell.centerLat + latStep * 0.3, + cell.centerLon - lonStep * 0.3, latStep, lonStep); + expect(a.i, cell.i); + expect(a.j, cell.j); + expect(b.i, cell.i); + expect(b.j, cell.j); + expect(cell.contains(cell.centerLat, cell.centerLon), isTrue); + }); + + test('filter keeps only in-cell points (parses string coords)', () { + final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); + final pts = >[ + {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell (num) + {'lat': '${cell.centerLat}', 'lon': '${cell.centerLon}'}, // in cell (string) + { + 'lat': (cell.i + 3) * latStep + latStep * 0.5, + 'lon': cell.centerLon + }, // a different cell + {'lat': null, 'lon': cell.centerLon}, // unparseable + ]; + expect(cell.filter(pts).length, 2); + }); + }); +} diff --git a/test/utils/repeater_format_test.dart b/test/utils/repeater_format_test.dart new file mode 100644 index 00000000..891271a7 --- /dev/null +++ b/test/utils/repeater_format_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/distance_formatter.dart'; +import 'package:mesh_mapper/utils/repeater_format.dart'; + +void main() { + group('formatDateOnly / daysAgo', () { + test('formats a date as MM/DD/YY (local)', () { + final secs = DateTime(2026, 6, 16).millisecondsSinceEpoch ~/ 1000; + expect(formatDateOnly(secs), '06/16/26'); + }); + + test('accepts millisecond timestamps too', () { + final ms = DateTime(2026, 1, 2).millisecondsSinceEpoch; + expect(formatDateOnly(ms), '01/02/26'); + }); + + test('null / non-positive -> N/A', () { + expect(formatDateOnly(null), 'N/A'); + expect(formatDateOnly(0), 'N/A'); + }); + + test('daysAgo: Today / N days ago', () { + final now = DateTime.now(); + final today = now.millisecondsSinceEpoch ~/ 1000; + final twoDays = + now.subtract(const Duration(days: 2)).millisecondsSinceEpoch ~/ 1000; + expect(daysAgo(today), 'Today'); + expect(daysAgo(twoDays), '2 days ago'); + }); + + test('formatDateWithAgo combines both', () { + final secs = DateTime(2026, 6, 16).millisecondsSinceEpoch ~/ 1000; + expect(formatDateWithAgo(secs), startsWith('06/16/26 (')); + }); + }); + + group('humanizeClockSkew', () { + test('null + within tolerance -> null', () { + expect(humanizeClockSkew(null), isNull); + expect(humanizeClockSkew(60), isNull); + expect(humanizeClockSkew(-120), isNull); + }); + + test('minutes, ahead vs behind', () { + // -2964 s = 49.4 min ahead (matches the spec example). + expect(humanizeClockSkew(-2964), '49.4 minutes ahead'); + expect(humanizeClockSkew(2964), '49.4 minutes behind'); + }); + + test('hours + days', () { + expect(humanizeClockSkew(7200), '2.0 hours behind'); + expect(humanizeClockSkew(-172800), '2.0 days ahead'); + }); + }); + + group('formatCoverageDistance (web parity)', () { + test('metric km + m', () { + expect(formatCoverageDistance(123260), '123.26 km'); + expect(formatCoverageDistance(150), '150 m'); + }); + + test('imperial mi + ft', () { + // 2000 m = 6561.68 ft = 1.2427 mi + expect(formatCoverageDistance(2000, isImperial: true), '1.24 mi'); + expect(formatCoverageDistance(100, isImperial: true), '328 ft'); + }); + }); +} From 402dcbd96fb7fa86c15e8c0bc497551299f06795 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:22:34 -0400 Subject: [PATCH 134/360] Fix path-mode warning: app restores original setting on clean disconnect The multi-byte path warning told users the radio would stay in multi-byte mode until they change it. That's wrong: _restorePathHashMode() restores the original setting on a clean disconnect (unless the user changed it themselves). Reword to say the app restores it on a normal disconnect and warn that an unclean drop needs a manual revert in Settings. --- lib/screens/connection_screen.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 4f8898b6..ee9ccb23 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -2476,7 +2476,9 @@ class _ConnectionScreenState extends State ), const SizedBox(height: 12), const Text( - 'Your radio will remain in multi-byte mode until you change it.', + 'On a normal disconnect, the app restores your radio to its ' + 'original path setting. If the connection drops unexpectedly, ' + 'you may need to switch it back yourself in Settings.', style: TextStyle(fontSize: 13, color: Colors.amber), ), ], From 87e445488acce0a7f5d7932a7fc75f2be112a298 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:27:04 -0400 Subject: [PATCH 135/360] Detailed-mode cell tap: blob-aware summary + clicked-tile 3x3 highlight Tapping a coverage cell in Detailed mode now (a) fetches and keeps the pings whose 3x3 blob colours the tapped cell, matching the web's lazyShowPingsAt (GridCell.filterWithinBlob / blobFetchRadiusMeters, used by _showCellSummary), and (b) draws an outline-only 3x3 highlight block centred on the tapped tile (GridCell.blockRing + a self-healing line-layer overlay, cleared when the summary sheet closes). Simplified mode collapses to the single tapped cell. Adds GridCell unit tests. --- lib/utils/coverage_summary.dart | 51 ++++++++++ lib/widgets/map_widget.dart | 128 ++++++++++++++++++++++++-- test/utils/coverage_summary_test.dart | 86 +++++++++++++++++ 3 files changed, 258 insertions(+), 7 deletions(-) diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index f30d0650..2c7d7257 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -81,6 +81,57 @@ class GridCell { return la != null && lo != null && contains(la, lo); }).toList(); } + + // The coverage tile smears each ping over a (2·blob+1)² block of cells + // (server `$blob`: Detailed 100 m = 1 → 3×3, Simplified 300 m = 0 → 1×1). So a + // green cell can be coloured by a ping up to [blob] cells away. These two + // helpers mirror the web's `lazyShowPingsAt` (dev/index.php) so the app fetches + // and keeps exactly the pings that coloured the tapped cell — otherwise a + // blob-painted neighbour cell falsely reads "no coverage data here". blob=0 + // collapses both back to the own-cell behaviour, unchanged. + + /// Metres from the cell centre to the far corner of the ±[blob] cell block — + /// the fetch radius that reaches every ping whose blob can colour this cell. + /// Floored at [minMeters] (the active gridSize) so blob=0 keeps the old radius. + double blobFetchRadiusMeters(int blob, double minMeters) { + final latM = (blob + 0.5) * latStep * 111000; + final lonM = + (blob + 0.5) * lonStep * 111000 * math.cos(centerLat * math.pi / 180); + final corner = math.sqrt(latM * latM + lonM * lonM).ceilToDouble() + 5; + return math.max(corner, minMeters); + } + + /// Keep the points whose blob covers this cell: own cell ±[blob] in each axis + /// (blob=0 reduces to own-cell-only, i.e. [filter]). + List> filterWithinBlob( + List> points, int blob) { + return points.where((p) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + if (la == null || lo == null) return false; + return ((la / latStep).floor() - i).abs() <= blob && + ((lo / lonStep).floor() - j).abs() <= blob; + }).toList(); + } + + /// The closed outer ring (`[lon, lat]` pairs, GeoJSON order) of the + /// (2·[blob]+1)² block of cells centred on this cell — the 3×3 highlight + /// block in Detailed (blob = 1), the single cell in Simplified (blob = 0). + /// The cell itself is always the middle, so a tap highlights a block centred + /// on the tapped tile (parity with the web's clicked-tile-centred highlight). + List> blockRing(int blob) { + final minLat = (i - blob) * latStep; + final maxLat = (i + blob + 1) * latStep; + final minLon = (j - blob) * lonStep; + final maxLon = (j + blob + 1) * lonStep; + return [ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat], + ]; + } } // --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index acd2a8da..15bf32da 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -537,6 +537,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const String _patchSourceId = 'meshmapper-coverage-patch'; static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; bool _patchLayerReady = false; + + // Tap-to-highlight overlay: an outline-only line layer tracing the clicked + // cell's (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the + // tapped tile. Installed once (empty); populated on a cell tap and cleared to + // empty when the summary sheet closes — no per-tap layer churn. + static const String _cellHighlightSourceId = 'meshmapper-cell-highlight'; + static const String _cellHighlightLayerId = 'meshmapper-cell-highlight-layer'; + bool _cellHighlightReady = false; int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; bool _hasStyleLoadedOnce = @@ -1945,13 +1953,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (zone == null || zone.isEmpty) return; final gridSize = appState.preferences.coverageGridSize; - final radius = gridSize.toDouble(); // Snap to the clicked CELL (same grid as the server/web) so the summary is - // identical anywhere in the cell and matches the web: fetch the cell centre, - // then filter the returned pings to that cell. + // identical anywhere in the cell and matches the web's lazyShowPingsAt. final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; final cell = GridCell.containing( coordinates.latitude, coordinates.longitude, steps[0], steps[1]); + // The Detailed (100 m) coverage tile smears each ping over a 3×3 cell block + // (server $blob=1); Simplified (300 m) doesn't (blob=0). So a green cell can + // be coloured by a ping up to blob cells away — fetch out to the block's far + // corner and keep the pings whose blob covers this cell, else a blob-painted + // neighbour cell falsely reads "no coverage data here" (matches the web's + // ($gsize === 100 ? 1 : 0) blob rule for both presets). + final blob = gridSize == 100 ? 1 : 0; + final radius = cell.blobFetchRadiusMeters(blob, gridSize.toDouble()); final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, hopBytes: appState.effectiveHopBytes); final isImperial = appState.preferences.isImperial; @@ -1959,7 +1973,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugLog('[COVERAGE] cell tap @ ' '${coordinates.latitude.toStringAsFixed(5)},' '${coordinates.longitude.toStringAsFixed(5)} ' - 'cell=${cell.i}/${cell.j} r=${radius.toStringAsFixed(0)}m'); + 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); + + // Highlight the tapped cell's block (3×3 in Detailed, 1 cell in Simplified), + // always centred on the tapped tile. Fire-and-forget; cleared on sheet close. + _setCellHighlight(cell, blob); final Future summaryFuture = appState .fetchCellCoverage( @@ -1967,8 +1985,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { lon: cell.centerLon, radiusMeters: radius, ) - .then( - (points) => GridSummary.fromPoints(cell.filter(points), lookup)) + .then((points) => + GridSummary.fromPoints(cell.filterWithinBlob(points, blob), lookup)) .catchError((Object e) { debugWarn('[COVERAGE] cell summary failed: $e'); return null; @@ -1986,7 +2004,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { summaryFuture: summaryFuture, isImperial: isImperial, ), - ).whenComplete(() => _cellSummaryShowing = false); + ).whenComplete(() { + _cellSummaryShowing = false; + _clearCellHighlight(); + }); } /// Handles taps on custom layer features (repeater cluster bubbles and @@ -2373,6 +2394,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // style. Clear the flag so _updateFocusLines won't try to remove // already-gone layers next time it's called. _focusLinesInstalled = false; + // The tap cell-highlight overlay is likewise gone; _addCoverageOverlay + // re-installs it below. + _cellHighlightReady = false; // Disable symbol decluttering on the annotation manager. By default, // MapLibre symbol layers hide overlapping icons/labels at lower zoom to @@ -2593,6 +2617,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _applyBasePatchFilter(appState, layerId); } + // Tap-to-highlight overlay: installed empty here (renders nothing until a + // cell tap pushes a block into it). Self-heals on first tap if this fails. + _cellHighlightReady = false; + await _ensureCellHighlightLayer(); + _lastAppliedCoverageOpacity = opacity; _lastAppliedGridSize = gridSize; _lastAppliedCvd = prefs.colorVisionType; @@ -2645,6 +2674,91 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Empty FeatureCollection — used to clear a GeoJSON source without removing + /// its layer (an empty source renders nothing, costs nothing). + Map _emptyFeatureCollection() => + {'type': 'FeatureCollection', 'features': []}; + + /// Outline-only FeatureCollection for the tapped cell's (2·blob+1)² block: a + /// single LineString tracing the block border, centred on the tapped tile. + Map _buildCellHighlightGeoJson(GridCell cell, int blob) => + { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'LineString', + 'coordinates': cell.blockRing(blob), + }, + }, + ], + }; + + /// Idempotently (re)create the tap cell-highlight source + line layer. A line + /// layer over a tiny LineString ring — no fill, and nothing renders while the + /// source is empty (the idle state). Self-healing like the patch layer. + Future _ensureCellHighlightLayer() async { + if (_cellHighlightReady) return true; + if (_mapController == null) return false; + try { + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + try { + await _mapController!.removeLayer(_cellHighlightLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_cellHighlightSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); + await _mapController!.addLineLayer( + _cellHighlightSourceId, + _cellHighlightLayerId, + const LineLayerProperties( + lineColor: '#00d2ff', + lineWidth: 2.5, + lineOpacity: 0.95, + lineCap: 'round', + lineJoin: 'round', + ), + belowLayerId: belowLayer, + ); + _cellHighlightReady = true; + return true; + } catch (e) { + debugLog('[COVERAGE] cell-highlight layer create failed: $e'); + return false; + } + } + + /// Draw the highlight block for the tapped [cell] (centred on it). [blob] = 1 + /// in Detailed (3×3), 0 in Simplified (single cell). + Future _setCellHighlight(GridCell cell, int blob) async { + if (_mapController == null) return; + if (!await _ensureCellHighlightLayer()) return; + try { + await _mapController!.setGeoJsonSource( + _cellHighlightSourceId, _buildCellHighlightGeoJson(cell, blob)); + } catch (e) { + debugLog('[COVERAGE] cell-highlight set failed: $e'); + } + } + + /// Clear the tap highlight (summary sheet closed): set the source to empty so + /// the layer renders nothing. The layer stays installed (cheap, no churn). + Future _clearCellHighlight() async { + if (_mapController == null || !_cellHighlightReady) return; + try { + await _mapController! + .setGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] cell-highlight clear failed: $e'); + } + } + /// Returns the layer ID of the symbol annotation manager's first (and only) /// layer, or `null` if the manager isn't initialized yet. Used as a /// `belowLayerId` reference to insert other layers (coverage overlay, focus diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart index 9cd2f171..6b8487f1 100644 --- a/test/utils/coverage_summary_test.dart +++ b/test/utils/coverage_summary_test.dart @@ -250,5 +250,91 @@ void main() { ]; expect(cell.filter(pts).length, 2); }); + + test('Detailed (blob=1) keeps a ping one cell away; own-cell-only drops it', + () { + // The Detailed (100 m) coverage tile paints a 3×3 block per ping, so a + // green cell can be coloured by a ping up to 1 cell away. Mirrors the web's + // lazyShowPingsAt: own-cell-only would falsely show "no coverage data here". + final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); + final pts = >[ + { + 'lat': (cell.i + 1) * latStep + latStep * 0.5, + 'lon': (cell.j - 1) * lonStep + lonStep * 0.5, + }, // diagonal neighbour — own cell is (i+1, j-1) + { + 'lat': (cell.i + 2) * latStep + latStep * 0.5, + 'lon': cell.centerLon, + }, // two cells away — outside the ±1 blob + {'lat': null, 'lon': cell.centerLon}, // unparseable + ]; + // blob=1: the diagonal neighbour is within ±1; the two-away ping is not. + expect(cell.filterWithinBlob(pts, 1).length, 1); + // own-cell-only (the pre-fix behaviour) drops both → empty summary. + expect(cell.filter(pts).length, 0); + }); + + test('Simplified (blob=0) reduces to own-cell-only filtering', () { + const sLat = 0.0027, sLon = 0.00384; // kCoverageGridSteps[300] + final cell = GridCell.containing(45.0, -75.0, sLat, sLon); + final pts = >[ + {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell + { + 'lat': (cell.i + 1) * sLat + sLat * 0.5, + 'lon': cell.centerLon + }, // a neighbour cell + ]; + expect(cell.filterWithinBlob(pts, 0).length, cell.filter(pts).length); + expect(cell.filterWithinBlob(pts, 0).length, 1); + }); + + test('blob fetch radius reaches the ±blob block corner, floored at gridSize', + () { + // Detailed: blob=1, 100 m floor → must exceed 100 m and reach the block's + // far corner (~212 m here) so blob-neighbour pings get fetched. + final detailed = GridCell.containing(45.0, -75.0, latStep, lonStep); + final rDetailed = detailed.blobFetchRadiusMeters(1, 100); + expect(rDetailed, greaterThan(100)); + expect(rDetailed, greaterThanOrEqualTo(212)); + + // Simplified: blob=0, the own-cell corner (~212 m) is below the 300 m floor + // → returns exactly 300, byte-unchanged from the old gridSize radius. + const sLat = 0.0027, sLon = 0.00384; + final simplified = GridCell.containing(45.0, -75.0, sLat, sLon); + expect(simplified.blobFetchRadiusMeters(0, 300), 300); + }); + }); + + group('GridCell.blockRing', () { + const latStep = 0.0009; + const lonStep = 0.00128; + const cell = GridCell(10, 20, latStep, lonStep); + + test('blob=1 makes a 3x3 block centred on the tapped cell', () { + final ring = cell.blockRing(1); + expect(ring.length, 5); + expect(ring.first, ring.last); // closed ring + final sw = ring[0]; // [minLon, minLat] + final ne = ring[2]; // [maxLon, maxLat] + // 3 cells wide/tall: i-1..i+2 and j-1..j+2 + expect(sw[1], closeTo(9 * latStep, 1e-12)); + expect(ne[1], closeTo(12 * latStep, 1e-12)); + expect(sw[0], closeTo(19 * lonStep, 1e-12)); + expect(ne[0], closeTo(22 * lonStep, 1e-12)); + // The block's centre is the tapped cell's centre — the clicked tile is + // always the middle. + expect((sw[1] + ne[1]) / 2, closeTo(cell.centerLat, 1e-12)); + expect((sw[0] + ne[0]) / 2, closeTo(cell.centerLon, 1e-12)); + }); + + test('blob=0 is just the tapped cell', () { + final ring = cell.blockRing(0); + final sw = ring[0]; + final ne = ring[2]; + expect(sw[1], closeTo(10 * latStep, 1e-12)); + expect(ne[1], closeTo(11 * latStep, 1e-12)); + expect(sw[0], closeTo(20 * lonStep, 1e-12)); + expect(ne[0], closeTo(21 * lonStep, 1e-12)); + }); }); } From 14e45c1258a0bbab028bef624db5dac2212228e8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:27:04 -0400 Subject: [PATCH 136/360] TX wire-tag: send tag + plaintext coords in Broadcast-Coordinates mode With a session and 'Broadcast My Coordinates' on, the on-air body is now MM::lat,lon (keyed tag plus plaintext coords) instead of coords-only. The bare wire tag still goes to the API (txWireTag), so /wardrive validation and tx_pings are unchanged, and the 11-bit session-limit counter guard now applies to both privacy and broadcast modes. No session yet still falls back to plaintext MM:lat,lon. --- lib/services/ping_service.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 3a29815c..34c29d33 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,8 +597,9 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // Default (privacy): a keyed wire tag "MM:..." — coords go only via the API. - // Opt-in (Broadcast My Coordinates) OR no session yet: plaintext "MM:lat,lon". + // With a session: a keyed wire tag "MM:" (privacy default), or + // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). + // No session yet: plaintext "MM:lat,lon" (no tag can be computed). final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -606,9 +607,12 @@ class PingService { String pingMessage; int? txPingCounter; String? txWireTag; - if (!broadcastCoords && sessionId != null && sessionId.isNotEmpty) { + final hasSession = sessionId != null && sessionId.isNotEmpty; + if (hasSession) { // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it // is exhausted, end the session cleanly rather than repeating/corrupting tags. + // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes + // a counter exactly like a privacy ping, so the session ends identically. if ((getPingCounter?.call() ?? 0) >= 2047) { debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; @@ -618,8 +622,12 @@ class PingService { txPingCounter = getNextPingCounter?.call() ?? 1; txWireTag = WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); - pingMessage = txWireTag; + // Identical to privacy mode in every way EXCEPT broadcast-coords appends the + // plaintext coords to the on-air body. The bare tag still goes to the API + // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. + pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; } else { + // No session yet → no tag can be computed; plaintext coords only (unchanged). pingMessage = 'MM:$coordsStr'; } From 4a6a90b9471f145417d7f8c9c646c0f71110ceef Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:28:32 -0400 Subject: [PATCH 137/360] Offline sessions: enable mobile download via the native share sheet Mobile offline-session download was a 'coming soon' stub (web already worked). Add AppStateProvider.shareOfflineSession: write the session's pretty JSON to a temp file and open the OS share sheet (Save to Files, Drive, email, ...) via share_plus, mirroring shareDebugLog. settings_screen's download handler now calls it on mobile; the web blob-download path is unchanged. Downloads work before upload and after a failed upload (those sessions stay listed). --- lib/providers/app_state_provider.dart | 24 ++++++++++++++++++++++++ lib/screens/settings_screen.dart | 14 +++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 62ba7150..384e5cb5 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -9,6 +10,7 @@ import 'package:flutter/widgets.dart' import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart' show SharePlus, ShareParams, XFile; import '../models/connection_state.dart'; @@ -6757,6 +6759,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Export an offline session to a file and open the native share sheet (Save + /// to Files, Drive, email, …) — the mobile equivalent of the web JSON + /// download. Mirrors [shareDebugLog]; writes the same pretty JSON the web + /// path produces. Throws on failure so the caller can surface an error. + Future shareOfflineSession(String filename) async { + final data = _offlineSessionService.getSessionData(filename); + if (data == null) { + throw Exception('Session "$filename" not found'); + } + final jsonString = const JsonEncoder.withIndent(' ').convert(data); + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/$filename'); + await file.writeAsString(jsonString); + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: 'MeshMapper Offline Session', + ), + ); + debugLog('[OFFLINE] Shared session: $filename, status: ${result.status}'); + } + /// View a debug log file in-app /// /// Reads the file contents and stores them for display in a dialog. diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7314dcf5..ea943ea1 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2329,8 +2329,8 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession( - BuildContext context, AppStateProvider appState, String filename) { + Future _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { try { final sessionData = appState.offlineSessionService.getSessionData(filename); @@ -2366,13 +2366,9 @@ class _SettingsScreenState extends State { ); } } else { - // Mobile: Not yet implemented - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mobile download coming soon - use web version'), - duration: Duration(seconds: 3), - ), - ); + // Mobile: write the JSON to a temp file and open the native share + // sheet (Save to Files, Drive, email, …) — mirrors the debug-log share. + await appState.shareOfflineSession(filename); } } catch (e) { if (context.mounted) { From a18be02eb351b3728e705b8b4754f47e6b6ce0ea Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:28:32 -0400 Subject: [PATCH 138/360] Apply map style + CVD palette changes immediately (bump mapRevision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setMapStyle and setColorVisionType called plain notifyListeners(), which does not bump mapRevision — so the MapWidget (isolated behind the mapRevision Selector, Critical Rule 9) never rebuilt and the new style/palette never reached the native map. Switch both to _notifyMapNow() so the change actually applies. --- lib/providers/app_state_provider.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 384e5cb5..548b07db 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5195,11 +5195,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } - /// Set map style (dark, light, satellite) and persist + /// Set map style (dark, light, satellite) and persist. + /// The base map style is map-rendered state: `MapWidget._buildMap` reads + /// `preferences.mapStyle` and feeds it to `MapLibreMap.styleString`, but the + /// map is isolated behind the `mapRevision` Selector (see Critical Rule 9) and + /// uses `context.read`. Plain `notifyListeners()` leaves `mapRevision` + /// untouched, so the map never rebuilds and the new style never reaches the + /// native `setStyle` — the log fires but the map stays on the old style. Bump + /// the revision via `_notifyMapNow()` so the rebuild applies the style. void setMapStyle(String style) { _preferences = _preferences.copyWith(mapStyle: style); debugLog('[MAP] Map style set to $style'); - notifyListeners(); + _notifyMapNow(); _savePreferences(); } @@ -5233,7 +5240,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); - notifyListeners(); + // Map-rendered state: the CVD palette recolours both the coverage overlay + // (detected via `overlayPrefChanged` in `MapWidget._buildMap`) and the ping + // markers (`PingColors`). Both only re-apply on a map rebuild, which is + // gated by `mapRevision` (Critical Rule 9) — plain `notifyListeners()` would + // update the preference but leave the map on the old colours. Bump the + // revision via `_notifyMapNow()` so the rebuild applies the new palette. + _notifyMapNow(); _savePreferences(); } From c93540aae42d2c6ac2b5480c3f6d71bd95f5b064 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 05:57:54 -0400 Subject: [PATCH 139/360] perf: incremental coverage-marker sync (stop re-pushing every pin each event) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map re-pushed every accumulated TX/RX/DISC/trace marker to the native layer on every ping event — hundreds of awaited platform round-trips per event late in a session — so marker/ping display lag grew with session length and the coverage tile often repainted before its ping marker showed. Markers now skip the native update when their icon/size is unchanged, bounding per-event work to actually-changed pins. Adds a [MAP] Coverage sync diag line. --- lib/widgets/map_widget.dart | 52 +++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 15bf32da..003b2249 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -615,6 +615,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // cluster-enabled GeoJSON source so MapLibre can group nearby markers into // count bubbles at low zoom. See _setupRepeaterClusterLayers(). final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + // Last-applied visual signature (iconImage|iconSize) per coverage symbol. + // A symbol's geometry is baked into its key, so only icon image/size can + // change after creation (TX red→green on echo, focus enlarge). We skip the + // native updateSymbol round-trip when the signature is unchanged — without + // this, every sync re-pushed ALL accumulated symbols (O(n)/event), which + // made marker/ping display lag grow with session length. See _syncCoverageSymbols. + final Map _coverageSymbolSig = {}; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -2368,6 +2375,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // creates fresh symbols in the new manager) instead of updateSymbol. _gpsSymbol = null; _coverageSymbols.clear(); + _coverageSymbolSig.clear(); _distanceLabelSymbols.clear(); // Distance-label companions: the native side wipes registered images on // style reload, so the "already registered" cache must be cleared too or @@ -3892,6 +3900,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final wantedKeys = {}; final focusActive = _focusedPingLocation != null; + // Diagnostics so the O(n)-per-event regression stays measurable on-device: + // a healthy steady-state sync should be mostly "unchanged". + int added = 0, updated = 0, skipped = 0; Future syncOne({ required String type, @@ -3910,29 +3921,46 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (focusActive && !isFocused) return; wantedKeys.add(key); - final options = SymbolOptions( - geometry: LatLng(lat, lon), - iconImage: iconImageOverride ?? _MapImages.coverage(type, success), - iconSize: isFocused ? 1.2 : 1.0, - ); + final iconImage = iconImageOverride ?? _MapImages.coverage(type, success); + final iconSize = isFocused ? 1.2 : 1.0; + // Everything that can change for an existing symbol (geometry is fixed by + // the key). If unchanged, skip the native round-trip entirely. + final sig = '$iconImage|$iconSize'; final existing = _coverageSymbols[key]; if (existing == null) { try { final symbol = await _mapController!.addSymbol( - options, + SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: iconImage, + iconSize: iconSize, + ), {'kind': type, 'id': idForMetadata}, ); _coverageSymbols[key] = symbol; + _coverageSymbolSig[key] = sig; + added++; } catch (e) { debugError('[MAP] addSymbol($type) failed at $ts: $e'); } - } else { + } else if (_coverageSymbolSig[key] != sig) { try { - await _mapController!.updateSymbol(existing, options); + await _mapController!.updateSymbol( + existing, + SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: iconImage, + iconSize: iconSize, + ), + ); + _coverageSymbolSig[key] = sig; + updated++; } catch (e) { debugError('[MAP] updateSymbol($type) failed at $ts: $e'); } + } else { + skipped++; } } @@ -4017,12 +4045,20 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); for (final key in toRemove) { final sym = _coverageSymbols.remove(key); + _coverageSymbolSig.remove(key); if (sym != null) { try { await _mapController!.removeSymbol(sym); } catch (_) {} } } + + // Only log when work actually happened — a healthy sync over a large + // session should read "+0 added, 0 updated, N unchanged" (no native churn). + if (added > 0 || updated > 0 || toRemove.isNotEmpty) { + debugLog('[MAP] Coverage sync: +$added added, $updated updated, ' + '$skipped unchanged, ${toRemove.length} removed'); + } } /// Returns true if the given GPS marker style should rotate to face the From 117c0ced1b5c01df8d5654591763daea1371a89d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 06:27:04 -0400 Subject: [PATCH 140/360] =?UTF-8?q?-=20Fixed=20offline=20sessions=20failin?= =?UTF-8?q?g=20to=20upload=20=E2=80=94=20the=20app=20now=20waits=20for=20t?= =?UTF-8?q?he=20server=20to=20accept=20the=20upload=20session=20before=20s?= =?UTF-8?q?ending,=20and=20never=20discards=20pings=20it=20couldn't=20uplo?= =?UTF-8?q?ad=20(they're=20kept=20and=20retried=20instead=20of=20being=20?= =?UTF-8?q?=20lost=20as=20a=20"partial=20upload").?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/app_state_provider.dart | 113 +++++++++++++------------- lib/services/api_service.dart | 2 +- 2 files changed, 57 insertions(+), 58 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 548b07db..26f71d06 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4942,68 +4942,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); - // Server needs ~3-5s to propagate session after auth - await Future.delayed(const Duration(seconds: 4)); + // Server can take several seconds to make a freshly-created offline session + // visible to /wardrive (read-after-write propagation). Give it a brief settle, + // then let the FIRST batch wait it out with a generous backoff — once any batch + // lands the session is valid for the rest. + await Future.delayed(const Duration(seconds: 2)); - // 4. Upload pings in batches of 50 with retry on session timing errors + // 4. Upload pings in batches of 50, retrying session/transient errors. const batchSize = 50; var uploadedCount = 0; - var discardedCount = 0; - var sessionFailed = false; final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; - for (var i = 0; i < pings.length; i += batchSize) { - if (sessionFailed) break; + // Backoff (seconds) for session-propagation / transient errors. The first batch + // absorbs the session-propagation delay, so it gets a much longer budget than + // later batches (which only see a session error if it genuinely expired/revoked). + const firstBatchBackoff = [2, 3, 4, 5, 6, 8, 10]; + const laterBatchBackoff = [2, 4]; + for (var i = 0; i < pings.length; i += batchSize) { final batchNum = (i ~/ batchSize) + 1; onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); + final backoff = i == 0 ? firstBatchBackoff : laterBatchBackoff; + var result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); - // Retry logic for session timing errors (server propagation delay) - if (result == UploadResult.sessionError) { - const maxRetries = 3; - for (var retry = 1; retry <= maxRetries; retry++) { - final delay = 2 * retry; - debugLog( - '[OFFLINE] Batch $batchNum session error, retry $retry/$maxRetries after ${delay}s'); - onProgress?.call('Batch $batchNum/$totalBatches (retry $retry)'); - await Future.delayed(Duration(seconds: delay)); - result = await _apiService.uploadBatchWithSessionId( - batch, offlineSessionId); - if (result != UploadResult.sessionError) break; - } - } else if (result == UploadResult.retryable) { - debugLog('[OFFLINE] Batch $batchNum retryable error, retrying after 2s'); - await Future.delayed(const Duration(seconds: 2)); - result = await _apiService.uploadBatchWithSessionId( - batch, offlineSessionId); - } - - // Process final result - switch (result) { - case UploadResult.success: - uploadedCount += batch.length; - debugLog( - '[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); - break; - case UploadResult.nonRetryable: - discardedCount += batch.length; - debugWarn('[OFFLINE] Batch $batchNum discarded (data error)'); - break; - case UploadResult.sessionError: - debugError( - '[OFFLINE] Batch $batchNum session error persists after retries, aborting'); - sessionFailed = true; - break; - case UploadResult.retryable: - debugError( - '[OFFLINE] Batch $batchNum still failing after retry, aborting'); - sessionFailed = true; - break; - } + // Retry only session-propagation / transient errors. nonRetryable + // (data/zone/key) errors are NOT retried — we stop and preserve instead. + for (var retry = 0; + retry < backoff.length && + (result == UploadResult.sessionError || + result == UploadResult.retryable); + retry++) { + final delay = backoff[retry]; + final kind = + result == UploadResult.sessionError ? 'session' : 'transient'; + debugLog( + '[OFFLINE] Batch $batchNum $kind error, retry ${retry + 1}/${backoff.length} after ${delay}s'); + onProgress?.call('Batch $batchNum/$totalBatches (retry ${retry + 1})'); + await Future.delayed(Duration(seconds: delay)); + result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + } + + if (result == UploadResult.success) { + uploadedCount += batch.length; + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + continue; + } + + // Any non-success after retries: STOP and preserve the remaining pings. + // We never discard un-uploaded data — it stays in the file for a later retry. + final stopReason = result == UploadResult.sessionError + ? 'session error' + : result == UploadResult.nonRetryable + ? 'data/zone error' + : 'network error'; + debugWarn( + '[OFFLINE] Batch $batchNum stopped ($stopReason) — preserving remaining pings'); + break; } // Delay after posting before disconnect @@ -5018,25 +5017,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[OFFLINE] Isolated upload session released'); - // 6. Clean up session based on results - final totalProcessed = uploadedCount + discardedCount; - final remainingPings = pings.length - totalProcessed; + // 6. Clean up session based on results — prune ONLY successfully-uploaded + // pings; everything not uploaded is preserved in the file for a later retry. + final remainingPings = pings.length - uploadedCount; if (remainingPings <= 0) { await _offlineSessionService.markAsUploaded(filename); debugLog( - '[OFFLINE] Session complete: $uploadedCount uploaded, $discardedCount discarded from $filename'); + '[OFFLINE] Session complete: $uploadedCount uploaded from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - if (totalProcessed > 0) { + if (uploadedCount > 0) { await _offlineSessionService.removeProcessedPings( - filename, totalProcessed); + filename, uploadedCount); debugLog( - '[OFFLINE] Removed $totalProcessed processed pings, $remainingPings remain in $filename'); + '[OFFLINE] Removed $uploadedCount uploaded pings, $remainingPings preserved in $filename'); } debugWarn( - '[OFFLINE] Partial upload: $uploadedCount uploaded, $discardedCount discarded, $remainingPings remaining from $filename'); + '[OFFLINE] Partial upload: $uploadedCount uploaded, $remainingPings preserved in $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 58c1c785..22985506 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1285,7 +1285,7 @@ class ApiService { }; if (nonRetryableErrors.contains(reason)) { debugWarn( - '[API] Offline upload batch non-retryable error: $reason - discarding batch'); + '[API] Offline upload batch non-retryable error: $reason - stopping upload, pings preserved'); return UploadResult.nonRetryable; } From 1357328532a8764094a5aa9418b1e702cfcebee3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 15:48:16 -0400 Subject: [PATCH 141/360] =?UTF-8?q?Offline=20sessions:=20capture=20device?= =?UTF-8?q?=20model,=20power=20level,=20and=20app=20version=20at=20record?= =?UTF-8?q?=20time=20for=20parity=20with=20live=20uploads=20=E2=80=94=20mo?= =?UTF-8?q?del=20was=20previously=20sent=20as=20the=20generic=20'Offline?= =?UTF-8?q?=20Upload'=20and=20power/version=20were=20derived=20at=20upload?= =?UTF-8?q?=20time=20instead=20of=20when=20the=20pings=20were=20recorded.?= =?UTF-8?q?=20Snapshotted=20fields=20are=20sent=20on=20upload=20auth=20wit?= =?UTF-8?q?h=20fallbacks=20for=20legacy=20sessions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/app_state_provider.dart | 31 ++++++++++++---- lib/services/offline_session_service.dart | 45 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 26f71d06..4e137eaf 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4701,6 +4701,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, ); _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); @@ -4729,6 +4734,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, ); } @@ -4868,16 +4878,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. + // Use the device/radio metadata snapshotted when the session was recorded so the upload + // matches a live session (feature parity); fall back to current values for legacy + // sessions saved before these fields were captured. + final uploadModel = session.deviceModel ?? 'Offline Upload'; + final uploadPower = session.powerLevel ?? _preferences.powerLevel; + final uploadVersion = session.appVersion ?? _appVersion; debugLog( - '[OFFLINE] Authenticating for offline upload with device: $deviceName'); + '[OFFLINE] Authenticating for offline upload with device: $deviceName ' + '(model: $uploadModel, power: ${uploadPower}w, ver: $uploadVersion)'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, + appVersion: uploadVersion, + power: uploadPower, iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', + model: uploadModel, radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, @@ -4905,10 +4922,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { reason: 'register', contactUri: session.contactUri, who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, + appVersion: uploadVersion, + power: uploadPower, iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', + model: uploadModel, radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index b7ba554b..ecd0acaf 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -14,6 +14,9 @@ class OfflineSession { final String? deviceName; // Device name for display final String? contactUri; // Signed contact URI for registration during upload final String? radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") + final String? deviceModel; // Device model/manufacturer captured at record time + final double? powerLevel; // TX power level (watts) captured at record time + final String? appVersion; // App version captured at record time final bool uploaded; // Track upload status OfflineSession({ @@ -25,6 +28,9 @@ class OfflineSession { this.deviceName, this.contactUri, this.radioConfig, + this.deviceModel, + this.powerLevel, + this.appVersion, this.uploaded = false, }); @@ -39,6 +45,9 @@ class OfflineSession { deviceName: json['deviceName'] as String?, contactUri: json['contactUri'] as String?, radioConfig: json['radioConfig'] as String?, + deviceModel: json['deviceModel'] as String?, + powerLevel: (json['powerLevel'] as num?)?.toDouble(), + appVersion: json['appVersion'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -54,6 +63,9 @@ class OfflineSession { 'deviceName': deviceName, 'contactUri': contactUri, 'radioConfig': radioConfig, + 'deviceModel': deviceModel, + 'powerLevel': powerLevel, + 'appVersion': appVersion, 'uploaded': uploaded, }; } @@ -73,6 +85,9 @@ class OfflineSession { deviceName: deviceName, contactUri: contactUri, radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, uploaded: uploaded ?? this.uploaded, ); } @@ -167,6 +182,9 @@ class OfflineSessionService { String? deviceName, String? contactUri, String? radioConfig, + String? deviceModel, + double? powerLevel, + String? appVersion, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -185,6 +203,9 @@ class OfflineSessionService { if (devicePublicKey != null) 'device_public_key': devicePublicKey, if (deviceName != null) 'device_name': deviceName, if (radioConfig != null) 'radio_config': radioConfig, + if (deviceModel != null) 'device_model': deviceModel, + if (powerLevel != null) 'power_level': powerLevel, + if (appVersion != null) 'app_version': appVersion, }; final session = OfflineSession( @@ -196,6 +217,9 @@ class OfflineSessionService { deviceName: deviceName, contactUri: contactUri, radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -214,6 +238,9 @@ class OfflineSessionService { String? deviceName, String? contactUri, String? radioConfig, + String? deviceModel, + double? powerLevel, + String? appVersion, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to auto-save, skipping'); @@ -233,6 +260,18 @@ class OfflineSessionService { if (effectiveRadioConfig != null) { updatedData['radio_config'] = effectiveRadioConfig; } + final effectiveDeviceModel = deviceModel ?? existing.deviceModel; + if (effectiveDeviceModel != null) { + updatedData['device_model'] = effectiveDeviceModel; + } + final effectivePowerLevel = powerLevel ?? existing.powerLevel; + if (effectivePowerLevel != null) { + updatedData['power_level'] = effectivePowerLevel; + } + final effectiveAppVersion = appVersion ?? existing.appVersion; + if (effectiveAppVersion != null) { + updatedData['app_version'] = effectiveAppVersion; + } _sessions[index] = OfflineSession( filename: existing.filename, @@ -243,6 +282,9 @@ class OfflineSessionService { deviceName: deviceName ?? existing.deviceName, contactUri: contactUri ?? existing.contactUri, radioConfig: effectiveRadioConfig, + deviceModel: effectiveDeviceModel, + powerLevel: effectivePowerLevel, + appVersion: effectiveAppVersion, ); await _saveSessions(); debugLog( @@ -262,6 +304,9 @@ class OfflineSessionService { deviceName: deviceName, contactUri: contactUri, radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { From 77107e904e9cefaf384c45216e34a494dc4ce7d4 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 15:55:54 -0400 Subject: [PATCH 142/360] Fix app crash on launch / resume-from-background: reject invalid map coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 10 TestFlight crash reports share one root cause: an invalid GPS or stored coordinate (NaN / infinite / out-of-range) reached MapLibre's camera, whose native LatLng constructor throws std::domain_error — uncaught across the C++/Obj-C boundary, so it aborts the app (SIGABRT). iOS can briefly report an invalid CLLocation right after the app resumes from background (the "brought it back up and it crashed" reports), and a corrupted/stale lastKnownPosition from Hive explains the "instacrash on launch" reports. The prior _cameraAnimationReady one-frame delay only addressed GL-surface timing, not the invalid input. Two layers of defense: - Layer A (crash-stopper): validate every coordinate before it reaches the map camera. New shared isValidLatLng() guards the animate helpers, the three center builders feeding initialCameraPosition + style-load zoom, the focus/history fit-bounds (invalid points filtered), and the cluster zoom-ins in map_widget, plus the offline_maps_screen center. - Layer B (stop the bad data at the source): drop invalid fixes at the GPS stream, simulator, getCurrentPosition and getFreshPosition; ignore invalid stored last-known-position on load and never persist invalid coords. This also keeps invalid coordinates out of the API upload payloads. Adds lib/utils/geo_validation.dart + unit test. flutter analyze clean; full suite green (72 tests). --- lib/providers/app_state_provider.dart | 12 +++- lib/screens/offline_maps_screen.dart | 23 +++---- lib/services/gps_service.dart | 34 ++++++++-- lib/utils/geo_validation.dart | 14 ++++ lib/widgets/map_widget.dart | 93 ++++++++++++++++----------- test/utils/geo_validation_test.dart | 40 ++++++++++++ 6 files changed, 163 insertions(+), 53 deletions(-) create mode 100644 lib/utils/geo_validation.dart create mode 100644 test/utils/geo_validation_test.dart diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4e137eaf..28cc3bf4 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -47,6 +47,7 @@ import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../services/custom_api_service.dart'; import '../utils/constants.dart'; +import '../utils/geo_validation.dart'; import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -7304,10 +7305,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final lat = box.get('last_position_lat') as double?; final lon = box.get('last_position_lon') as double?; - if (lat != null && lon != null) { + if (lat != null && lon != null && isValidLatLng(lat, lon)) { _lastKnownPosition = (lat: lat, lon: lon); debugLog('[GPS] Loaded last position: $lat, $lon'); notifyListeners(); // Trigger UI rebuild so map can center on last position + } else if (lat != null && lon != null) { + debugWarn('[GPS] Ignoring invalid stored last position: $lat, $lon'); } } catch (e) { debugLog('[GPS] Failed to load last position: $e'); @@ -7316,6 +7319,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Save last known GPS position to Hive storage (throttled to every 30 seconds) Future _saveLastPosition(double lat, double lon) async { + // Never persist invalid coords — a corrupted last-known position would be + // loaded as the initial map center on next launch and abort the app. + if (!isValidLatLng(lat, lon)) { + debugWarn('[GPS] Skipping save of invalid last position: $lat, $lon'); + return; + } + // Throttle saves to every 30 seconds to avoid excessive Hive operations final now = DateTime.now(); if (_lastPositionSaveTime != null && diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 4173d44d..5549f918 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -10,6 +10,7 @@ import '../providers/app_state_provider.dart'; import '../services/offline_map_service.dart'; import '../services/tile_cache_service.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import '../widgets/app_toast.dart'; import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; @@ -1010,18 +1011,18 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + // Determine map center - prefer current GPS, fallback to last known, then + // Ottawa. Only adopt a source position when its coords are valid — this + // feeds initialCameraPosition, and an invalid LatLng aborts the app in + // MapLibre's native ctor. LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final pos = appState.currentPosition; + final lastKnown = appState.lastKnownPosition; + if (pos != null && isValidLatLng(pos.latitude, pos.longitude)) { + center = LatLng(pos.latitude, pos.longitude); + } else if (lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon)) { + center = LatLng(lastKnown.lat, lastKnown.lon); } return Scaffold( diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 43f951a1..4167116f 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../models/connection_state.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import 'gps_simulator_service.dart'; /// GPS service for location tracking @@ -246,6 +247,14 @@ class GpsService { debugLog( '[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' 'lon=${position.longitude.toStringAsFixed(5)}, accuracy=${position.accuracy.toStringAsFixed(1)}m'); + // Drop invalid fixes (NaN/infinite/out-of-range). iOS can briefly + // report an invalid CLLocation after resume; passing it downstream + // aborts the app in MapLibre and poisons the upload payload. + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Dropping invalid position: lat=${position.latitude}, lon=${position.longitude}'); + return; + } _lastPosition = position; _positionController.add(position); @@ -390,6 +399,11 @@ class GpsService { debugLog( '[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Fresh position has invalid coords, using cached: lat=${position.latitude}, lon=${position.longitude}'); + return _lastPosition; + } _lastPosition = position; return position; } catch (e) { @@ -411,10 +425,16 @@ class GpsService { } try { - return await Geolocator.getCurrentPosition( + final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] getCurrentPosition returned invalid coords: lat=${position.latitude}, lon=${position.longitude}'); + return null; + } + return position; } catch (e) { debugLog('[GPS] getCurrentPosition failed: $e'); return null; @@ -456,6 +476,11 @@ class GpsService { // Subscribe to simulator positions _simulatorSubscription = simulator.positionStream.listen((position) { + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Dropping invalid simulator position: lat=${position.latitude}, lon=${position.longitude}'); + return; + } _lastPosition = position; _positionController.add(position); @@ -467,9 +492,10 @@ class GpsService { _simulatorEnabled = true; // Set initial position immediately from simulator - if (simulator.currentPosition != null) { - _lastPosition = simulator.currentPosition; - _positionController.add(simulator.currentPosition!); + final seed = simulator.currentPosition; + if (seed != null && isValidLatLng(seed.latitude, seed.longitude)) { + _lastPosition = seed; + _positionController.add(seed); } _updateStatus(GpsStatus.locked); // Simulator always has "lock" diff --git a/lib/utils/geo_validation.dart b/lib/utils/geo_validation.dart new file mode 100644 index 00000000..274f6be6 --- /dev/null +++ b/lib/utils/geo_validation.dart @@ -0,0 +1,14 @@ +// Coordinate-validity helpers shared across the GPS, state, and map layers. + +/// Whether [lat]/[lon] are safe to hand to MapLibre's camera. +/// +/// MapLibre's native `LatLng` constructor throws `std::domain_error` on NaN, +/// infinite, or out-of-range (|lat| > 90) coordinates. That C++ exception is +/// uncaught across the C++→Obj-C boundary, so it calls `std::terminate` and +/// aborts the whole app (`SIGABRT`). iOS can briefly report an invalid +/// `CLLocation` (e.g. right after the app resumes from background, or with a +/// negative accuracy), which `geolocator` passes straight through — so every +/// coordinate that can reach the map camera or be persisted/uploaded must be +/// validated against the WGS84 domain first. +bool isValidLatLng(double lat, double lon) => + lat.isFinite && lon.isFinite && lat.abs() <= 90 && lon.abs() <= 180; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 003b2249..fb06c5e0 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -20,6 +20,7 @@ import '../services/gps_service.dart'; import '../utils/coverage_summary.dart'; import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; @@ -894,6 +895,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { !_canAnimateCamera) { return; } + // Guard against NaN/infinite/out-of-range coords — MapLibre's native LatLng + // ctor throws (uncaught → SIGABRT) on invalid input. See isValidLatLng. + if (!isValidLatLng(target.latitude, target.longitude)) { + debugWarn('[MAP] Skipping camera move to invalid target ' + '(${target.latitude}, ${target.longitude})'); + return; + } _mapController!.animateCamera( CameraUpdate.newLatLngZoom(target, targetZoom), duration: const Duration(milliseconds: 500), @@ -921,6 +929,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { !_canAnimateCamera) { return; } + // Guard against NaN/infinite/out-of-range coords — MapLibre's native LatLng + // ctor throws (uncaught → SIGABRT) on invalid input. See isValidLatLng. + if (!isValidLatLng(target.latitude, target.longitude)) { + debugWarn('[MAP] Skipping auto-follow camera move to invalid target ' + '(${target.latitude}, ${target.longitude})'); + return; + } _mapController!.animateCamera( CameraUpdate.newCameraPosition(CameraPosition( target: target, @@ -944,7 +959,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final points = [ pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) - ]; + ].where((p) => isValidLatLng(p.latitude, p.longitude)).toList(); if (points.length < 2) return; // Build bounding box from all points @@ -1139,31 +1154,28 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// before; only its trigger moved out of `build()`. Also called from `build()` /// as an idempotent safety net (all heavy work is version-gated). void _handleGpsPosition(AppStateProvider appState) { - // Map center — prefer current GPS, fallback to last known. + // Map center — prefer current GPS, fallback to last known. Only adopt a + // source position when its coords are valid; otherwise stay on the safe + // default center (invalid coords abort the app in MapLibre's LatLng ctor). LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final pos = appState.currentPosition; + final lastKnown = appState.lastKnownPosition; + if (pos != null && isValidLatLng(pos.latitude, pos.longitude)) { + center = LatLng(pos.latitude, pos.longitude); + } else if (lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon)) { + center = LatLng(lastKnown.lat, lastKnown.lon); } // One-time zoom to last known position when GPS is not yet available. if (appState.currentPosition == null && - appState.lastKnownPosition != null && + lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon) && !_hasZoomedToLastKnown && _isMapReady && _canAnimateCamera) { _hasZoomedToLastKnown = true; - final lastKnownCenter = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final lastKnownCenter = LatLng(lastKnown.lat, lastKnown.lon); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _animateToPositionWithZoom(lastKnownCenter, 15.0 - _zoomEpsilon); @@ -1311,18 +1323,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _rotationLocked = appState.preferences.mapRotationLocked; } - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + // Determine map center - prefer current GPS, fallback to last known, then + // Ottawa. Only adopt a source position when its coords are valid — this + // feeds initialCameraPosition, and an invalid LatLng aborts the app in + // MapLibre's native ctor (the launch/resume crash). LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final centerPos = appState.currentPosition; + final centerLastKnown = appState.lastKnownPosition; + if (centerPos != null && + isValidLatLng(centerPos.latitude, centerPos.longitude)) { + center = LatLng(centerPos.latitude, centerPos.longitude); + } else if (centerLastKnown != null && + isValidLatLng(centerLastKnown.lat, centerLastKnown.lon)) { + center = LatLng(centerLastKnown.lat, centerLastKnown.lon); } // Camera-follow / derived heading / one-time zooms / GPS-marker puck now @@ -2146,7 +2159,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // always null at non-max zoom (the camera-change collapse fires when the // user zooms out), so no collapse-handling is needed here. if (!_isAtMaxZoom()) { - if (_canAnimateCamera) { + if (_canAnimateCamera && + isValidLatLng(coordinates.latitude, coordinates.longitude)) { final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; final newZoom = math.min(currentZoom + 2, _maxUserZoom); @@ -2252,7 +2266,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // so read `point_count` directly instead of re-querying. if (properties['cluster'] == true) { if (!_isAtMaxZoom()) { - if (_canAnimateCamera) { + if (_canAnimateCamera && + isValidLatLng(coordinates.latitude, coordinates.longitude)) { final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; final newZoom = math.min(currentZoom + 2, _maxUserZoom); @@ -2475,12 +2490,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (!_hasStyleLoadedOnce) { _hasStyleLoadedOnce = true; - // Center on GPS if available (initial centering) - if (appState.currentPosition != null && _canAnimateCamera) { - final center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); + // Center on GPS if available (initial centering). Skip on invalid + // coords — an out-of-range/NaN LatLng aborts the app in MapLibre. + final stylePos = appState.currentPosition; + if (stylePos != null && + isValidLatLng(stylePos.latitude, stylePos.longitude) && + _canAnimateCamera) { + final center = LatLng(stylePos.latitude, stylePos.longitude); _mapController!.animateCamera( CameraUpdate.newLatLngZoom(center, _defaultZoom), ); @@ -4693,7 +4709,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Fit camera to show all history session markers void _fitCameraToHistoryMarkers(List markers) { final pts = markers - .where((m) => m.latitude != null && m.longitude != null) + .where((m) => + m.latitude != null && + m.longitude != null && + isValidLatLng(m.latitude!, m.longitude!)) .toList(); if (pts.isEmpty) return; diff --git a/test/utils/geo_validation_test.dart b/test/utils/geo_validation_test.dart new file mode 100644 index 00000000..b2aac142 --- /dev/null +++ b/test/utils/geo_validation_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/geo_validation.dart'; + +void main() { + group('isValidLatLng', () { + test('accepts normal coordinates', () { + expect(isValidLatLng(45.4215, -75.6972), isTrue); // Ottawa + expect(isValidLatLng(0, 0), isTrue); + expect(isValidLatLng(-33.8688, 151.2093), isTrue); // Sydney + }); + + test('accepts exact domain edges', () { + expect(isValidLatLng(90, 180), isTrue); + expect(isValidLatLng(-90, -180), isTrue); + }); + + test('rejects NaN', () { + expect(isValidLatLng(double.nan, 0), isFalse); + expect(isValidLatLng(0, double.nan), isFalse); + expect(isValidLatLng(double.nan, double.nan), isFalse); + }); + + test('rejects infinity', () { + expect(isValidLatLng(double.infinity, 0), isFalse); + expect(isValidLatLng(0, double.negativeInfinity), isFalse); + }); + + test('rejects out-of-range latitude', () { + expect(isValidLatLng(91, 0), isFalse); + expect(isValidLatLng(-91, 0), isFalse); + expect(isValidLatLng(200, 0), isFalse); + }); + + test('rejects out-of-range longitude', () { + expect(isValidLatLng(0, 181), isFalse); + expect(isValidLatLng(0, -181), isFalse); + expect(isValidLatLng(0, 200), isFalse); + }); + }); +} From 146a2e5971fbe8ff499b293a55c52820753c7733 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 16:35:16 -0400 Subject: [PATCH 143/360] Detailed-mode cell tap: filled dominant-colour footprint + dimmed backdrop (web parity) Replaces the cyan outline box around a tapped cell's 3x3 block with the web front end's spot-click look (highlightSpotCoverage): the block fills as a grid of cells in one dominant colour and the coverage backdrop dims to 0.15 so the footprint pops, restored when the summary sheet closes (honouring ping-focus). The dominant colour is the highest-priority status (green > cyan > orange > purple > grey > red) among only the pings whose blob covers the tapped cell, so a red-dominant block repaints green-looking neighbours that were coloured by a ping outside the blob (the intentional smear). Applies in both grid modes (3x3 Detailed, single cell Simplified). Highlight + dim appear with the fetched pings, driven off the same fetch the summary sheet already uses (no extra network call). --- lib/utils/coverage_summary.dart | 59 +++++++++ lib/utils/coverage_tile_palette.dart | 6 + lib/widgets/map_widget.dart | 135 +++++++++++++++------ test/utils/coverage_summary_test.dart | 80 ++++++++++++ test/utils/coverage_tile_palette_test.dart | 32 +++++ 5 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 test/utils/coverage_tile_palette_test.dart diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index 2c7d7257..ef701a2b 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -132,6 +132,65 @@ class GridCell { [minLon, minLat], ]; } + + /// One closed ring (`[lon, lat]` pairs) per individual cell in the + /// (2·[blob]+1)² block centred on this cell — nine cells in Detailed + /// (blob = 1), one in Simplified (blob = 0). Used to fill the tapped footprint + /// as a grid of bordered cells, matching the web's nine `L.rectangle`s. + List>> blockCellPolygons(int blob) { + final rings = >>[]; + for (var di = -blob; di <= blob; di++) { + for (var dj = -blob; dj <= blob; dj++) { + final minLat = (i + di) * latStep; + final maxLat = (i + di + 1) * latStep; + final minLon = (j + dj) * lonStep; + final maxLon = (j + dj + 1) * lonStep; + rings.add([ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat], + ]); + } + } + return rings; + } +} + +/// The single dominant coverage `st` category (1..6) across [points], or null +/// when empty. Port of the web's `highlightSpotCoverage` colour pick: each ping +/// maps to a status bucket, and the highest-priority one present wins +/// (green < cyan < orange < purple < grey < red, i.e. the lowest st). Because +/// the caller passes only the pings whose blob covers the tapped cell, this is +/// the "intentional smear" — a green-looking neighbour repaints to the local +/// dominant when its green came from a ping outside the blob. +int? dominantCoverageStatus(List> points) { + int? best; + for (final p in points) { + final st = _pointStatusCategory(p); + if (best == null || st < best) best = st; + } + return best; +} + +/// A single ping's coverage `st` category (1=green, 2=cyan, 3=orange, +/// 4=purple, 5=grey, 6=red). Mirrors `highlightSpotCoverage` / +/// `coverageStatusColor`: status 1 (BIDIR) is green either way, so the +/// repeats check only documents the web's branch. +int _pointStatusCategory(Map p) { + final status = _toInt(p['status']); + final hr = p['heard_repeats']; + final dh = p['direct_heard']; + final hasRepeats = (hr is String && hr.isNotEmpty && hr != 'None') || + (dh is String && dh.isNotEmpty && dh != 'None'); + if (status == 1 && hasRepeats) return 1; // green + if (status == 2) return 3; // orange (TX) + if (status == 5) return 4; // purple (RX) + if (status == 6 || status == 7) return 2; // cyan (DISC/TRACE) + if (status == 1) return 1; // green (BIDIR) + if (status == 3) return 5; // grey (dead) + return 6; // red (drop / unknown) } // --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ diff --git a/lib/utils/coverage_tile_palette.dart b/lib/utils/coverage_tile_palette.dart index 7d90909e..16555fcc 100644 --- a/lib/utils/coverage_tile_palette.dart +++ b/lib/utils/coverage_tile_palette.dart @@ -62,6 +62,12 @@ class CoverageTilePalette { static List> _paletteFor(String cvdMode) => _palettes[cvdMode] ?? _palettes['none']!; + /// `[fill, border]` hex for coverage status [st] (1..6, clamped) under + /// [cvdMode] — the literal colours used to paint a tapped cell's footprint + /// in one uniform colour (the web's `highlightSpotCoverage` block fill). + static List colorsForStatus(String cvdMode, int st) => + _paletteFor(cvdMode)[st.clamp(1, 6) - 1]; + /// Builds `['match', ['get','st'], 1, c1, ..., 5, c5, c6]` — st 6 doubles /// as the match default so unknown future codes render as red, the same /// fallthrough the server-side mapping uses. diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index fb06c5e0..08b1ea28 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -539,13 +539,21 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; bool _patchLayerReady = false; - // Tap-to-highlight overlay: an outline-only line layer tracing the clicked - // cell's (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the - // tapped tile. Installed once (empty); populated on a cell tap and cleared to - // empty when the summary sheet closes — no per-tap layer churn. + // Tap-to-highlight overlay: a fill layer painting the clicked cell's + // (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the tapped + // tile, in one uniform dominant colour while the coverage backdrop dims + // (web highlightSpotCoverage parity). Installed once (empty); populated on a + // cell tap and cleared to empty when the summary sheet closes — no per-tap + // layer churn. static const String _cellHighlightSourceId = 'meshmapper-cell-highlight'; static const String _cellHighlightLayerId = 'meshmapper-cell-highlight-layer'; bool _cellHighlightReady = false; + // While a tapped cell's footprint is shown, the coverage backdrop is dimmed so + // the bright footprint pops (parity with the web's spot-click). True between + // _showCellFootprint and _clearCellHighlight; gates the opacity restore and + // suppresses the build-method opacity-sync (which would otherwise un-dim). + bool _coverageDimmedForCell = false; + static const double _kCellHighlightFadeOpacity = 0.15; int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; bool _hasStyleLoadedOnce = @@ -1809,11 +1817,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Settings → General) and push it to the live raster layer without // rebuilding the whole overlay. Skipped while ping focus mode is active — // focus forces opacity to 0 and _dismissPingFocus restores the preference - // value directly. + // value directly — and while a tapped cell dims the backdrop (_clearCellHighlight + // restores it on sheet close), or this would un-dim it mid-sheet. final wantedOpacity = appState.preferences.coverageOverlayOpacity; if (_isMapReady && _styleLoaded && _focusedPingLocation == null && + !_coverageDimmedForCell && _lastAppliedCoverageOpacity != null && _lastAppliedCoverageOpacity != wantedOpacity) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1995,24 +2005,36 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${coordinates.longitude.toStringAsFixed(5)} ' 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); - // Highlight the tapped cell's block (3×3 in Detailed, 1 cell in Simplified), - // always centred on the tapped tile. Fire-and-forget; cleared on sheet close. - _setCellHighlight(cell, blob); + _cellSummaryShowing = true; - final Future summaryFuture = appState + // Fetch the pings once, keep the ones whose blob covers the tapped cell, and + // drive BOTH the summary sheet and the footprint highlight off that list. + final Future>> blobPointsFuture = appState .fetchCellCoverage( lat: cell.centerLat, lon: cell.centerLon, radiusMeters: radius, ) - .then((points) => - GridSummary.fromPoints(cell.filterWithinBlob(points, blob), lookup)) + .then((points) => cell.filterWithinBlob(points, blob)); + + // Footprint highlight + backdrop dim appear with the data (web parity with + // highlightSpotCoverage(points)): the block fills in the dominant colour of + // the in-blob pings. No pings → no footprint, no dim (web early-return). + blobPointsFuture.then((pts) { + if (!mounted || !_cellSummaryShowing) return; + final st = dominantCoverageStatus(pts); + if (st != null) _showCellFootprint(cell, blob, st); + }).catchError((Object e) { + debugWarn('[COVERAGE] cell highlight failed: $e'); + }); + + final Future summaryFuture = blobPointsFuture + .then((pts) => GridSummary.fromPoints(pts, lookup)) .catchError((Object e) { debugWarn('[COVERAGE] cell summary failed: $e'); return null; }); - _cellSummaryShowing = true; showModalBottomSheet( context: context, useSafeArea: true, @@ -2703,26 +2725,29 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Map _emptyFeatureCollection() => {'type': 'FeatureCollection', 'features': []}; - /// Outline-only FeatureCollection for the tapped cell's (2·blob+1)² block: a - /// single LineString tracing the block border, centred on the tapped tile. - Map _buildCellHighlightGeoJson(GridCell cell, int blob) => + /// Filled FeatureCollection for the tapped cell's (2·blob+1)² block — one + /// Polygon per cell (nine in Detailed, one in Simplified), centred on the + /// tapped tile. Drawn in a single uniform colour to mirror the web's nine + /// `L.rectangle`s. + Map _buildCellFootprintGeoJson(GridCell cell, int blob) => { 'type': 'FeatureCollection', 'features': [ - { - 'type': 'Feature', - 'properties': {}, - 'geometry': { - 'type': 'LineString', - 'coordinates': cell.blockRing(blob), + for (final ring in cell.blockCellPolygons(blob)) + { + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, }, - }, ], }; - /// Idempotently (re)create the tap cell-highlight source + line layer. A line - /// layer over a tiny LineString ring — no fill, and nothing renders while the - /// source is empty (the idle state). Self-healing like the patch layer. + /// Idempotently (re)create the tap cell-highlight source + fill layer. Nothing + /// renders while the source is empty (the idle state); colours are set per tap + /// in [_showCellFootprint]. Self-healing like the patch layer. Future _ensureCellHighlightLayer() async { if (_cellHighlightReady) return true; if (_mapController == null) return false; @@ -2738,15 +2763,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (_) {} await _mapController! .addGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); - await _mapController!.addLineLayer( + await _mapController!.addFillLayer( _cellHighlightSourceId, _cellHighlightLayerId, - const LineLayerProperties( - lineColor: '#00d2ff', - lineWidth: 2.5, - lineOpacity: 0.95, - lineCap: 'round', - lineJoin: 'round', + // Placeholder colours; the per-tap dominant colour is pushed in + // _showCellFootprint. Full opacity so the footprint pops over the + // dimmed coverage backdrop. + const FillLayerProperties( + fillColor: '#bd2130', + fillOutlineColor: '#8b101b', + fillOpacity: 1.0, ), belowLayerId: belowLayer, ); @@ -2758,22 +2784,53 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - /// Draw the highlight block for the tapped [cell] (centred on it). [blob] = 1 - /// in Detailed (3×3), 0 in Simplified (single cell). - Future _setCellHighlight(GridCell cell, int blob) async { - if (_mapController == null) return; + /// Fill the tapped [cell]'s block in the [st] dominant colour and dim the + /// coverage backdrop so the footprint stands out (web `highlightSpotCoverage` + /// parity). [blob] = 1 in Detailed (3×3), 0 in Simplified (single cell). + Future _showCellFootprint(GridCell cell, int blob, int st) async { + if (_mapController == null || !mounted) return; + // Read the palette before any await so context isn't used across an async gap. + final cvd = context.read().preferences.colorVisionType; + final colors = CoverageTilePalette.colorsForStatus(cvd, st); if (!await _ensureCellHighlightLayer()) return; try { + // setLayerProperties serializes with skipNulls:false (resets omitted + // fields to spec defaults), so resend all three fill props together. + await _mapController!.setLayerProperties( + _cellHighlightLayerId, + FillLayerProperties( + fillColor: colors[0], + fillOutlineColor: colors[1], + fillOpacity: 1.0, + ), + ); await _mapController!.setGeoJsonSource( - _cellHighlightSourceId, _buildCellHighlightGeoJson(cell, blob)); + _cellHighlightSourceId, _buildCellFootprintGeoJson(cell, blob)); + // Dim the coverage backdrop (base + patch) beneath the bright footprint. + _coverageDimmedForCell = true; + await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); } catch (e) { debugLog('[COVERAGE] cell-highlight set failed: $e'); } } - /// Clear the tap highlight (summary sheet closed): set the source to empty so - /// the layer renders nothing. The layer stays installed (cheap, no churn). + /// Clear the tap highlight (summary sheet closed): empty the source so the + /// layer renders nothing, and restore the dimmed coverage backdrop. The layer + /// stays installed (cheap, no churn). Future _clearCellHighlight() async { + if (_coverageDimmedForCell) { + _coverageDimmedForCell = false; + // Restore the backdrop, honouring ping-focus mode (which keeps it hidden). + if (mounted) { + final restore = _focusedPingLocation != null + ? 0.0 + : context + .read() + .preferences + .coverageOverlayOpacity; + await _applyCoverageOverlayOpacity(restore); + } + } if (_mapController == null || !_cellHighlightReady) return; try { await _mapController! diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart index 6b8487f1..535ba335 100644 --- a/test/utils/coverage_summary_test.dart +++ b/test/utils/coverage_summary_test.dart @@ -337,4 +337,84 @@ void main() { expect(ne[0], closeTo(21 * lonStep, 1e-12)); }); }); + + group('GridCell.blockCellPolygons', () { + const latStep = 0.0009; + const lonStep = 0.00128; + const cell = GridCell(10, 20, latStep, lonStep); + + test('blob=0 yields the single tapped cell', () { + final polys = cell.blockCellPolygons(0); + expect(polys.length, 1); + final ring = polys.first; + expect(ring.length, 5); + expect(ring.first, ring.last); // closed ring, [lon, lat] order + expect(ring[0][0], closeTo(20 * lonStep, 1e-12)); // minLon + expect(ring[0][1], closeTo(10 * latStep, 1e-12)); // minLat + expect(ring[2][0], closeTo(21 * lonStep, 1e-12)); // maxLon + expect(ring[2][1], closeTo(11 * latStep, 1e-12)); // maxLat + }); + + test('blob=1 yields nine unit cells covering the 3x3 block', () { + final polys = cell.blockCellPolygons(1); + expect(polys.length, 9); + final iSet = {}; + final jSet = {}; + for (final ring in polys) { + expect(ring.length, 5); + expect(ring.first, ring.last); + // Each polygon is exactly one grid cell (1 lonStep x 1 latStep). + expect(ring[2][0] - ring[0][0], closeTo(lonStep, 1e-12)); + expect(ring[2][1] - ring[0][1], closeTo(latStep, 1e-12)); + jSet.add((ring[0][0] / lonStep).round()); + iSet.add((ring[0][1] / latStep).round()); + } + // The 3x3 covers exactly i in 9..11 and j in 19..21, centred on (10, 20). + expect(iSet, {9, 10, 11}); + expect(jSet, {19, 20, 21}); + }); + }); + + group('dominantCoverageStatus', () { + Map p(int status, + {String? heardRepeats, String? directHeard}) => + { + 'lat': 45.0, + 'lon': -75.0, + 'status': status, + if (heardRepeats != null) 'heard_repeats': heardRepeats, + if (directHeard != null) 'direct_heard': directHeard, + }; + + test('returns null for no points', () { + expect(dominantCoverageStatus(const []), isNull); + }); + + test('maps each status to its st category (web parity)', () { + expect(dominantCoverageStatus([p(1, heardRepeats: 'ab(5)')]), 1); // green + expect(dominantCoverageStatus([p(1)]), 1); // BIDIR is green either way + expect(dominantCoverageStatus([p(2)]), 3); // TX -> orange + expect(dominantCoverageStatus([p(5)]), 4); // RX -> purple + expect(dominantCoverageStatus([p(6)]), 2); // DISC -> cyan + expect(dominantCoverageStatus([p(7)]), 2); // TRACE -> cyan + expect(dominantCoverageStatus([p(3)]), 5); // DEAD -> grey + expect(dominantCoverageStatus([p(0)]), 6); // DROP -> red + expect(dominantCoverageStatus([p(99)]), 6); // unknown -> red + }); + + test('green wins over red when both are in the blob', () { + expect(dominantCoverageStatus([p(0), p(1)]), 1); // red + green -> green + expect(dominantCoverageStatus([p(1), p(0)]), 1); // order independent + }); + + test('an all-red blob stays red (the intentional smear)', () { + // The green a neighbour cell shows came from a ping OUTSIDE this blob, so + // the in-blob pings are all red -> the whole block resolves to red. + expect(dominantCoverageStatus([p(0), p(0)]), 6); + }); + + test('picks the highest-priority (lowest st) across a mix', () { + expect(dominantCoverageStatus([p(2), p(6), p(3)]), 2); // orange/cyan/grey -> cyan + }); + }); } diff --git a/test/utils/coverage_tile_palette_test.dart b/test/utils/coverage_tile_palette_test.dart new file mode 100644 index 00000000..e09c7899 --- /dev/null +++ b/test/utils/coverage_tile_palette_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/coverage_tile_palette.dart'; + +void main() { + group('CoverageTilePalette.colorsForStatus', () { + test('none palette maps st to [fill, border]', () { + expect(CoverageTilePalette.colorsForStatus('none', 1), + ['#1e7e34', '#14522d']); // green + expect(CoverageTilePalette.colorsForStatus('none', 2), + ['#17a2b8', '#117a8b']); // cyan + expect(CoverageTilePalette.colorsForStatus('none', 6), + ['#bd2130', '#8b101b']); // red + }); + + test('unknown cvd mode falls back to none', () { + expect(CoverageTilePalette.colorsForStatus('bogus', 1), + CoverageTilePalette.colorsForStatus('none', 1)); + }); + + test('clamps out-of-range st into 1..6', () { + expect(CoverageTilePalette.colorsForStatus('none', 0), + CoverageTilePalette.colorsForStatus('none', 1)); + expect(CoverageTilePalette.colorsForStatus('none', 99), + CoverageTilePalette.colorsForStatus('none', 6)); + }); + + test('a cvd palette differs from none', () { + expect(CoverageTilePalette.colorsForStatus('protanopia', 1), + isNot(CoverageTilePalette.colorsForStatus('none', 1))); + }); + }); +} From a74b53010b71b639312d993b92be2f42dcbda782 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 20:46:31 -0400 Subject: [PATCH 144/360] Cell-summary & repeater-detail popups: bright map (no scrim) + minimize button Both tap popups now match the ping-focus sheets: a transparent modal barrier so the map and markers stay fully bright (the tile footprint's 3x3 highlight + 0.15 coverage dim still pop), plus a minimize button that collapses the sheet to a tappable bottom pill, leaving the map interactive. Adds a generic _MinimizedInfoPopup + pill (kept separate from focus state), extracts _presentCellSummarySheet for reuse on reshow, and threads an optional onMinimize into CellSummarySheet. Opening a new popup supersedes a minimized one (a minimized cell's footprint is cleaned up when superseded by a repeater; cell-to-cell keeps the old footprint until the new data lands, so no flash). --- lib/widgets/cell_summary_sheet.dart | 15 +++ lib/widgets/map_widget.dart | 197 +++++++++++++++++++++++++++- 2 files changed, 205 insertions(+), 7 deletions(-) diff --git a/lib/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart index 90648da3..4710fcf2 100644 --- a/lib/widgets/cell_summary_sheet.dart +++ b/lib/widgets/cell_summary_sheet.dart @@ -13,10 +13,15 @@ class CellSummarySheet extends StatelessWidget { final Future summaryFuture; final bool isImperial; + /// When non-null, a minimize button is shown in the header (collapses the + /// sheet to a pill, like the ping-focus sheets). Null = close-only. + final VoidCallback? onMinimize; + const CellSummarySheet({ super.key, required this.summaryFuture, required this.isImperial, + this.onMinimize, }); // Web GRID SUMMARY palette (dev/index.php getPalette 'none'). @@ -52,6 +57,16 @@ class CellSummarySheet extends StatelessWidget { letterSpacing: 0.5), ), ), + if (onMinimize != null) + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: onMinimize, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: theme.colorScheme.onSurfaceVariant, + tooltip: 'Minimize', + ), + if (onMinimize != null) const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 08b1ea28..245e3354 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -390,6 +390,25 @@ class _ResolvedRepeater { const _ResolvedRepeater(this.repeater, this.snr, this.ambiguous); } +/// A minimized info popup (cell summary or repeater detail) rendered as a +/// bottom pill the user can tap to re-open. [onReshow] re-presents the sheet; +/// [onClose] dismisses it and runs any cleanup (e.g. clearing the cell +/// footprint). Mirrors the ping-focus minimize, but for the tap popups. +class _MinimizedInfoPopup { + final String title; + final IconData icon; + final Color color; + final VoidCallback onReshow; + final VoidCallback onClose; + const _MinimizedInfoPopup({ + required this.title, + required this.icon, + required this.color, + required this.onReshow, + required this.onClose, + }); +} + /// Map widget with TX/RX markers /// Uses MapLibre GL with OpenFreeMap vector tiles class MapWidget extends StatefulWidget { @@ -501,6 +520,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bool _focusPanelMinimized = false; dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry + // A minimized cell-summary / repeater-detail popup, shown as a tappable pill + // (parity with the ping-focus minimize). Separate from focus state so it can't + // entangle focus mode; null when nothing is minimized. + _MinimizedInfoPopup? _minimizedInfoPopup; + // MapLibre style and overlay tracking. // Tracks the zone code we last rendered the coverage overlay for. When the // zone check succeeds after the style has already loaded (e.g. first check @@ -1518,6 +1542,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), + // Minimized cell-summary / repeater-detail pill — same idea as the focus + // pill, for the tap popups. Map stays interactive while it's shown. + if (_minimizedInfoPopup != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildMinimizedInfoPill(_minimizedInfoPopup!), + ), + ), + // History session pill (bottom, styled like minimized focus panel) if (appState.viewingHistorySession) Positioned( @@ -2005,7 +2041,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${coordinates.longitude.toStringAsFixed(5)} ' 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); - _cellSummaryShowing = true; + // Supersede a currently-minimized popup. If it was a cell, its footprint + // stays until this tap's own footprint replaces it (no bright flash); if it + // was a repeater, just drop the pill. + if (_minimizedInfoPopup != null) { + setState(() => _minimizedInfoPopup = null); + } // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2035,20 +2076,68 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return null; }); - showModalBottomSheet( + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + } + + /// Present (or re-present) the cell GRID SUMMARY sheet for [cell]. Bright map + /// (transparent barrier so the footprint dim still pops) + a minimize button + /// that collapses to a tappable pill, mirroring the ping-focus sheets. The + /// footprint highlight + dim stay while minimized and are cleared on a real + /// close. Reused by both the initial tap and the pill's reshow. + void _presentCellSummarySheet({ + required GridCell cell, + required int blob, + required Future summaryFuture, + required bool isImperial, + }) { + _cellSummaryShowing = true; + final pillColor = Theme.of(context).colorScheme.primary; + showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays bright (footprint dim still pops). + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => CellSummarySheet( + builder: (ctx) => CellSummarySheet( summaryFuture: summaryFuture, isImperial: isImperial, + onMinimize: () => Navigator.pop(ctx, 'minimized'), ), - ).whenComplete(() { + ).then((result) { _cellSummaryShowing = false; - _clearCellHighlight(); + if (!mounted) return; + if (result == 'minimized') { + setState(() { + _minimizedInfoPopup = _MinimizedInfoPopup( + title: 'Grid Summary', + icon: Icons.grid_on, + color: pillColor, + onReshow: () { + setState(() => _minimizedInfoPopup = null); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + setState(() => _minimizedInfoPopup = null); + _clearCellHighlight(); + }, + ); + }); + } else { + _clearCellHighlight(); + } }); } @@ -6516,6 +6605,69 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Pill for a minimized cell-summary / repeater-detail popup (parity with + /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X + /// closes it and runs its cleanup. + Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { + return GestureDetector( + onTap: popup.onReshow, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(popup.icon, color: popup.color, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text( + popup.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: popup.onReshow, + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: popup.onClose, + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + Widget _buildMinimizedFocusPanel() { final source = _focusedPingSource; String title; @@ -7990,6 +8142,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Show repeater details popup void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + // Supersede any currently-minimized popup (clears a prior cell footprint/dim). + _minimizedInfoPopup?.onClose(); + // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -8035,9 +8190,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : repeater.hexId.toUpperCase(); final clockSkew = humanizeClockSkew(repeater.timeOffset); - showModalBottomSheet( + showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays bright (like focus mode). + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -8092,6 +8249,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -8254,7 +8419,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), - ); + ).then((result) { + if (!mounted) return; + if (result == 'minimized') { + setState(() { + _minimizedInfoPopup = _MinimizedInfoPopup( + title: repeater.name, + icon: Icons.cell_tower, + color: iconColor, + onReshow: () { + setState(() => _minimizedInfoPopup = null); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride); + }, + onClose: () => setState(() => _minimizedInfoPopup = null), + ); + }); + } + }); } /// One labelled icon row in the repeater detail card. A null [icon] leaves the From d8a934624465127e888c32da387b7ad464588f07 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 21:43:17 -0400 Subject: [PATCH 145/360] Minimized cell/repeater pill: hide the control bar (like focus mode) The minimize pill sat underneath the ping control panel. Mirror focus mode's mechanism: add an AppStateProvider.infoPopupMinimized flag and OR it into the same control-panel visibility conditions + map bottom-padding calc in home_screen that isFocusModeActive/viewingHistorySession already use. The pill set/clear is centralized in _setMinimizedInfoPopup/_clearMinimizedInfoPopup so the flag stays in lockstep across every show/reshow/close/supersede path. --- lib/providers/app_state_provider.dart | 11 +++ lib/screens/home_screen.dart | 28 +++++-- lib/widgets/map_widget.dart | 104 +++++++++++++++++--------- 3 files changed, 100 insertions(+), 43 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 28cc3bf4..daf227e6 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -856,6 +856,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // A cell-summary / repeater-detail popup is minimized to a bottom pill — hide + // the control panel and zero the map's bottom padding for it, like focus mode. + bool _infoPopupMinimized = false; + bool get infoPopupMinimized => _infoPopupMinimized; + set infoPopupMinimized(bool value) { + if (_infoPopupMinimized != value) { + _infoPopupMinimized = value; + notifyListeners(); + } + } + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b974b8cf..efcd7255 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -60,7 +60,10 @@ class _HomeScreenState extends State { rev: p.mapRevision, focus: p.isFocusModeActive, history: p.viewingHistorySession, - padH: isLandscape || p.isFocusModeActive || p.viewingHistorySession + padH: isLandscape || + p.isFocusModeActive || + p.viewingHistorySession || + p.infoPopupMinimized ? 0.0 : _getControlPanelHeight(), ctrl: isLandscape ? _mapControlsExpanded : null, @@ -528,8 +531,12 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel (hidden during focus mode and history view) - if (!isLandscape && !appState.isFocusModeActive && !appState.viewingHistorySession) + // Portrait: bottom control panel (hidden during focus mode, history view, + // and while a cell/repeater popup is minimized to a pill) + if (!isLandscape && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 0, left: 0, @@ -539,14 +546,23 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB (hidden during focus mode and history view) - if (isLandscape && _showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) + // Landscape: side control panel or FAB (hidden during focus mode, history + // view, and while a cell/repeater popup is minimized to a pill) + if (isLandscape && + _showControlPanel && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) + if (isLandscape && + !_showControlPanel && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 16, left: leftInset, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 245e3354..18b3f60f 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -2044,9 +2044,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Supersede a currently-minimized popup. If it was a cell, its footprint // stays until this tap's own footprint replaces it (no bright flash); if it // was a repeater, just drop the pill. - if (_minimizedInfoPopup != null) { - setState(() => _minimizedInfoPopup = null); - } + _clearMinimizedInfoPopup(); // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2115,26 +2113,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _cellSummaryShowing = false; if (!mounted) return; if (result == 'minimized') { - setState(() { - _minimizedInfoPopup = _MinimizedInfoPopup( - title: 'Grid Summary', - icon: Icons.grid_on, - color: pillColor, - onReshow: () { - setState(() => _minimizedInfoPopup = null); - _presentCellSummarySheet( - cell: cell, - blob: blob, - summaryFuture: summaryFuture, - isImperial: isImperial, - ); - }, - onClose: () { - setState(() => _minimizedInfoPopup = null); - _clearCellHighlight(); - }, - ); - }); + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: 'Grid Summary', + icon: Icons.grid_on, + color: pillColor, + onReshow: () { + _clearMinimizedInfoPopup(); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + }, + )); } else { _clearCellHighlight(); } @@ -4040,6 +4036,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${type}_${ts.millisecondsSinceEpoch}_' '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; + /// Fixed base epoch for coverage-marker z-ordering. Keeps [_recencyZIndex] + /// small enough to stay exact in the native float32 symbol sort-key (integers + /// are exact up to 16,777,216). + static final DateTime _kZBaseEpoch = DateTime.utc(2025, 1, 1); + + /// Render/tap z-order for a coverage marker: seconds since [_kZBaseEpoch], so a + /// more-recent ping always sorts above (renders on top of, and is tapped in + /// preference to) an older one — globally, across TX/RX/DISC/Trace. + /// + /// The annotation manager's symbol layer uses `symbol-sort-key: ["get", + /// "zIndex"]`; with `icon-allow-overlap = true` a higher sort key overlaps a + /// lower one. Setting this also flips `symbol-z-order: auto` off its default + /// `viewport-y` (southernmost-on-top) fallback. It's a pure function of the + /// ping's own timestamp, so existing symbols never need re-pushing — the + /// incremental-sync skip path (gated on the icon/size signature) is untouched. + int _recencyZIndex(DateTime ts) => ts.difference(_kZBaseEpoch).inSeconds; + /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. /// One symbol per ping, image varies by type/success state, opacity reflects /// focus mode (faded if focus active and this isn't the focused ping). @@ -4097,6 +4110,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { geometry: LatLng(lat, lon), iconImage: iconImage, iconSize: iconSize, + // Recency-ordered sort key: the most recent ping renders on top + // and wins the native topmost-first tap hit-test, so an older + // overlapping marker is never selected in its place. + zIndex: _recencyZIndex(ts), ), {'kind': type, 'id': idForMetadata}, ); @@ -6605,6 +6622,21 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Show the minimized info pill and hide the control panel (the focus/history + /// pills do the same — they share the bottom area). [infoPopupMinimized] zeroes + /// the map's bottom padding and removes the control panel in home_screen. + void _setMinimizedInfoPopup(_MinimizedInfoPopup popup) { + setState(() => _minimizedInfoPopup = popup); + context.read().infoPopupMinimized = true; + } + + /// Hide the minimized info pill and restore the control panel. + void _clearMinimizedInfoPopup() { + if (_minimizedInfoPopup == null) return; + setState(() => _minimizedInfoPopup = null); + context.read().infoPopupMinimized = false; + } + /// Pill for a minimized cell-summary / repeater-detail popup (parity with /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X /// closes it and runs its cleanup. @@ -8422,20 +8454,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ).then((result) { if (!mounted) return; if (result == 'minimized') { - setState(() { - _minimizedInfoPopup = _MinimizedInfoPopup( - title: repeater.name, - icon: Icons.cell_tower, - color: iconColor, - onReshow: () { - setState(() => _minimizedInfoPopup = null); - _showRepeaterDetails(repeater, - isDuplicate: isDuplicate, - regionHopBytesOverride: regionHopBytesOverride); - }, - onClose: () => setState(() => _minimizedInfoPopup = null), - ); - }); + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: repeater.name, + icon: Icons.cell_tower, + color: iconColor, + onReshow: () { + _clearMinimizedInfoPopup(); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride); + }, + onClose: _clearMinimizedInfoPopup, + )); } }); } From 2b64b352e7ec63e35513207ca7209dfcdfa8dc69 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:10:28 -0400 Subject: [PATCH 146/360] Sounds follow media volume; landscape controls can minimize to a centered bottom bar - audio_service: route TX/RX/alert blips to the Android media stream (usage: media) so they track the media volume slider instead of the ringer/notification stream (#88). - home_screen: add a minimize button to the landscape control panel; the collapsed state shows a compact bar centered along the bottom of the map (#329). --- lib/screens/home_screen.dart | 85 ++++++++++++++++++++++++++++++++- lib/services/audio_service.dart | 7 ++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index efcd7255..e42d7ccc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -546,10 +546,11 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB (hidden during focus mode, history - // view, and while a cell/repeater popup is minimized to a pill) + // Landscape: full side control panel, bottom-left (hidden during focus + // mode, history view, and while a cell/repeater popup is minimized) if (isLandscape && _showControlPanel && + !_isControlsMinimized && !appState.isFocusModeActive && !appState.viewingHistorySession && !appState.infoPopupMinimized) @@ -558,6 +559,20 @@ class _HomeScreenState extends State { left: leftInset, child: _buildLandscapeControlPanel(appState), ), + // Landscape: minimized control bar, centered along the bottom of the map + // (see #329) + if (isLandscape && + _showControlPanel && + _isControlsMinimized && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center(child: _buildLandscapeCompactControlPanel()), + ), if (isLandscape && !_showControlPanel && !appState.isFocusModeActive && @@ -708,6 +723,19 @@ class _HomeScreenState extends State { ), ), ), + // Minimize button - collapse to compact strip (see #329) + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _isControlsMinimized = true), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.close_fullscreen, + size: 22, color: Colors.grey.shade400), + ), + ), + ), // Close button - larger touch target Material( color: Colors.transparent, @@ -738,6 +766,59 @@ class _HomeScreenState extends State { ); } + /// Build minimized landscape control panel — a compact strip mirroring the + /// portrait compact panel (CompactPingControls + expand button), sized for the + /// landscape side overlay. Reuses the shared `_isControlsMinimized` flag (see + /// #329). Status/zone stays visible via the floating status bar, so this only + /// needs the ping controls + an expand affordance. + Widget _buildLandscapeCompactControlPanel() { + return Container( + width: _landscapePanelWidth, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Compact controls (expands to fill available space) + const Expanded( + child: CompactPingControls(), + ), + // Vertical divider + Container( + height: 24, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Colors.grey.withValues(alpha: 0.3), + ), + // Expand button - restores the full landscape panel + GestureDetector( + onTap: () => setState(() => _isControlsMinimized = false), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.open_in_full, + size: 20, + color: Colors.grey.shade500, + ), + ), + ), + ], + ), + ), + ); + } + /// Compact zone chip for landscape panel Widget _buildZoneChip(AppStateProvider appState) { IconData icon; diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 64f3707e..d0f0c90a 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -68,7 +68,9 @@ class AudioService { // Android: transient focus allows other audio to continue androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.sonification, - usage: AndroidAudioUsage.notification, + // Route blips to the MEDIA stream so they follow the media + // volume slider (not the ringer/notification volume). See #88. + usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, @@ -267,7 +269,8 @@ class AudioService { avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.sonification, - usage: AndroidAudioUsage.notification, + // Route blips to the MEDIA stream (follows media volume). See #88. + usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, From 2ae6d33c8040891682e9f191c001380abf940d52 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:17:14 -0400 Subject: [PATCH 147/360] TX 'Broadcast My Coordinates': drop combined wire-tag+coords on-air format that hid pings from coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In broadcast-coords mode the app sent on-air "MM::," while still posting the bare wire_tag to /wardrive. The server's observer truncates the on-air body to 16 chars and exact-matches it as the wire tag, so it never joined the app's coverage row — the TX stayed at DEAD and never reached the coverage view (visible only in the analyzer). RX and privacy mode were unaffected. Broadcast-coords now uses the plain coords path: "MM:," on air, no wire_tag/ping_counter to the API (coords still travel via API lat/lon), matching the server's documented contract. No ping counter is consumed in this mode. --- lib/services/ping_service.dart | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 34c29d33..6d61b571 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,9 +597,15 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // With a session: a keyed wire tag "MM:" (privacy default), or - // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). - // No session yet: plaintext "MM:lat,lon" (no tag can be computed). + // Two mutually-exclusive paths (they cannot be combined — the server's observer + // truncates the on-air body to 16 chars and exact-matches it as the wire tag, so + // appending coords to a tag breaks the observer→app join and the TX is stranded + // at DEAD/never shown in coverage): + // • Privacy (default, session present): keyed wire tag "MM:" on air, bare + // tag + counter posted to the API → server's validated wire-tag path. + // • Broadcast My Coordinates ON, or no session yet: plaintext "MM:lat,lon" on + // air and NO wire_tag/ping_counter to the API → server's coords path (coords + // also always travel via the API lat/lon either way). final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -608,11 +614,10 @@ class PingService { int? txPingCounter; String? txWireTag; final hasSession = sessionId != null && sessionId.isNotEmpty; - if (hasSession) { + if (hasSession && !broadcastCoords) { // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it // is exhausted, end the session cleanly rather than repeating/corrupting tags. - // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes - // a counter exactly like a privacy ping, so the session ends identically. + // Only the privacy/wire-tag path consumes a counter. if ((getPingCounter?.call() ?? 0) >= 2047) { debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; @@ -622,12 +627,11 @@ class PingService { txPingCounter = getNextPingCounter?.call() ?? 1; txWireTag = WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); - // Identical to privacy mode in every way EXCEPT broadcast-coords appends the - // plaintext coords to the on-air body. The bare tag still goes to the API - // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. - pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; + pingMessage = txWireTag; } else { - // No session yet → no tag can be computed; plaintext coords only (unchanged). + // Broadcast-coords mode (or no session yet): plaintext coords, NO wire tag. + // txPingCounter / txWireTag stay null → _pendingTx* null → the queued TX entry + // omits ping_counter/wire_tag → server uses its coords path. pingMessage = 'MM:$coordsStr'; } From 6a7e939757740401aac3907d4f739032fde269c1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:23:05 -0400 Subject: [PATCH 148/360] Revert "TX 'Broadcast My Coordinates': drop combined wire-tag+coords on-air format" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the combined on-air format MM::, for broadcast-coords mode — the keyed region-tag is intentional so cross-region spillover can still be attributed even when a user broadcasts coordinates. The coverage-join breakage is being fixed server-side (api.php normalizes the on-air body back to the bare tag before the join + spillover decode) instead of removing the tag client-side. This reverts commit be1bfea36bf5ff8939f90c53d23e15a3a12921de. --- lib/services/ping_service.dart | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 6d61b571..34c29d33 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,15 +597,9 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // Two mutually-exclusive paths (they cannot be combined — the server's observer - // truncates the on-air body to 16 chars and exact-matches it as the wire tag, so - // appending coords to a tag breaks the observer→app join and the TX is stranded - // at DEAD/never shown in coverage): - // • Privacy (default, session present): keyed wire tag "MM:" on air, bare - // tag + counter posted to the API → server's validated wire-tag path. - // • Broadcast My Coordinates ON, or no session yet: plaintext "MM:lat,lon" on - // air and NO wire_tag/ping_counter to the API → server's coords path (coords - // also always travel via the API lat/lon either way). + // With a session: a keyed wire tag "MM:" (privacy default), or + // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). + // No session yet: plaintext "MM:lat,lon" (no tag can be computed). final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -614,10 +608,11 @@ class PingService { int? txPingCounter; String? txWireTag; final hasSession = sessionId != null && sessionId.isNotEmpty; - if (hasSession && !broadcastCoords) { + if (hasSession) { // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it // is exhausted, end the session cleanly rather than repeating/corrupting tags. - // Only the privacy/wire-tag path consumes a counter. + // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes + // a counter exactly like a privacy ping, so the session ends identically. if ((getPingCounter?.call() ?? 0) >= 2047) { debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; @@ -627,11 +622,12 @@ class PingService { txPingCounter = getNextPingCounter?.call() ?? 1; txWireTag = WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); - pingMessage = txWireTag; + // Identical to privacy mode in every way EXCEPT broadcast-coords appends the + // plaintext coords to the on-air body. The bare tag still goes to the API + // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. + pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; } else { - // Broadcast-coords mode (or no session yet): plaintext coords, NO wire tag. - // txPingCounter / txWireTag stay null → _pendingTx* null → the queued TX entry - // omits ping_counter/wire_tag → server uses its coords path. + // No session yet → no tag can be computed; plaintext coords only (unchanged). pingMessage = 'MM:$coordsStr'; } From d99687fe61e821e11e1dde2d03af405ff1acdb4e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:31:11 -0400 Subject: [PATCH 149/360] Coverage tap-to-inspect: connection lines + repeater coverage cells Tapping coverage data now draws connection lines from the points the tap flow already fetches (no new API calls), matching the web client's matching/fan-out: - Tap a coverage tile: fan out blue dashed lines from the cell centre to every unique repeater that heard its pings (with distance labels), hiding the rest. - Tap a repeater: draw its coverage cells (status/tile colours) + status-coloured lines to each cell centre, dim the base tiles, and hide the other repeaters. Cap 5000 cells (farthest-first) so the full footprint draws. - Repeater pill: status shown as a coloured tower icon (green online / grey offline) so the stats row fits on one line. New helpers in coverage_summary.dart (fromCoverageWithPoints, repeaterCoverageCells, heardEndpointsForCell) reuse the existing web-parity matching logic. --- DEVELOPMENT.md | 34 + lib/utils/coverage_summary.dart | 272 +++++++- lib/widgets/map_widget.dart | 1132 ++++++++++++++++++++++++++----- 3 files changed, 1256 insertions(+), 182 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6005f94c..784e1b91 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -369,6 +369,40 @@ vector-only — every region server must serve `vector_tile.php` (the legacy ras `lib/services/api_service.dart` (`freshenVectorTile`), `lib/utils/coverage_tile_palette.dart`, `lib/utils/mvt_cells.dart`. +### Coverage Connection Lines (tap-to-inspect) + +Tapping coverage data draws connection lines from points the tap flow ALREADY +fetches (no extra network calls), matching the web client's exact matching + +fan-out logic (`MeshMapper_Server/dev/index.php`): + +- **Tap a coverage tile (Feature A)**: fans out a theme-aware blue dashed line + from the cell centre to every UNIQUE repeater that heard the cell's pings (with + a distance pill per line) and hides the repeaters that didn't. Hooks the + blob-filtered points already computed in `_showCellSummary`. Port of + `updateAllActiveLines`/`updateActiveLinesInternal` via `heardEndpointsForCell`. +- **Tap a repeater (Feature B)**: draws the repeater's matched coverage cells + (status/tile-coloured fills, deduped per grid cell with highest-priority status + winning, red/DROP hidden) plus a status-coloured dashed line from the repeater + to each cell centre. The base coverage tiles DIM and every OTHER repeater is + hidden so the focused repeater's cells/lines pop (web `setSoloCircle` + + tile-dim parity); both restored on close. + Reuses the points fetched in `_showRepeaterDetails`. Port of + `buildChartFromPoints` (`RepeaterStats.fromCoverageWithPoints`) + + `drawRepeaterCoverageFromCache` (`repeaterCoverageCells`). +- **Volume cap**: both cap at the farthest 250 lines/cells (longest reach kept), + logged under `[COVERAGE]` when truncated. +- **Layers** (`map_widget.dart`): `coverage-lines-layer` (shared A/B, per-feature + `color`) and `coverage-cells-layer` (per-feature fill) — install-once empty, + updated via `setGeoJsonSource`, kept separate from the focus-mode lines so the + two features never wipe each other. Imperative draws (no `mapRevision` bump). + Teardown funnels through `_clearCellHighlight` (A) and `_clearRepeaterIsolation` + (B), which also restore the dimmed backdrop and the hidden/all repeaters. +- **Files**: `lib/widgets/map_widget.dart` (`_updateCoverageLines`, + `_updateCoverageCells`, `_drawRepeaterCoverage`, `_syncCoverageDistanceLabels`), + `lib/utils/coverage_summary.dart` (`heardEndpointsForCell`, + `repeaterCoverageCells`, `RepeaterStats.fromCoverageWithPoints`), + `lib/utils/coverage_tile_palette.dart` (`colorsForStatus`). + ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` - RX Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (write to device) diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index ef701a2b..a3160420 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -509,8 +509,26 @@ class RepeaterStats { int get totalMatched => bidir + tx + rx + disc + dead; factory RepeaterStats.fromCoverage( - List> points, Repeater target, RepeaterLookup lookup, - {bool disableDupLogic = false}) { + List> points, + Repeater target, + RepeaterLookup lookup, + {bool disableDupLogic = false}) => + fromCoverageWithPoints(points, target, lookup, + disableDupLogic: disableDupLogic) + .stats; + + /// Like [fromCoverage] but ALSO returns the raw points that matched this + /// repeater (parity with the web client's `buildChartFromPoints` + /// `_matchedPoints`), so callers can draw the repeater's coverage cells + + /// connection lines. [fromCoverage] delegates here, so there is ONE source of + /// matching truth. + static ({RepeaterStats stats, List> matched}) + fromCoverageWithPoints( + List> points, + Repeater target, + RepeaterLookup lookup, + {bool disableDupLogic = false}) { + final matchedPoints = >[]; final repId = target.id.toLowerCase(); final repLat = target.lat; final repLon = target.lon; @@ -599,6 +617,7 @@ class RepeaterStats { } if (matched) { + matchedPoints.add(p); switch (type) { case 'BIDIR': bidir++; @@ -621,7 +640,7 @@ class RepeaterStats { } } - return RepeaterStats( + final stats = RepeaterStats( bidir: bidir, tx: tx, rx: rx, @@ -629,5 +648,252 @@ class RepeaterStats { dead: dead, maxRangeMeters: maxRange > 0 ? maxRange : null, ); + return (stats: stats, matched: matchedPoints); } } + +// --- repeater coverage cells (Feature B) ------------------------------------- + +/// One coverage grid cell that a selected repeater heard, ready to render as a +/// status-coloured footprint cell plus a connection line to the repeater. Port +/// of the cell-dedup loop in the web client's `drawRepeaterCoverageFromCache` +/// (`dev/index.php:12208-12228`). +class RepeaterCoverageCell { + final int li; // latitude grid index + final int lj; // longitude grid index + final int st; // coverage status category 1..6 (lower = higher priority) + final double centerLat; + final double centerLon; + final double distanceMeters; // repeater -> cell centre (for the volume cap) + + const RepeaterCoverageCell({ + required this.li, + required this.lj, + required this.st, + required this.centerLat, + required this.centerLon, + required this.distanceMeters, + }); +} + +/// Dedups [matchedPoints] (from [RepeaterStats.fromCoverageWithPoints]) into the +/// grid cells the repeater heard, dilating each ping over a (2·[blob]+1)² block +/// (3×3 Detailed, 1×1 Simplified) and keeping the highest-priority status per +/// cell. Mirrors `drawRepeaterCoverageFromCache`: our `st` runs 1=green..6=red +/// (lower wins, INVERTED vs the web's `prio` map), so the keep test is +/// `newSt < existingSt`. Drops st==6 (red/DROP) cells — the app's vector overlay +/// is always the tiles/"advanced" equivalent, where the web hides drops +/// (`useAdv && st==='red'`). [latStep]/[lonStep] come from `kCoverageGridSteps`. +List repeaterCoverageCells( + List> matchedPoints, Repeater target, + {required double latStep, required double lonStep, required int blob}) { + final cells = {}; + for (final p in matchedPoints) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + if (la == null || lo == null) continue; + final st = _pointStatusCategory(p); + if (st == 6) continue; // hide red/DROP cells (tiles/advanced parity) + final li0 = (la / latStep).floor(); + final lj0 = (lo / lonStep).floor(); + for (var dx = -blob; dx <= blob; dx++) { + for (var dy = -blob; dy <= blob; dy++) { + final li = li0 + dx; + final lj = lj0 + dy; + final key = '${li}_$lj'; + final existing = cells[key]; + if (existing != null && st >= existing.st) continue; // lower st wins + final centerLat = (li + 0.5) * latStep; + final centerLon = (lj + 0.5) * lonStep; + cells[key] = RepeaterCoverageCell( + li: li, + lj: lj, + st: st, + centerLat: centerLat, + centerLon: centerLon, + distanceMeters: + _haversineMeters(target.lat, target.lon, centerLat, centerLon), + ); + } + } + } + return cells.values.toList(); +} + +// --- cell fan-out endpoints (Feature A) -------------------------------------- + +/// One repeater endpoint that heard a ping in a tapped cell, ready to render as +/// a fan-out connection line from the cell centre. +class HeardEndpoint { + final double lat; // repeater location (the dedup key, 6dp) + final double lon; + final double? snr; // best SNR seen (parity / optional labelling) + final String repeaterId; // resolved repeater id (drives the fade set) + final double distanceMeters; // cell centre -> repeater (for the volume cap) + + const HeardEndpoint({ + required this.lat, + required this.lon, + required this.snr, + required this.repeaterId, + required this.distanceMeters, + }); +} + +final RegExp _hexRunRe = RegExp(r'[a-f0-9]+', caseSensitive: false); + +/// Resolves the UNIQUE repeater endpoints that heard any of [blobPoints] (the +/// pings whose blob covers a tapped cell), for the tile fan-out lines. Exact +/// port of the web client's `updateActiveLinesInternal` (`dev/index.php:13830`) +/// with the `drawLine`-intercept endpoint collection from `updateAllActiveLines` +/// (`:13740`): dedup by endpoint lat/lon (6dp, first-wins), skip duplicate-styled +/// and hidden candidates, skip VIA tokens already heard, honour the DISC +/// public_key pre-branch and the DISC/TRACE coord requirement. The +/// ambiguous-`?`-no-candidate fallback is omitted because it only ever yields +/// duplicate-marked endpoints (which the intercept skips). +List heardEndpointsForCell( + List> blobPoints, RepeaterLookup lookup, + {required double startLat, required double startLon}) { + final seen = {}; + + void addEndpoint(_RepInfo rep, double? snr) { + if (rep.hidden) return; + // Self-line guard (web drawLine skips start==end within 1e-6). + if ((startLat - rep.lat).abs() < 1e-6 && (startLon - rep.lon).abs() < 1e-6) { + return; + } + final key = '${rep.lat.toStringAsFixed(6)},${rep.lon.toStringAsFixed(6)}'; + if (seen.containsKey(key)) return; // first-wins (mirror web seenEndpoints) + seen[key] = HeardEndpoint( + lat: rep.lat, + lon: rep.lon, + snr: snr, + repeaterId: rep.id, + distanceMeters: _haversineMeters(startLat, startLon, rep.lat, rep.lon), + ); + } + + for (final p in blobPoints) { + final status = _toInt(p['status']); + if (status == 0) continue; + + // 0. DISC public_key pre-branch (web :13841): resolve the responding + // repeater by full hex and add it (coord-validated), then skip this ping. + if (status == 6 && p['public_key'] is String) { + final pkClean = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final targetRep = lookup._byFullHex[pkClean]; + if (targetRep != null) { + final hr = + p['heard_repeats'] is String ? p['heard_repeats'] as String : ''; + final cm = _coordBracketRe.firstMatch(hr); + if (cm != null) { + final tLat = double.tryParse(cm.group(1)!); + final tLon = double.tryParse(cm.group(2)!); + if (tLat != null && + tLon != null && + _within100m(targetRep.lat, targetRep.lon, tLat, tLon)) { + addEndpoint(targetRep, + _toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])); + } + } + continue; // web returns the whole ping here + } + } + + // Combined heard list + the heardIDs set used to skip already-heard VIAs. + final rawRepeats = []; + final hrRaw = p['heard_repeats']; + if (hrRaw is String && hrRaw.isNotEmpty && hrRaw != 'None') { + rawRepeats.add(hrRaw); + } + final dhRaw = p['direct_heard']; + if (dhRaw is String && dhRaw.isNotEmpty && dhRaw != 'None') { + rawRepeats.add(dhRaw); + } + final combinedRepeats = rawRepeats.join(' '); + final heardIDs = {}; + for (final m in _hexRunRe.allMatches(combinedRepeats)) { + heardIDs.add(m.group(0)!.toLowerCase()); + } + + // 1. VIA lines (web :13883): path tokens, skipping any also in heardIDs. + final via = p['via']; + if (via is String && via.isNotEmpty && via != 'Direct' && via != 'N/A') { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + for (final m in _tokenRe.allMatches(cleanVia)) { + final normId = m.group(1)!.toLowerCase(); + if (normId == 'd' || normId == 'direct') continue; + if (heardIDs.contains(normId)) continue; + var candidates = lookup._candidatesFor(normId); + final coordStr = m.group(4); + if (coordStr != null) { + final hasDup = candidates.any((rep) => rep.status == 2); + if (!hasDup) { + final c = coordStr.split(','); + final pLat = c.length >= 2 ? double.tryParse(c[0]) : null; + final pLon = c.length >= 2 ? double.tryParse(c[1]) : null; + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + } else { + candidates = const <_RepInfo>[]; + } + for (final rep in candidates) { + if (rep.status == 2 && status != 6) continue; // dup -> skip endpoint + addEndpoint(rep, null); + } + } + } + + // 2. HEARD lines (web :13934): signal tokens, SNR-carried. + for (final m in _tokenRe.allMatches(combinedRepeats)) { + final rID = m.group(1)!.toLowerCase(); + var dbm = m.group(3); + final coordStr = m.group(4); + var candidates = lookup._candidatesFor(rID); + if (coordStr != null) { + final hasDup = candidates.any((rep) => rep.status == 2); + final c = coordStr.split(','); + final pLat = c.length >= 2 ? double.tryParse(c[0]) : null; + final pLon = c.length >= 2 ? double.tryParse(c[1]) : null; + if (!hasDup) { + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + if (status == 6 || status == 7) { + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + } else { + candidates = const <_RepInfo>[]; // no coords -> drop (default dup logic) + } + if ((dbm == null || dbm.isEmpty) && (status == 6 || status == 7)) { + dbm = (_toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])) + ?.toString(); + } + final snr = dbm != null ? double.tryParse(dbm) : null; + final fullHexMatch = lookup._byFullHex[rID] != null; + for (final rep in candidates) { + final isDup = + rep.status == 2 && !fullHexMatch && status != 6 && status != 7; + if (isDup) continue; // dup -> skip endpoint + addEndpoint(rep, snr); + } + } + } + + return seen.values.toList(); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 18b3f60f..163647b9 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -390,20 +390,20 @@ class _ResolvedRepeater { const _ResolvedRepeater(this.repeater, this.snr, this.ambiguous); } -/// A minimized info popup (cell summary or repeater detail) rendered as a -/// bottom pill the user can tap to re-open. [onReshow] re-presents the sheet; -/// [onClose] dismisses it and runs any cleanup (e.g. clearing the cell -/// footprint). Mirrors the ping-focus minimize, but for the tap popups. +/// A minimized info popup (cell summary or repeater detail) rendered as a 2-row +/// bottom pill — and the DEFAULT state a tap opens in. [title] is the row-1 +/// identity (icon + name); [statsBuilder] builds the row-2 stat chips (it may +/// use a FutureBuilder for lazily-fetched values). [onReshow] expands to the +/// full detail sheet; [onClose] dismisses it and runs any cleanup (e.g. clearing +/// the cell footprint). Mirrors the ping-focus minimize, but for the tap popups. class _MinimizedInfoPopup { - final String title; - final IconData icon; - final Color color; + final Widget title; + final WidgetBuilder statsBuilder; final VoidCallback onReshow; final VoidCallback onClose; const _MinimizedInfoPopup({ required this.title, - required this.icon, - required this.color, + required this.statsBuilder, required this.onReshow, required this.onClose, }); @@ -497,6 +497,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Region boundary overlay toggle (on by default) bool _showRegionBorders = true; + // Repeater pins toggle (on by default). When false, _syncRepeaterSymbols + // pushes an empty FeatureCollection so the pins clear without touching the + // layer styling (see #347). + bool _showRepeaters = true; + // Collapsible map controls in landscape bool _mapControlsExpanded = true; @@ -550,6 +555,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Guards against opening two GRID SUMMARY sheets when both the feature-tap and // the onMapClick hit-test fire for the same coverage cell tap. bool _cellSummaryShowing = false; + // True from a tile tap until the cell popup (pill OR sheet) is closed — gates + // drawing the footprint highlight so it isn't drawn after a dismiss. Cleared + // in _clearCellHighlight (the teardown for both the pill and the sheet). + bool _cellPopupActive = false; // Grid size is baked into the tile URL and the CVD palette into the layer // paint; a change to either rebuilds the overlay via the build watcher. int? _lastAppliedGridSize; @@ -577,6 +586,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _showCellFootprint and _clearCellHighlight; gates the opacity restore and // suppresses the build-method opacity-sync (which would otherwise un-dim). bool _coverageDimmedForCell = false; + // Same backdrop dim while a selected repeater's coverage cells/lines are shown + // (Feature B, web `drawRepeaterCoverageFromCache` parity). True between + // _drawRepeaterCoverage and _clearRepeaterIsolation; gates the opacity restore + // and suppresses the build-method opacity-sync. + bool _coverageDimmedForRepeater = false; static const double _kCellHighlightFadeOpacity = 0.15; int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; @@ -641,6 +655,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // nudge the symbol-collision pass. bool _focusLinesInstalled = false; + // Community coverage connection lines/cells (tap a tile -> fan out to heard + // repeaters; tap a repeater -> its coverage cells + lines). Install-once empty + // layers, updated in place via setGeoJsonSource. Separate from the focus-mode + // lines (which key off _focusedPingLocation and are rebuilt by + // _syncAllAnnotations) so the two features never wipe each other. + bool _coverageLinesInstalled = false; + bool _coverageCellsInstalled = false; + // Set while a tapped cell's fan-out is shown: the repeater ids that heard the + // cell's pings. Non-null -> _buildRepeaterFeatureCollection hides every + // repeater NOT in the set (web fades-but-keeps; the app hides for consistency + // with focus/isolation). Built from the capped endpoints. Null = inactive. + Set? _coverageHeardRepeaterIds; + // Distance-label pills for the cell fan-out lines (own tracking, mirrors the + // focus-mode distance labels but keyed by endpoint lat/lon at 6dp). + final Map _coverageDistanceLabelSymbols = {}; + final Set _registeredCoverageLabelImages = {}; + final Map _registeredCoverageLabelImageSizes = {}; + // Native annotation tracking — populated by sync methods. // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff // (add new, update existing, remove deleted) on each data version change. @@ -728,6 +760,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _spiderCollapseZoomDelta and collapse (positions become invalid). double? _spiderOpenedAtZoom; + // Repeater isolation — when non-null, ONLY this repeater is shown on the map; + // every other repeater is skipped in _buildRepeaterFeatureCollection (web + // parity: clicking a repeater hides the rest). Set when a single repeater's + // detail sheet opens, cleared when it closes/minimizes or on empty-map tap. + String? _isolatedRepeaterId; + // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving @@ -1549,9 +1587,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bottom: 16 + MediaQuery.of(context).padding.bottom, left: 16, right: 16, - child: Center( - child: _buildMinimizedInfoPill(_minimizedInfoPopup!), - ), + child: _buildMinimizedInfoPill(_minimizedInfoPopup!), ), // History session pill (bottom, styled like minimized focus panel) @@ -1800,6 +1836,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (gridChanged) { appState.clearCoveragePatch(); } + // An open community coverage view (cell fan-out / repeater cells) is + // tied to the old grid steps/blob and palette — clear it so it doesn't + // render stale geometry/colours over the rebuilt overlay. + _clearCoverageConnections(); _lastAppliedGridSize = prefsForOverlay.coverageGridSize; _lastAppliedCvd = prefsForOverlay.colorVisionType; } @@ -1860,6 +1900,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _styleLoaded && _focusedPingLocation == null && !_coverageDimmedForCell && + !_coverageDimmedForRepeater && _lastAppliedCoverageOpacity != null && _lastAppliedCoverageOpacity != wantedOpacity) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1988,6 +2029,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _collapseSpider(); return; // dismissing the spider shouldn't also open a cell summary } + // Safety net for a stranded isolation (active but no sheet and no pill + // keeping the selection alive). While minimized to a pill, the repeater + // stays the active selection, so leave the others hidden — don't restore. + if (_isolatedRepeaterId != null && _minimizedInfoPopup == null) { + _clearRepeaterIsolation(); // restore hidden repeaters, don't open a cell + return; + } + // Safety net for a stranded Feature A fan-out (faded repeaters + lines but + // no pill keeping the cell summary alive) — restore on empty tap. + if (_coverageHeardRepeaterIds != null && _minimizedInfoPopup == null) { + _clearCoverageConnections(); + return; + } // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer // taps through onFeatureTapped, so query the coverage layer at the tap point. _maybeShowCellSummaryAt(point, coordinates); @@ -2043,8 +2097,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Supersede a currently-minimized popup. If it was a cell, its footprint // stays until this tap's own footprint replaces it (no bright flash); if it - // was a repeater, just drop the pill. + // was a repeater, drop the pill AND restore the hidden repeaters. _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + _cellPopupActive = true; // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2060,13 +2116,44 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // highlightSpotCoverage(points)): the block fills in the dominant colour of // the in-blob pings. No pings → no footprint, no dim (web early-return). blobPointsFuture.then((pts) { - if (!mounted || !_cellSummaryShowing) return; + if (!mounted || !_cellPopupActive) return; final st = dominantCoverageStatus(pts); if (st != null) _showCellFootprint(cell, blob, st); }).catchError((Object e) { debugWarn('[COVERAGE] cell highlight failed: $e'); }); + // Feature A: fan out a dashed line from the cell centre to every UNIQUE + // repeater that heard the cell's pings (web `updateAllActiveLines` parity), + // hide the repeaters that didn't, and label each line with its distance. + // Lines are theme-aware blue (web keys off the basemap; we key off the + // Flutter theme). Read brightness before the async gap. + final fanColor = Theme.of(context).brightness == Brightness.dark + ? '#4da6ff' + : '#00008b'; + blobPointsFuture.then((pts) { + if (!mounted || !_cellPopupActive) return; + final eps = _capByFarthest( + heardEndpointsForCell(pts, lookup, + startLat: cell.centerLat, startLon: cell.centerLon), + (e) => e.distanceMeters, + 250, + '[COVERAGE] cell fan-out', + ); + final segments = [ + for (final e in eps) (lat: e.lat, lon: e.lon, color: fanColor) + ]; + _updateCoverageLines(segments, cell.centerLat, cell.centerLon); + _syncCoverageDistanceLabels( + segments, cell.centerLat, cell.centerLon, isImperial); + // Empty -> null restores all repeaters (never hide-all on an empty set). + _coverageHeardRepeaterIds = + eps.isEmpty ? null : {for (final e in eps) e.repeaterId}; + _syncRepeaterSymbols(appState); + }).catchError((Object e) { + debugWarn('[COVERAGE] cell fan-out failed: $e'); + }); + final Future summaryFuture = blobPointsFuture .then((pts) => GridSummary.fromPoints(pts, lookup)) .catchError((Object e) { @@ -2074,7 +2161,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return null; }); - _presentCellSummarySheet( + // Open minimized by default — a compact stats pill; tap it to expand to the + // full GRID SUMMARY sheet. + _minimizeCellSummary( cell: cell, blob: blob, summaryFuture: summaryFuture, @@ -2094,7 +2183,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { required bool isImperial, }) { _cellSummaryShowing = true; - final pillColor = Theme.of(context).colorScheme.primary; showModalBottomSheet( context: context, useSafeArea: true, @@ -2113,24 +2201,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _cellSummaryShowing = false; if (!mounted) return; if (result == 'minimized') { - _setMinimizedInfoPopup(_MinimizedInfoPopup( - title: 'Grid Summary', - icon: Icons.grid_on, - color: pillColor, - onReshow: () { - _clearMinimizedInfoPopup(); - _presentCellSummarySheet( - cell: cell, - blob: blob, - summaryFuture: summaryFuture, - isImperial: isImperial, - ); - }, - onClose: () { - _clearMinimizedInfoPopup(); - _clearCellHighlight(); - }, - )); + // Collapse back to the stats pill (keep the footprint). + _minimizeCellSummary( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); } else { _clearCellHighlight(); } @@ -2168,7 +2245,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Spider stays open — users frequently compare stacked repeaters back to // back, so collapsing on every selection would be annoying. if (layerId == _spiderSymbolLayerId) { - _showRepeaterDetailsById(id); + // Don't isolate from inside a spider — users compare stacked repeaters + // back-to-back, so keep the spread open and all markers visible. + _showRepeaterDetailsById(id, isolate: false); return; } @@ -2441,7 +2520,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Open the repeater detail sheet for a given [repeaterId]. Looks up the /// Repeater object from app state and recomputes the duplicate/hopOverride /// flags. Used by both direct tap dispatch and the GPS fall-through path. - void _showRepeaterDetailsById(String repeaterId) { + void _showRepeaterDetailsById(String repeaterId, {bool isolate = true}) { if (!mounted) return; final appState = context.read(); final repeater = @@ -2457,9 +2536,39 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeater, isDuplicate: isDuplicate, regionHopBytesOverride: hopOverride, + isolate: isolate, ); } + /// Restore all repeaters after a repeater isolation (web-parity "click a + /// repeater hides the rest"). No-op if nothing is isolated. No setState — + /// nothing in build() reads [_isolatedRepeaterId]; the source is pushed + /// imperatively, matching the focus/spider resyncs. + void _clearRepeaterIsolation() { + if (_isolatedRepeaterId == null) return; + _isolatedRepeaterId = null; + // Tear down any Feature B coverage cells/lines drawn for the selection and + // restore the base coverage tiles it dimmed. + _clearCoverageLines(); + _clearCoverageCells(); + _restoreCoverageBackdropForRepeater(); + if (!mounted) return; + _syncRepeaterSymbols(context.read()); + } + + /// Restore the base coverage overlay opacity dimmed by a Feature B repeater + /// coverage draw (honours ping focus, which keeps the overlay hidden at 0). + /// No-op when not dimmed. + void _restoreCoverageBackdropForRepeater() { + if (!_coverageDimmedForRepeater) return; + _coverageDimmedForRepeater = false; + if (!mounted) return; + final restore = _focusedPingLocation != null + ? 0.0 + : context.read().preferences.coverageOverlayOpacity; + _applyCoverageOverlayOpacity(restore); + } + Future _onStyleLoaded(AppStateProvider appState) async { // Re-entrance guard. iOS plugin sometimes fires onStyleLoadedCallback // multiple times during a single setStyle. The race causes "Layer not @@ -2527,6 +2636,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // The tap cell-highlight overlay is likewise gone; _addCoverageOverlay // re-installs it below. _cellHighlightReady = false; + // Community coverage line/cell layers + their tracked symbols/images are + // also gone with the style; reset so the next draw treats it as fresh. + _coverageLinesInstalled = false; + _coverageCellsInstalled = false; + _coverageHeardRepeaterIds = null; + _coverageDistanceLabelSymbols.clear(); + _registeredCoverageLabelImages.clear(); + _registeredCoverageLabelImageSizes.clear(); // Disable symbol decluttering on the annotation manager. By default, // MapLibre symbol layers hide overlapping icons/labels at lower zoom to @@ -2903,6 +3020,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// layer renders nothing, and restore the dimmed coverage backdrop. The layer /// stays installed (cheap, no churn). Future _clearCellHighlight() async { + _cellPopupActive = false; + // Tear down the Feature A fan-out (lines + distance labels) and restore any + // repeaters faded to the heard set whenever the cell summary closes. Also + // drop any Feature B cells defensively (cheap no-op when none). + _restoreFadedRepeaters(); + _clearCoverageLines(); + _clearCoverageCells(); + _clearCoverageDistanceLabels(); if (_coverageDimmedForCell) { _coverageDimmedForCell = false; // Restore the backdrop, honouring ping-focus mode (which keeps it hidden). @@ -3350,6 +3475,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final features = >[]; for (final repeater in visible) { + // Repeater isolation: while a repeater is focused/selected, hide every + // other repeater entirely (skip the feature, so they also drop out of + // cluster counts) — same approach as focus mode below. Restored on close + // by _clearRepeaterIsolation. + if (_isolatedRepeaterId != null && repeater.id != _isolatedRepeaterId) { + continue; + } + // Feature A (tile fan-out): when a tapped cell's heard-repeater set is + // active and no single repeater is isolated, hide every repeater that did + // NOT hear the cell's pings (the web fades-but-keeps; we hide, matching + // focus/isolation). Cleared by _restoreFadedRepeaters. The set holds + // lowercased ids (from RepeaterLookup), so compare lowercased. + if (_coverageHeardRepeaterIds != null && + _isolatedRepeaterId == null && + !_coverageHeardRepeaterIds!.contains(repeater.id.toLowerCase())) { + continue; + } final isDuplicate = duplicates.contains(repeater.id); final statusKey = _repeaterStatusKey(repeater, isDuplicate); final isConnected = focusActive && @@ -3657,7 +3799,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } try { - final geojson = _buildRepeaterFeatureCollection(appState); + // When repeaters are toggled off, push an empty collection so the pins + // clear without removing/recreating the styled layers (see #347). + final geojson = _showRepeaters + ? _buildRepeaterFeatureCollection(appState) + : _emptyFeatureCollection(); // Detailed mode references baked per-chip images — make sure every one is // registered before pushing, or the symbol layer would reference a // missing icon. No-op in Simplified (features use the pre-registered @@ -4343,6 +4489,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; static const _focusLinesAmbiguousLabelId = 'focus-lines-ambiguous-label'; + // Source/layer IDs for the community coverage connection lines (shared by the + // tile fan-out and the repeater coverage view — mutually exclusive in time, + // per-feature `color`) and the repeater coverage cells (per-feature fill). + static const _coverageLinesSourceId = 'coverage-lines-source'; + static const _coverageLinesLayerId = 'coverage-lines-layer'; + static const _coverageCellsSourceId = 'coverage-cells-source'; + static const _coverageCellsLayerId = 'coverage-cells-layer'; + /// Builds and applies the focus-mode dotted polylines that visually connect /// a focused ping to each repeater that heard it. Color-coded by SNR; /// ambiguous matches get a wider white outline drawn underneath. @@ -4507,6 +4661,330 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // =========================================================================== + // Community coverage connection lines + cells (Features A & B) + // =========================================================================== + + /// Volume cap shared by both coverage features (user choice: keep the + /// FARTHEST). When [items] exceeds [cap], sorts by [dist] DESCENDING and keeps + /// the first [cap] (so the longest-reach lines/cells always survive); logs the + /// truncation. Returns [items] unchanged when within the cap. + List _capByFarthest( + List items, double Function(T) dist, int cap, String logTag) { + if (items.length <= cap) return items; + final sorted = [...items]..sort((a, b) => dist(b).compareTo(dist(a))); + debugLog('$logTag truncated ${items.length}->$cap (kept farthest)'); + return sorted.take(cap).toList(); + } + + /// Idempotently (re)create the empty coverage connection-line layer + /// (data-driven dashed lines, below the repeater chips). Modeled on + /// [_ensureCellHighlightLayer]. + Future _ensureCoverageLinesLayer() async { + if (_coverageLinesInstalled) return true; + if (_mapController == null) return false; + try { + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + try { + await _mapController!.removeLayer(_coverageLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_coverageLinesSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_coverageLinesSourceId, _emptyFeatureCollection()); + await _mapController!.addLineLayer( + _coverageLinesSourceId, + _coverageLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + lineWidth: 2.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + _coverageLinesInstalled = true; + return true; + } catch (e) { + debugLog('[COVERAGE] coverage-lines layer create failed: $e'); + return false; + } + } + + /// Idempotently (re)create the empty coverage cells fill layer (Feature B), + /// anchored BELOW the lines layer so the lines read on top of the cells. + Future _ensureCoverageCellsLayer() async { + if (_coverageCellsInstalled) return true; + if (_mapController == null) return false; + try { + final belowLayer = _coverageLinesInstalled + ? _coverageLinesLayerId + : (_clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId()); + try { + await _mapController!.removeLayer(_coverageCellsLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_coverageCellsSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_coverageCellsSourceId, _emptyFeatureCollection()); + await _mapController!.addFillLayer( + _coverageCellsSourceId, + _coverageCellsLayerId, + const FillLayerProperties( + fillColor: ['get', 'fill'], + fillOutlineColor: ['get', 'border'], + fillOpacity: 0.8, + ), + belowLayerId: belowLayer, + ); + _coverageCellsInstalled = true; + return true; + } catch (e) { + debugLog('[COVERAGE] coverage-cells layer create failed: $e'); + return false; + } + } + + /// Draw one dashed connection line per [segments] entry from + /// ([startLat],[startLon]) out to each segment's endpoint, coloured by the + /// segment's `color` (blue for the tile fan-out, status colour for a repeater). + /// Empty list clears the layer. + Future _updateCoverageLines( + List<({double lat, double lon, String color})> segments, + double startLat, + double startLon) async { + if (_mapController == null || !_styleLoaded) return; + if (segments.isEmpty) { + await _clearCoverageLines(); + return; + } + if (!await _ensureCoverageLinesLayer()) return; + final features = >[ + for (final s in segments) + { + 'type': 'Feature', + 'properties': {'color': s.color}, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [startLon, startLat], + [s.lon, s.lat], + ], + }, + }, + ]; + try { + await _mapController!.setGeoJsonSource(_coverageLinesSourceId, + {'type': 'FeatureCollection', 'features': features}); + } catch (e) { + debugLog('[COVERAGE] coverage-lines set failed: $e'); + } + } + + /// Empty the coverage-lines source so it renders nothing (layer stays). + Future _clearCoverageLines() async { + if (_mapController == null || !_coverageLinesInstalled) return; + try { + await _mapController! + .setGeoJsonSource(_coverageLinesSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] coverage-lines clear failed: $e'); + } + } + + /// Draw the selected repeater's coverage [cells] as status-coloured fills + /// (Feature B). [latStep]/[lonStep] size each cell's ring. Empty list clears. + Future _updateCoverageCells(List cells, String cvd, + double latStep, double lonStep) async { + if (_mapController == null || !_styleLoaded) return; + if (cells.isEmpty) { + await _clearCoverageCells(); + return; + } + if (!await _ensureCoverageCellsLayer()) return; + final features = >[]; + for (final c in cells) { + final colors = CoverageTilePalette.colorsForStatus(cvd, c.st); + final ring = GridCell(c.li, c.lj, latStep, lonStep).blockRing(0); + features.add({ + 'type': 'Feature', + 'properties': {'fill': colors[0], 'border': colors[1]}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } + try { + await _mapController!.setGeoJsonSource(_coverageCellsSourceId, + {'type': 'FeatureCollection', 'features': features}); + } catch (e) { + debugLog('[COVERAGE] coverage-cells set failed: $e'); + } + } + + /// Empty the coverage-cells source so it renders nothing (layer stays). + Future _clearCoverageCells() async { + if (_mapController == null || !_coverageCellsInstalled) return; + try { + await _mapController! + .setGeoJsonSource(_coverageCellsSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] coverage-cells clear failed: $e'); + } + } + + /// Place a distance-pill label at the midpoint of each coverage fan-out line + /// (Feature A). Mirrors [_syncDistanceLabels] but with its own tracking keyed + /// by endpoint lat/lon (6dp). Empty list removes all coverage labels. + Future _syncCoverageDistanceLabels( + List<({double lat, double lon, String color})> segments, + double startLat, + double startLon, + bool isImperial) async { + if (_mapController == null || !_styleLoaded) return; + if (segments.isEmpty) { + await _clearCoverageDistanceLabels(); + return; + } + final wanted = {}; + for (final s in segments) { + final key = '${s.lat.toStringAsFixed(6)},${s.lon.toStringAsFixed(6)}'; + wanted.add(key); + final midLat = (startLat + s.lat) / 2; + final midLon = (startLon + s.lon) / 2; + final meters = + GpsService.distanceBetween(startLat, startLon, s.lat, s.lon); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + final imageName = 'cov-dist-${labelText.hashCode}'; + if (!_registeredCoverageLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredCoverageLabelImages.add(imageName); + _registeredCoverageLabelImageSizes[imageName] = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(coverage label) failed: $e'); + } + } + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + ); + final existing = _coverageDistanceLabelSymbols[key]; + if (existing == null) { + try { + _coverageDistanceLabelSymbols[key] = await _mapController! + .addSymbol(options, {'kind': 'cov-distance'}); + } catch (e) { + debugError('[MAP] addSymbol(coverage distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(coverage distance) failed: $e'); + } + } + } + final toRemove = _coverageDistanceLabelSymbols.keys + .where((k) => !wanted.contains(k)) + .toList(); + for (final k in toRemove) { + final sym = _coverageDistanceLabelSymbols.remove(k); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// Remove all coverage distance-label pills. + Future _clearCoverageDistanceLabels() async { + if (_mapController == null) return; + final toRemove = List.of(_coverageDistanceLabelSymbols.values); + _coverageDistanceLabelSymbols.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + + /// Restore all repeaters hidden by a Feature A fan-out (clears the heard set + /// and re-pushes the repeater source). No-op when no fade is active. No + /// setState — the source is pushed imperatively, like [_clearRepeaterIsolation]. + void _restoreFadedRepeaters() { + if (_coverageHeardRepeaterIds == null) return; + _coverageHeardRepeaterIds = null; + if (!mounted) return; + _syncRepeaterSymbols(context.read()); + } + + /// Tear down ALL community coverage visuals (lines, cells, labels, fade). + /// The single funnel used by every entry/exit path. + Future _clearCoverageConnections() async { + _restoreFadedRepeaters(); + _restoreCoverageBackdropForRepeater(); + await _clearCoverageLines(); + await _clearCoverageCells(); + await _clearCoverageDistanceLabels(); + } + + /// Feature B draw: render [matched] (the points that heard [repeater]) as + /// deduped status-coloured coverage cells plus a status-coloured dashed line + /// from the repeater to each cell centre. Caps at the farthest 250. The lines + /// layer is ensured BEFORE the cells layer so the lines read on top. + Future _drawRepeaterCoverage(Repeater repeater, + List> matched, String cvd, int gridSize) async { + final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; + final blob = gridSize == 100 ? 1 : 0; + // High cap so a repeater's full footprint draws (web parity — it draws all). + // Still bounded (farthest-first) to protect against pathological volumes. + final cells = _capByFarthest( + repeaterCoverageCells(matched, repeater, + latStep: steps[0], lonStep: steps[1], blob: blob), + (c) => c.distanceMeters, + 5000, + '[COVERAGE] repeater coverage', + ); + if (cells.isEmpty) { + await _clearCoverageCells(); + await _clearCoverageLines(); + return; + } + await _ensureCoverageLinesLayer(); + await _updateCoverageCells(cells, cvd, steps[0], steps[1]); + final segments = [ + for (final c in cells) + ( + lat: c.centerLat, + lon: c.centerLon, + color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], + ), + ]; + await _updateCoverageLines(segments, repeater.lat, repeater.lon); + // Dim the base coverage tiles so the repeater's coloured cells + lines pop + // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in + // _clearRepeaterIsolation. Skip while ping focus already hid the overlay. + if (_focusedPingLocation == null) { + _coverageDimmedForRepeater = true; + await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); + } + } + /// Rebuilds the regional boundary layer from `appState.regionBorders`. /// Always-on: renders whenever polygons are present, independent of BLE /// or auth state. Idempotent — safe to call repeatedly (removes existing @@ -5152,86 +5630,123 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return PingColors.signalBad; } - /// Map controls (always vertical, used inside collapsible wrapper) + /// Map controls. Single vertical column in portrait; in landscape the taller + /// set is split into two columns so the lower icons don't run off the short + /// viewport (hidden under the bottom edge) — see issue #329 follow-up. Widget _buildMapControls(AppStateProvider appState) { final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + // Collect the active control buttons (conditionals preserved); dividers are + // interleaved later by _stackControlButtons so the two-column split is clean. + final buttons = [ + // Map style toggle + _buildControlButton( + icon: mapStyle.icon, + tooltip: 'Map Style: ${mapStyle.label}', + onPressed: () => _cycleMapStyle(appState), + ), + // MeshMapper overlay toggle (only show when zone code available) + if (appState.zoneCode != null) + _buildControlButton( + icon: Icons.layers, + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', + onPressed: _toggleMeshMapperOverlay, + isActive: _showMeshMapperOverlay, + ), + // Repeater pins toggle (only show when repeaters are present) + if (appState.repeaters.isNotEmpty) + _buildControlButton( + icon: Icons.cell_tower, + tooltip: _showRepeaters ? 'Hide Repeaters' : 'Show Repeaters', + onPressed: () => _toggleRepeaters(appState), + isActive: _showRepeaters, + ), + // Region boundary toggle (only show when borders available) + if (appState.regionBorders.isNotEmpty) + _buildControlButton( + icon: Icons.fence, + tooltip: _showRegionBorders + ? 'Hide Region Boundary' + : 'Show Region Boundary', + onPressed: () => _toggleRegionBorders(appState), + isActive: _showRegionBorders, + ), + // Center on position / toggle auto-follow + _buildControlButton( + icon: _autoFollow ? Icons.my_location : Icons.location_searching, + tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, + isActive: _autoFollow, + ), + // Always North toggle + _buildControlButton( + icon: _alwaysNorth ? Icons.navigation : Icons.explore, + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', + onPressed: _toggleNorthMode, + isActive: !_alwaysNorth, + ), + // Rotation lock toggle + _buildControlButton( + icon: _rotationLocked ? Icons.sync_disabled : Icons.rotate_right, + tooltip: _rotationLocked ? 'Unlock Rotation' : 'Lock Rotation', + onPressed: _toggleRotationLock, + isActive: _rotationLocked, + ), + // Legend button + _buildControlButton( + icon: Icons.info_outline, + tooltip: 'Legend & Info', + onPressed: _showLegendPopup, + ), + ]; + + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + + final Widget body; + if (isLandscape && buttons.length > 4) { + // Split into two side-by-side columns so the set fits vertically. + final half = (buttons.length / 2).ceil(); + body = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _stackControlButtons(buttons.sublist(0, half)), + Container(width: 1, color: Colors.white24), + _stackControlButtons(buttons.sublist(half)), + ], + ); + } else { + body = _stackControlButtons(buttons); + } + return Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), // Controls are below the toggle button, so rounded bottom only borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Map style toggle - _buildControlButton( - icon: mapStyle.icon, - tooltip: 'Map Style: ${mapStyle.label}', - onPressed: () => _cycleMapStyle(appState), - ), - // MeshMapper overlay toggle (only show when zone code available) - if (appState.zoneCode != null) ...[ - _buildControlDivider(), - _buildControlButton( - icon: Icons.layers, - tooltip: _showMeshMapperOverlay - ? 'Hide Coverage Overlay' - : 'Show Coverage Overlay', - onPressed: _toggleMeshMapperOverlay, - isActive: _showMeshMapperOverlay, - ), - ], - // Region boundary toggle (only show when borders available) - if (appState.regionBorders.isNotEmpty) ...[ - _buildControlDivider(), - _buildControlButton( - icon: Icons.fence, - tooltip: _showRegionBorders - ? 'Hide Region Boundary' - : 'Show Region Boundary', - onPressed: () => _toggleRegionBorders(appState), - isActive: _showRegionBorders, - ), - ], - _buildControlDivider(), - // Center on position / toggle auto-follow - _buildControlButton( - icon: _autoFollow ? Icons.my_location : Icons.location_searching, - tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: - appState.currentPosition != null ? _centerOnPosition : null, - isActive: _autoFollow, - ), - _buildControlDivider(), - // Always North toggle - _buildControlButton( - icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth - ? 'Always North (Click to Rotate with Heading)' - : 'Rotating with Heading (Click for Always North)', - onPressed: _toggleNorthMode, - isActive: !_alwaysNorth, - ), - _buildControlDivider(), - // Rotation lock toggle - _buildControlButton( - icon: _rotationLocked ? Icons.sync_disabled : Icons.rotate_right, - tooltip: _rotationLocked ? 'Unlock Rotation' : 'Lock Rotation', - onPressed: _toggleRotationLock, - isActive: _rotationLocked, - ), - _buildControlDivider(), - // Legend button - _buildControlButton( - icon: Icons.info_outline, - tooltip: 'Legend & Info', - onPressed: _showLegendPopup, - ), - ], - ), + child: body, + ); + } + + /// Stack control buttons vertically with a divider between each (no leading or + /// trailing divider), matching the original single-column look. + Widget _stackControlButtons(List buttons) { + final children = []; + for (var i = 0; i < buttons.length; i++) { + if (i > 0) children.add(_buildControlDivider()); + children.add(buttons[i]); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: children, ); } @@ -5349,6 +5864,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + void _toggleRepeaters(AppStateProvider appState) { + setState(() { + _showRepeaters = !_showRepeaters; + }); + // Re-push the repeater source: real features when shown, empty when hidden. + _syncRepeaterSymbols(appState); + } + void _removeRegionBorders() { if (_mapController == null) return; try { @@ -6492,6 +7015,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); if (located.isEmpty) return; + // Session ping-focus and the community coverage views are distinct gestures; + // clear any open community fan-out / repeater cells (and their pill/isolation) + // so the two don't stack on the map. + _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + _clearCoverageConnections(); + // Only save pre-focus state on first activation. When re-activating // (e.g. user taps a different ping while already in focus, or expanding // from minimized), we keep the original pre-focus snapshot so dismiss @@ -6641,15 +7171,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X /// closes it and runs its cleanup. Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { + final theme = Theme.of(context); return GestureDetector( onTap: popup.onReshow, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + color: theme.colorScheme.outline.withValues(alpha: 0.5), ), boxShadow: [ BoxShadow( @@ -6659,47 +7190,229 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(popup.icon, color: popup.color, size: 18), - const SizedBox(width: 8), - Flexible( - child: Text( - popup.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, + // Row 1: identity + expand/close controls. + Row( + children: [ + Expanded(child: popup.title), + const SizedBox(width: 8), + GestureDetector( + onTap: popup.onReshow, + child: Icon(Icons.keyboard_arrow_up, + size: 20, color: theme.colorScheme.onSurfaceVariant), ), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: popup.onReshow, - child: Icon( - Icons.keyboard_arrow_up, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: popup.onClose, - child: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + const SizedBox(width: 8), + GestureDetector( + onTap: popup.onClose, + child: Icon(Icons.close, + size: 18, color: theme.colorScheme.onSurfaceVariant), + ), + ], ), + const SizedBox(height: 8), + // Row 2: stat chips (wraps if the row is too narrow). + popup.statsBuilder(context), ], ), ), ); } + /// One icon+value stat chip for the minimized pill's row 2. [iconColor] tints + /// the icon; [valueColor] tints the value (defaults to onSurface). + Widget _pillStat(IconData icon, String value, + {Color? iconColor, Color? valueColor}) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + size: 14, color: iconColor ?? theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 3), + Text( + value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: valueColor ?? theme.colorScheme.onSurface, + ), + ), + ], + ); + } + + // Web GRID SUMMARY / repeater palette colours reused for the pill chips. + static const Color _pillGood = Color(0xFF1E7E34); + static const Color _pillBad = Color(0xFFBD2130); + static const Color _pillDistBlue = Color(0xFF007BFF); + + /// Row-1 identity for the cell pill: grid icon + "Grid Summary". + Widget _cellPillTitle() { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.grid_on, size: 16, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text('Grid Summary', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface)), + ], + ); + } + + /// Row-2 stats for the cell pill: Max Dist · Avg SNR · Avg Noise · Total Pings. + Widget _cellPillStats( + BuildContext context, Future summaryFuture, bool isImperial) { + return FutureBuilder( + future: summaryFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final s = snap.data; + final dist = loading + ? '…' + : (s?.maxDistMeters != null + ? formatCoverageDistance(s!.maxDistMeters!, isImperial: isImperial) + : 'N/A'); + final noise = + loading ? '…' : (s?.avgNoise != null ? '${s!.avgNoise} dBm' : 'N/A'); + final total = loading ? '…' : '${s?.total ?? 0}'; + return Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _pillStat(Icons.straighten, dist, iconColor: _pillDistBlue), + _snrPillStat(loading ? null : s), + _pillStat(Icons.graphic_eq, noise), + _pillStat(Icons.tag, total), + ], + ); + }, + ); + } + + /// Avg-SNR pill chip — a signal-bar icon coloured by quality bucket + the + /// averaged value (mirrors CellSummarySheet._snrCell). [s] null = still loading. + Widget _snrPillStat(GridSummary? s) { + final bucket = s?.snrBucket; + if (s == null || bucket == null || s.avgSnr == null) { + return _pillStat(Icons.signal_cellular_alt, s == null ? '…' : 'N/A'); + } + final IconData icon; + final Color color; + switch (bucket) { + case 'good': + icon = Icons.signal_cellular_4_bar; + color = _pillGood; + break; + case 'medium': + icon = Icons.signal_cellular_alt; + color = const Color(0xFF856404); + break; + default: + icon = Icons.signal_cellular_alt_1_bar; + color = _pillBad; + } + return _pillStat(icon, s.avgSnr!.toStringAsFixed(1), + iconColor: color, valueColor: color); + } + + /// Row-1 identity for the repeater pill: a status-coloured dot + the name. + Widget _repeaterPillTitle(Repeater repeater, Color statusColor) { + final theme = Theme.of(context); + return Row( + children: [ + // Status as a coloured tower (green = online, grey = stale/offline, plus + // the new/ambiguous palette colours). Carries the status that used to be + // a word in the stats row, so that row now fits on a single line. + Icon(Icons.cell_tower, size: 16, color: statusColor), + const SizedBox(width: 8), + Flexible( + child: Text( + repeater.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface), + ), + ), + ], + ); + } + + /// Row-2 stats for the repeater pill: Max Range · Hop Bytes · Clock Sync · + /// Last Heard. (Online/offline status is the coloured tower in the title.) + /// Max Range comes from the lazy [statsFuture]. + Widget _repeaterPillStats( + BuildContext context, + Repeater repeater, + Future statsFuture, + String? clockSkew, + bool isImperial) { + final clockOk = clockSkew == null; + final lastHeard = repeater.lastHeard > 0 ? daysAgo(repeater.lastHeard) : 'N/A'; + return FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : 'N/A'); + return Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _pillStat(Icons.open_in_full, range, iconColor: _pillDistBlue), + _pillStat(Icons.swap_horiz, '${repeater.hopBytes}B'), + _pillStat(Icons.schedule, clockOk ? 'Synced' : 'Off', + iconColor: clockOk ? _pillGood : _pillBad, + valueColor: clockOk ? _pillGood : _pillBad), + _pillStat(Icons.history, lastHeard), + ], + ); + }, + ); + } + + /// Show the cell GRID SUMMARY as a minimized pill (the default a tile tap + /// opens in). Tapping it expands to the full sheet; closing clears the + /// footprint. Reused on the initial tap and when the sheet is minimized. + void _minimizeCellSummary({ + required GridCell cell, + required int blob, + required Future summaryFuture, + required bool isImperial, + }) { + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: _cellPillTitle(), + statsBuilder: (ctx) => _cellPillStats(ctx, summaryFuture, isImperial), + onReshow: () { + _clearMinimizedInfoPopup(); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + }, + )); + } + Widget _buildMinimizedFocusPanel() { final source = _focusedPingSource; String title; @@ -8171,11 +8884,32 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - /// Show repeater details popup + /// Show repeater details. Opens minimized as a stats pill by default; tapping + /// the pill re-enters with [expand] true to show the full detail sheet, and + /// the sheet's minimize re-enters with [expand] false. [cachedStats] carries + /// the lazily-fetched stats across those toggles so they aren't re-fetched. void _showRepeaterDetails(Repeater repeater, - {bool isDuplicate = false, int? regionHopBytesOverride}) { + {bool isDuplicate = false, + int? regionHopBytesOverride, + bool isolate = true, + bool expand = false, + Future? cachedStats}) { // Supersede any currently-minimized popup (clears a prior cell footprint/dim). + // No-op on the internal pill<->sheet re-entries (nothing is minimized then). _minimizedInfoPopup?.onClose(); + // Drop any lingering Feature A cell fan-out fade so it doesn't reappear when + // this repeater's isolation is later cleared (the fade is suppressed while a + // repeater is isolated, but the set would otherwise linger). + _restoreFadedRepeaters(); + + // Focus this repeater: hide every OTHER repeater while its detail sheet/pill + // is open (web setSoloCircle parity) and track it as the active selection + // (Feature B draw guard + teardown). Restored by _clearRepeaterIsolation on + // sheet/pill close or empty-map tap. + if (isolate && _isolatedRepeaterId != repeater.id) { + _isolatedRepeaterId = repeater.id; + _syncRepeaterSymbols(context.read()); + } // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -8206,14 +8940,29 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, hopBytes: appState.effectiveHopBytes); final isImperial = appState.preferences.isImperial; - final Future statsFuture = appState - .fetchRepeaterCoveragePoints(prefix: repeater.id) - .then( - (pts) => RepeaterStats.fromCoverage(pts, repeater, lookup)) - .catchError((Object e) { - debugWarn('[COVERAGE] repeater stats failed: $e'); - return null; - }); + final cvd = appState.preferences.colorVisionType; + final gridSize = appState.preferences.coverageGridSize; + final Future statsFuture = cachedStats ?? + appState + .fetchRepeaterCoveragePoints(prefix: repeater.id) + .then((pts) { + final res = + RepeaterStats.fromCoverageWithPoints(pts, repeater, lookup); + // Feature B: draw this repeater's coverage cells + status-coloured + // connection lines (web `drawRepeaterCoverageFromCache` parity). Only + // on the first fetch (cachedStats == null reaches here), and only if + // this repeater is still the isolated selection — guards a fast + // repeater switch during the network fetch — and has a known location. + if (mounted && + _isolatedRepeaterId == repeater.id && + repeater.hasLocation) { + _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize); + } + return res.stats; + }).catchError((Object e) { + debugWarn('[COVERAGE] repeater stats failed: $e'); + return null; + }); final fingerprintShort = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); @@ -8222,6 +8971,30 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : repeater.hexId.toUpperCase(); final clockSkew = humanizeClockSkew(repeater.timeOffset); + // Open minimized by default — a compact stats pill; tap it to expand to the + // full detail sheet. Isolation (others hidden) persists across pill<->sheet. + if (!expand) { + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: _repeaterPillTitle(repeater, statusColor), + statsBuilder: (ctx) => _repeaterPillStats( + ctx, repeater, statsFuture, clockSkew, isImperial), + onReshow: () { + _clearMinimizedInfoPopup(); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: true, + cachedStats: statsFuture); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + }, + )); + return; + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -8234,12 +9007,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { builder: (context) => Container( padding: EdgeInsets.fromLTRB( 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge (containing ID) and name - Row( + // Scrollable so the content can't overflow on shorter screens — this + // (non-scroll-controlled) bottom sheet caps height to ~9/16 of the + // screen, and the detail card is occasionally taller than that. + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge (containing ID) and name + Row( children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { @@ -8375,22 +9152,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Text(formatDateWithAgo(repeater.lastHeard), style: const TextStyle(fontSize: 13)), ), - // Clock-skew warning - if (clockSkew != null) ...[ + // Clock-skew warning — single compact row, e.g. "Clock is + // 1.2 days ahead" (was two rows / a long sentence). + if (clockSkew != null) _repRow( context, Icons.warning_amber_rounded, - const Text('Repeater time is not set correctly', - style: TextStyle(fontSize: 13)), - color: Colors.red.shade400, - ), - _repRow( - context, - null, - Text(clockSkew, style: const TextStyle(fontSize: 13)), + Text('Clock is $clockSkew', + style: const TextStyle(fontSize: 13)), color: Colors.red.shade400, ), - ], // First heard if (repeater.createdAt != null) _repRow( @@ -8450,22 +9221,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), + ), ), ).then((result) { if (!mounted) return; if (result == 'minimized') { - _setMinimizedInfoPopup(_MinimizedInfoPopup( - title: repeater.name, - icon: Icons.cell_tower, - color: iconColor, - onReshow: () { - _clearMinimizedInfoPopup(); - _showRepeaterDetails(repeater, - isDuplicate: isDuplicate, - regionHopBytesOverride: regionHopBytesOverride); - }, - onClose: _clearMinimizedInfoPopup, - )); + // Collapse back to the stats pill. The selection (and its coverage + // cells/lines + tile dim) persists throughout pill<->sheet; it's torn + // down only on a real close. Reuse the already-fetched stats so the pill + // doesn't re-fetch (and doesn't redraw the coverage). + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: false, + cachedStats: statsFuture); + } else { + // Dismissed for real (X / swipe / barrier tap): clear the coverage + // cells/lines and restore the dimmed coverage tiles. + _clearRepeaterIsolation(); } }); } From 85bbb353dbd325f4022739c21f71823b2a8f83ce Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 08:18:00 -0400 Subject: [PATCH 150/360] Coverage tap-to-inspect fixes + tweaks - Fix dead taps at z>=15 on freshly-pinged cells: _handleFeatureTap now treats the session patch layer (and the footprint highlight / repeater coverage cells) as a coverage-cell tap, not just the base overlay. Fallback hit-test queries the patch layer too, with a small rect so tiny cells stay tappable. - Ignore cell taps below zoom 12 (cells are sub-pixel when zoomed out, so taps are almost always accidental). - Repeater pill: online/offline shown as a coloured tower icon in the title so the stats row fits one line. - Repeater coverage cap raised 250 -> 5000 so the full footprint draws (web parity). --- lib/widgets/map_widget.dart | 47 +++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 163647b9..3502e8aa 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -586,6 +586,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _showCellFootprint and _clearCellHighlight; gates the opacity restore and // suppresses the build-method opacity-sync (which would otherwise un-dim). bool _coverageDimmedForCell = false; + // Below this zoom, coverage cells are only a pixel or two wide, so a tile tap + // is almost always accidental — ignore cell taps when zoomed further out (a + // 300 m cell is ~8 px at z11, ~16 px at z12). Tune if needed. + static const double _kMinCellTapZoom = 12.0; // Same backdrop dim while a selected repeater's coverage cells/lines are shown // (Feature B, web `drawRepeaterCoverageFromCache` parity). True between // _drawRepeaterCoverage and _clearRepeaterIsolation; gates the opacity restore @@ -2047,15 +2051,26 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _maybeShowCellSummaryAt(point, coordinates); } - /// Hit-test the active coverage fill layer at [point]; if a cell is there, + /// Hit-test the active coverage fill layer around [point]; if a cell is there, /// open its GRID SUMMARY. The onMapClick fallback to _handleFeatureTap. Future _maybeShowCellSummaryAt( math.Point point, LatLng coordinates) async { final layerId = _activeCoverageLayerId; if (layerId == null || _mapController == null) return; try { + // Hit-test a small BOX around the tap, not the exact pixel: at low zoom a + // coverage cell is only a pixel or two wide (a 300 m cell ≈ 2 px at z9), so + // an exact-point query nearly always lands in a gap and the tap "doesn't + // register". A ~30 px box makes tiles tappable at any zoom. + const pad = 15.0; + final rect = + Rect.fromLTWH(point.x - pad, point.y - pad, pad * 2, pad * 2); + // Include the session patch layer (the user's own freshly-pinged cells, + // drawn on top of the base) so tapping a patched cell still hit-tests. + final layers = [layerId]; + if (_patchLayerReady) layers.add(_patchLayerId); final features = - await _mapController!.queryRenderedFeatures(point, [layerId], null); + await _mapController!.queryRenderedFeaturesInRect(rect, layers, null); if (!mounted || features.isEmpty) return; _showCellSummary(coordinates); } catch (e) { @@ -2068,6 +2083,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// mirroring the web cell-click popup (minus PING HISTORY). void _showCellSummary(LatLng coordinates) { if (!mounted || _cellSummaryShowing) return; + // Don't open a cell summary when zoomed too far out — cells are barely + // visible there, so taps are almost always accidental. Both tap paths + // (_handleFeatureTap + _maybeShowCellSummaryAt) funnel here, so this one + // guard covers them and also skips the network fetch below. + final zoom = _mapController?.cameraPosition?.zoom ?? 0; + if (zoom < _kMinCellTapZoom) { + debugLog('[COVERAGE] cell tap ignored — zoom ' + '${zoom.toStringAsFixed(2)} < $_kMinCellTapZoom'); + return; + } final appState = context.read(); final zone = appState.zoneCode; if (zone == null || zone.isEmpty) return; @@ -2302,11 +2327,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } - // Coverage cell: open the GRID SUMMARY. Coverage is a fill layer below the - // repeaters, so reaching it means no repeater/cluster was hit. Platforms that - // don't dispatch fill-layer taps here fall back to the queryRenderedFeatures - // hit-test in _onMapEmptyTap. - if (layerId == _activeCoverageLayerId) { + // Coverage cell: open the GRID SUMMARY. Coverage fills sit below the + // repeaters, so reaching one means no repeater/cluster was hit. Accept ALL + // coverage fill layers, not just the base overlay: the session patch layer + // (the user's own freshly-pinged cells) renders ON TOP of the base, so a tap + // on a patched cell — common when zoomed in near where you've wardriven — + // returns the patch layer id and would otherwise be a dead tap. The tapped + // footprint highlight and the repeater coverage cells are likewise tappable. + // Platforms that don't dispatch fill-layer taps fall back to the + // queryRenderedFeatures hit-test in _onMapEmptyTap. + if (layerId == _activeCoverageLayerId || + layerId == _patchLayerId || + layerId == _cellHighlightLayerId || + layerId == _coverageCellsLayerId) { _showCellSummary(coordinates); return; } From f3277e146ad18da9a54005303d71a354f36d9c81 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 14:34:45 -0400 Subject: [PATCH 151/360] Unify focus camera across coverage views; fix spider in Detailed Mode - Shared focus-camera lifecycle (_enterFocusCamera/_exitFocusCamera/ _fitCameraToPoints/_exitFocusCameraIfDone) so ping focus, tile (Feature A) and repeater (Feature B) coverage views share one save/restore of the user's camera, follow and rotation. - Spider spread-marker tap now collapses the spider and focuses that repeater (focus mode + its coverage cells), matching the GPS fall-through path (was: minimized pill only, no coverage). - Detailed Grid Mode: tapping a stacked pile spiders out at any zoom (un-clustered, no cluster-bubble zoom-in path); spider markers render baked chips so the hex label shows instead of an empty box. --- lib/widgets/map_widget.dart | 750 ++++++++++++++++++++++++------------ 1 file changed, 513 insertions(+), 237 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3502e8aa..77071fae 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -522,6 +522,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { double? _preFocusZoom; bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active + // True while ANY focus-style camera is engaged (ping focus OR a community + // tile/repeater coverage view). The pre-focus snapshot above is saved ONCE on + // the first entry and restored ONCE when the last view closes, so switching + // directly between views animates only once and restores to the original view. + bool _cameraFocusActive = false; bool _focusPanelMinimized = false; dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry @@ -1020,26 +1025,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - /// Zoom to fit a focused ping and its connected repeaters on screen - void _zoomToFocusBounds( - LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + // =========================================================================== + // Shared focus-camera lifecycle (ping focus + tile/repeater coverage views) + // =========================================================================== + + /// Animate the camera to fit [points] on screen (north-up, with room for the + /// bottom sheet/pill). No-op with fewer than 2 valid points (nothing to frame). + void _fitCameraToPoints(List points) { if (_mapController == null || !_isMapReady || !mounted || !_canAnimateCamera) { return; } + final valid = points + .where((p) => isValidLatLng(p.latitude, p.longitude)) + .toList(); + if (valid.length < 2) return; - final points = [ - pingLocation, - ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) - ].where((p) => isValidLatLng(p.latitude, p.longitude)).toList(); - if (points.length < 2) return; - - // Build bounding box from all points - double minLat = points[0].latitude, maxLat = points[0].latitude; - double minLon = points[0].longitude, maxLon = points[0].longitude; - for (final p in points) { + double minLat = valid[0].latitude, maxLat = valid[0].latitude; + double minLon = valid[0].longitude, maxLon = valid[0].longitude; + for (final p in valid) { if (p.latitude < minLat) minLat = p.latitude; if (p.latitude > maxLat) maxLat = p.latitude; if (p.longitude < minLon) minLon = p.longitude; @@ -1058,6 +1064,76 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } + /// Engage the shared focus camera: save the user's current camera / auto-follow + /// / rotation ONCE (so a switch between focus views keeps the original + /// snapshot), then lock north-up and stop following. Idempotent — a no-op when + /// a focus camera is already active. Camera/follow/rotation ONLY: it does NOT + /// touch the coverage overlay opacity or [isFocusModeActive] (each mode owns + /// those). + void _enterFocusCamera() { + if (_cameraFocusActive) return; + _cameraFocusActive = true; + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + if (_autoFollow) _autoFollow = false; + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north instantly so the zoom-to-fit view is stable. + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } + } + } + + /// Disengage the shared focus camera: animate back to the saved center/zoom and + /// restore auto-follow / rotation (after the zoom-back settles). Idempotent. + void _exitFocusCamera() { + if (!_cameraFocusActive) return; + _cameraFocusActive = false; + final center = _preFocusCenter; + final zoom = _preFocusZoom; + final restoreFollow = _wasAutoFollowBeforeFocus && !_autoFollow; + final restoreRot = _wasRotatingBeforeFocus && _alwaysNorth; + if (center != null && zoom != null) { + _animateToPositionWithZoom(center, zoom); + // Defer follow/rotation restore so they don't clobber the zoom-back + // animation mid-flight (both share the animation controller). + if (restoreFollow || restoreRot) { + Future.delayed(const Duration(milliseconds: 550), () { + if (!mounted) return; + setState(() { + if (restoreFollow) _autoFollow = true; + if (restoreRot) _alwaysNorth = false; + }); + }); + } + } else { + setState(() { + if (restoreFollow) _autoFollow = true; + if (restoreRot) _alwaysNorth = false; + }); + } + } + + /// True while any focus view (ping focus, tile coverage, repeater coverage) is + /// open. Drives [_exitFocusCameraIfDone] so a view switch (which clears the old + /// view AFTER the new one is claimed) keeps the camera engaged. + bool get _anyFocusViewActive => + _focusedPingLocation != null || + _cellPopupActive || + _isolatedRepeaterId != null; + + /// Restore the shared focus camera only when no focus view remains open. + void _exitFocusCameraIfDone() { + if (!_anyFocusViewActive) _exitFocusCamera(); + } + /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { @@ -1571,17 +1647,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), - // Minimized focus panel pill — shown when user minimizes a ping - // details sheet. Not a modal, so the map underneath stays fully - // interactable (zoom, pan, rotation). - if (_focusPanelMinimized && _focusedPingLocation != null) + // Minimized focus panel pill — the DEFAULT view for a ping tap (and what + // the full sheet collapses to). Gated on _focusedPingSource (not + // _focusedPingLocation) so a "missed" ping — heard by nobody, so no focus + // location/camera — still shows its pill. Full-width like the info pill so + // its 2-row layout has room. Not a modal: the map stays interactable. + if (_focusPanelMinimized && _focusedPingSource != null) Positioned( bottom: 16 + MediaQuery.of(context).padding.bottom, left: 16, right: 16, - child: Center( - child: _buildMinimizedFocusPanel(), - ), + child: _buildMinimizedFocusPanel(), ), // Minimized cell-summary / repeater-detail pill — same idea as the focus @@ -1594,8 +1670,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildMinimizedInfoPill(_minimizedInfoPopup!), ), - // History session pill (bottom, styled like minimized focus panel) - if (appState.viewingHistorySession) + // History session pill (bottom, styled like minimized focus panel). + // Hidden while a minimized focus/info pill occupies the same bottom + // slot, so the pill takes over the bottom area instead of stacking on + // top of (or under) this banner — parity with how the live-session + // control panel hides on infoPopupMinimized / isFocusModeActive. + if (appState.viewingHistorySession && + _minimizedInfoPopup == null && + !(_focusPanelMinimized && _focusedPingSource != null)) Positioned( bottom: 16 + MediaQuery.of(context).padding.bottom, left: 16, @@ -2041,9 +2123,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } // Safety net for a stranded Feature A fan-out (faded repeaters + lines but - // no pill keeping the cell summary alive) — restore on empty tap. + // no pill keeping the cell summary alive) — full teardown (clears the + // footprint/dim/fade and restores the shared focus camera) on empty tap. if (_coverageHeardRepeaterIds != null && _minimizedInfoPopup == null) { - _clearCoverageConnections(); + _clearCellHighlight(); return; } // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer @@ -2123,9 +2206,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Supersede a currently-minimized popup. If it was a cell, its footprint // stays until this tap's own footprint replaces it (no bright flash); if it // was a repeater, drop the pill AND restore the hidden repeaters. + // Mark the cell view active BEFORE the teardowns so the shared focus camera + // isn't restored mid-switch (it stays engaged and re-fits below). + _cellPopupActive = true; _clearMinimizedInfoPopup(); _clearRepeaterIsolation(); - _cellPopupActive = true; + // Switching from a minimized ping focus: tear it down so its pill doesn't + // linger and overlap this cell view's pill at the shared bottom slot + // (symmetric to _enterPingFocus, which tears down the coverage views). The + // cell view is already claimed (_cellPopupActive = true), so this dismiss's + // _exitFocusCameraIfDone no-ops and the shared focus camera stays engaged. + _dismissPingFocus(); + _enterFocusCamera(); // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2175,6 +2267,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _coverageHeardRepeaterIds = eps.isEmpty ? null : {for (final e in eps) e.repeaterId}; _syncRepeaterSymbols(appState); + // Match ping focus: frame the cell + the repeaters that heard it (no-op + // when nothing was heard — single point — leaving the north-up view). + _fitCameraToPoints([ + LatLng(cell.centerLat, cell.centerLon), + for (final e in eps) LatLng(e.lat, e.lon), + ]); }).catchError((Object e) { debugWarn('[COVERAGE] cell fan-out failed: $e'); }); @@ -2266,13 +2364,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ) { if (!mounted) return; - // Spider spread marker: open the detail sheet for the tapped repeater. - // Spider stays open — users frequently compare stacked repeaters back to - // back, so collapsing on every selection would be annoying. + // Spider spread marker: the user has picked one of the fanned-out repeaters + // to inspect → collapse the spider and focus that repeater (focus mode + its + // coverage cells), mirroring the GPS fall-through path in + // _fallThroughToRepeaterAt. if (layerId == _spiderSymbolLayerId) { - // Don't isolate from inside a spider — users compare stacked repeaters - // back-to-back, so keep the spread open and all markers visible. - _showRepeaterDetailsById(id, isolate: false); + _collapseSpider(); + _showRepeaterDetailsById(id); // isolate: true (default) return; } @@ -2293,12 +2391,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } - // Individual repeater: open the detail sheet. At max zoom we ALSO check - // for stacked siblings within the spider stick threshold and spread them - // out (covers the rare case where clustering didn't pick them up — e.g. - // identical-coordinate markers that just slipped past clusterRadius - // due to a recent data update). Below max zoom, spiderfy is disabled — - // the user is expected to zoom further first. + // Individual repeater: open the detail sheet. We ALSO check for stacked + // siblings within the spider stick threshold and spread them out. In + // Simplified Mode this is gated to max zoom (clustered, so the bubble-tap + // path zooms in first; an individual hit below max zoom is a real lone + // marker). In Detailed Mode there is no cluster bubble / "zoom in first" + // path and stacked markers overlap at every zoom, so spiderfy fires at any + // zoom — otherwise a stacked pile can only ever show one repeater's coverage + // and the rest stay buried. if (layerId == _repeaterIndividualLayerId) { // If a spider is open and the user tapped a non-spider individual // (originals are filtered out via `inSpider`, so this can only be a @@ -2307,8 +2407,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _collapseSpider(); } - if (_isAtMaxZoom()) { - final appState = context.read(); + final appState = context.read(); + final detailed = appState.preferences.coverageGridSize == 100; + if (_isAtMaxZoom() || detailed) { final group = _findSpiderGroup(coordinates, appState); if (group.length >= 2) { _spiderfy(coordinates, group); @@ -2526,22 +2627,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (repeaterId == null) return; // For an individual layer hit (not a spider symbol), apply the same - // stacked-siblings test as the direct tap path — but ONLY at max zoom. - // queryRenderedFeatures doesn't expose layerId per result in 0.25, so - // we infer: if a spider is open, the original individuals are filtered - // out, so any individual hit must be a non-stacked marker → just open - // the detail sheet. If no spider is open AND we're at max zoom, run - // the spiderfy test. + // stacked-siblings test as the direct tap path. queryRenderedFeatures + // doesn't expose layerId per result in 0.25, so we infer: if a spider is + // open, the original individuals are filtered out, so any individual hit + // must be a non-stacked marker → just open the detail sheet. Otherwise run + // the spiderfy test — gated to max zoom in Simplified Mode, but at any + // zoom in Detailed Mode (un-clustered, so stacked markers overlap at every + // zoom and have no cluster-bubble "zoom in first" path). Mirrors the + // direct tap path. if (_spiderCenter != null) { // User tapped outside the open spider's group — collapse + show // detail sheet for the tapped marker. _collapseSpider(); - } else if (_isAtMaxZoom()) { + } else { final appState = context.read(); - final group = _findSpiderGroup(coordinates, appState); - if (group.length >= 2) { - _spiderfy(coordinates, group); - return; + final detailed = appState.preferences.coverageGridSize == 100; + if (_isAtMaxZoom() || detailed) { + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } } } _showRepeaterDetailsById(repeaterId); @@ -2587,6 +2693,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _restoreCoverageBackdropForRepeater(); if (!mounted) return; _syncRepeaterSymbols(context.read()); + // Restore the shared focus camera if no focus view remains (no-op during a + // switch, where the incoming view is already marked active). + _exitFocusCameraIfDone(); } /// Restore the base coverage overlay opacity dimmed by a Feature B repeater @@ -3074,6 +3183,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _applyCoverageOverlayOpacity(restore); } } + // Restore the shared focus camera if no focus view remains (a no-op during a + // switch, where the incoming view is already marked active). Runs before the + // early-return below so it always fires. + _exitFocusCameraIfDone(); if (_mapController == null || !_cellHighlightReady) return; try { await _mapController! @@ -4098,6 +4211,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + // Match the individual layer: Detailed (gsize 100) bakes the hex into the + // chip image (the spider layer reuses the same no-text-field props, so a + // generic shape would render as an empty box); Simplified uses the shared + // shape + text-field hex. See _buildRepeaterFeatureCollection. + final detailed = appState.preferences.coverageGridSize == 100; final features = >[]; for (var i = 0; i < _spiderRepeaters.length; i++) { @@ -4112,8 +4230,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : effectiveBytes == 2 ? 2 : 1; - final iconImage = _MapImages.repeater(statusKey, shapeBytes); final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final iconImage = detailed + ? _MapImages.repeaterChip(statusKey, shapeBytes, hex) + : _MapImages.repeater(statusKey, shapeBytes); final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); features.add({ @@ -4172,6 +4292,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { final geojson = _buildSpiderFeatureCollection(appState, currentZoom); + // Detailed mode references baked per-chip images. The main collection + // usually registers them first, but a spidered repeater that the main + // builder filtered out (focus / isolation / heard-repeater fade) would + // reference an unregistered chip — bake any missing ones here too. + if (appState.preferences.coverageGridSize == 100) { + await _ensureRepeaterChipImages(geojson); + } await _mapController!.setGeoJsonSource(_spiderSourceId, geojson); } catch (e) { debugError('[MAP] Failed to update spider source: $e'); @@ -4734,7 +4861,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { const LineLayerProperties( lineColor: ['get', 'color'], lineOpacity: 0.9, - lineWidth: 2.5, + // Per-feature width: the tile fan-out (Feature A) and the repeater + // coverage (Feature B) share this layer but use different widths. + lineWidth: ['get', 'width'], lineDasharray: [2, 4], lineCap: 'round', ), @@ -4792,7 +4921,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Future _updateCoverageLines( List<({double lat, double lon, String color})> segments, double startLat, - double startLon) async { + double startLon, + {double width = 2.5}) async { if (_mapController == null || !_styleLoaded) return; if (segments.isEmpty) { await _clearCoverageLines(); @@ -4803,7 +4933,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { for (final s in segments) { 'type': 'Feature', - 'properties': {'color': s.color}, + 'properties': {'color': s.color, 'width': width}, 'geometry': { 'type': 'LineString', 'coordinates': [ @@ -4877,6 +5007,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Place a distance-pill label at the midpoint of each coverage fan-out line /// (Feature A). Mirrors [_syncDistanceLabels] but with its own tracking keyed /// by endpoint lat/lon (6dp). Empty list removes all coverage labels. + /// Screen-space rotation (degrees, clockwise) so a horizontal distance label + /// runs ALONG the line from ([startLat],[startLon]) to ([endLat],[endLon]) + /// instead of staying flat. Annotation symbols rotate in viewport space, so + /// this is the line's on-screen angle — the geographic bearing minus the + /// camera bearing, minus 90° (a horizontal label already lies along an + /// east-west line) — flipped 180° when it would otherwise read upside-down. + double _lineLabelRotation( + double startLat, double startLon, double endLat, double endLon) { + final bearing = + Geolocator.bearingBetween(startLat, startLon, endLat, endLon); + final cam = _mapController?.cameraPosition?.bearing ?? 0; + var angle = bearing - cam - 90; + angle = (angle + 180) % 360 - 180; // normalize to [-180, 180) + if (angle > 90) angle -= 180; // keep upright (text never reads inverted) + if (angle < -90) angle += 180; + return angle; + } + Future _syncCoverageDistanceLabels( List<({double lat, double lon, String color})> segments, double startLat, @@ -4914,6 +5062,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { iconImage: imageName, iconSize: 1.0, iconAnchor: 'center', + // Rotate the pill to follow its line (kept upright) instead of staying + // flat across the fan-out. + iconRotate: _lineLabelRotation(startLat, startLon, s.lat, s.lon), ); final existing = _coverageDistanceLabelSymbols[key]; if (existing == null) { @@ -4978,9 +5129,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Feature B draw: render [matched] (the points that heard [repeater]) as /// deduped status-coloured coverage cells plus a status-coloured dashed line - /// from the repeater to each cell centre. Caps at the farthest 250. The lines - /// layer is ensured BEFORE the cells layer so the lines read on top. - Future _drawRepeaterCoverage(Repeater repeater, + /// from the repeater to each cell centre. The lines layer is ensured BEFORE + /// the cells layer so the lines read on top. Returns the (capped) cells so the + /// caller can frame them with the repeater. + Future> _drawRepeaterCoverage(Repeater repeater, List> matched, String cvd, int gridSize) async { final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; final blob = gridSize == 100 ? 1 : 0; @@ -4996,7 +5148,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (cells.isEmpty) { await _clearCoverageCells(); await _clearCoverageLines(); - return; + return cells; } await _ensureCoverageLinesLayer(); await _updateCoverageCells(cells, cvd, steps[0], steps[1]); @@ -5008,7 +5160,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], ), ]; - await _updateCoverageLines(segments, repeater.lat, repeater.lon); + // Skinnier than the tile fan-out — a repeater draws many lines at once. + await _updateCoverageLines(segments, repeater.lat, repeater.lon, width: 1.5); // Dim the base coverage tiles so the repeater's coloured cells + lines pop // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in // _clearRepeaterIsolation. Skip while ping focus already hid the overlay. @@ -5016,6 +5169,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _coverageDimmedForRepeater = true; await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); } + return cells; } /// Rebuilds the regional boundary layer from `appState.regionBorders`. @@ -5228,6 +5382,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { iconImage: imageName, iconSize: 1.0, iconAnchor: 'center', + // Rotate the pill to follow its ping→repeater line (kept upright), so + // ping focus matches the tile/repeater coverage labels. + iconRotate: _lineLabelRotation( + ping.latitude, ping.longitude, r.repeater.lat, r.repeater.lon), ); final existing = _distanceLabelSymbols[key]; @@ -5356,7 +5514,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.updateSymbol( symbol, - SymbolOptions(geometry: targetLatLng), + // Re-assert the line rotation (unchanged — sliding along the same + // line) alongside the new position. + SymbolOptions( + geometry: targetLatLng, + iconRotate: _lineLabelRotation(ping.latitude, ping.longitude, + repeaterPos.latitude, repeaterPos.longitude), + ), ); } catch (e) { debugError('[MAP] updateSymbol(distance reflow) failed: $e'); @@ -6637,18 +6801,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for successful traces with a known repeater - _focusedPingSource = entry; - - if (!fromMinimized && entry.success) { - final resolved = _resolveRepeatersByHexIds( - [entry.targetRepeaterId], - snrValues: [entry.localSnr], - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). _activatePingFocus engages focus for a + // successful trace's target or clears any prior focus / community view (empty + // list for a failed trace); set _focusedPingSource AFTER it (its missed branch + // dismisses a prior ping, which would otherwise clear the source). + if (!fromMinimized) { + final resolved = entry.success + ? _resolveRepeatersByHexIds( + [entry.targetRepeaterId], + snrValues: [entry.localSnr], + ) + : const <_ResolvedRepeater>[]; + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _focusedPingSource = entry; + _minimizePingFocus(entry.timestamp); + return; } showModalBottomSheet( @@ -7040,49 +7209,47 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { - // Drop repeaters lacking GPS — they would draw lines off to (0, 0). - // The bottom-sheet row builder still surfaces them with a no-location - // icon. If nothing is left to focus on, skip activation entirely so - // the user's current map view (zoom, autofollow, rotation) is kept. + // Repeaters lacking GPS would draw lines off to (0, 0); the bottom-sheet row + // builder still surfaces them with a no-location icon. final located = repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); - if (located.isEmpty) return; - // Session ping-focus and the community coverage views are distinct gestures; - // clear any open community fan-out / repeater cells (and their pill/isolation) - // so the two don't stack on the map. + // "Dead" ping (nothing to focus): don't enter focus (north-up + hiding every + // marker over an empty map is jarring). Tear down any PRIOR ping focus first + // (its lines / hidden markers / overlay-dim — else they'd linger under this + // ping's pill); _dismissPingFocus no-ops when no ping is active. It also + // clears _focusedPingSource, so the caller re-sets it for THIS ping AFTER + // _activatePingFocus returns. Then close any open community view so the pill + // isn't shown over a stale fan-out (req: tapping a ping closes coverage). + if (located.isEmpty) { + _dismissPingFocus(); + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + _clearRepeaterIsolation(); + _clearCoverageConnections(); + return; + } + + final alreadyInFocus = _focusedPingLocation != null; + // Claim ping focus as the active view FIRST, so the community teardowns below + // see a focus view active and don't restore the shared camera mid-switch + // (then ping re-fits) — switching from a coverage view to a ping animates once. + _focusedPingLocation = pingLocation; + + // Close any open community coverage view (pill + footprint + dim + isolation + + // lines/cells/fade). Each teardown's _exitFocusCameraIfDone no-ops because + // ping focus is now the active view. _clearMinimizedInfoPopup(); + _clearCellHighlight(); _clearRepeaterIsolation(); _clearCoverageConnections(); - // Only save pre-focus state on first activation. When re-activating - // (e.g. user taps a different ping while already in focus, or expanding - // from minimized), we keep the original pre-focus snapshot so dismiss - // restores the correct camera position. - final alreadyInFocus = _focusedPingLocation != null; + // Engage the shared focus camera (saves the pre-focus snapshot once, + // north-up, stop follow). isFocusModeActive is a PING-only flag (it gates the + // home-screen map Selector); set it only for ping focus, only on first entry. + _enterFocusCamera(); if (!alreadyInFocus) { context.read().isFocusModeActive = true; - final pos = _mapController?.cameraPosition; - _preFocusCenter = pos?.target; - _preFocusZoom = pos?.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; - } - - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null && _canAnimateCamera) { - _mapController!.animateCamera( - CameraUpdate.bearingTo(0), - duration: const Duration(milliseconds: 1), - ); - } - } } _focusPanelMinimized = false; @@ -7092,7 +7259,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusSyncDeferred = true; setState(() { - _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; _focusedRepeaters = located; }); @@ -7104,7 +7270,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { - _zoomToFocusBounds(pingLocation, located); + _fitCameraToPoints([ + pingLocation, + ...located.map((r) => LatLng(r.repeater.lat, r.repeater.lon)), + ]); } }); @@ -7122,18 +7291,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Dismiss ping focus mode — restore map state. void _dismissPingFocus() { - if (_focusedPingLocation == null || !mounted) return; + // Key off the SOURCE, not the location: a "missed" ping has a source but no + // focus location, and must still be torn down (clear isFocusModeActive so the + // control bar returns, clear the pill state, etc.). + if (_focusedPingSource == null || !mounted) return; context.read().isFocusModeActive = false; - final center = _preFocusCenter; - final zoom = _preFocusZoom; - final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; - final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; - - // Clear focus state but do NOT restore auto-follow or rotation yet — - // they would immediately trigger animations in the build method that - // override our zoom-back animation (both share _animationController). + // Clear focus state first so _anyFocusViewActive sees ping focus gone. setState(() { _focusedPingLocation = null; _focusedPingTimestamp = null; @@ -7148,27 +7313,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final appState = context.read(); _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); - if (center != null && zoom != null) { - _animateToPositionWithZoom(center, zoom); + // Restore the shared focus camera (center/zoom + follow/rotation) now that no + // focus view remains. A no-op if a community view is somehow still open + // (can't happen — ping focus tears them down on entry). + _exitFocusCameraIfDone(); + } - // Restore auto-follow and heading rotation after the zoom-back - // animation completes (500ms) so they don't clobber it mid-flight. - if (shouldRestoreAutoFollow || shouldRestoreRotation) { - Future.delayed(const Duration(milliseconds: 550), () { - if (mounted) { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - }); - } - } else { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } + /// Collapse a just-tapped ping to the minimized 2-row pill — the DEFAULT for a + /// ping tap (parity with the cell/repeater tap flow). Run right after + /// [_activatePingFocus]: for a HEARD ping that engaged focus this just flips the + /// panel to minimized; for a MISSED ping (no focus location) it also sets + /// isFocusModeActive so the control bar hides under the pill — without any of + /// the focus VISUALS (those key off _focusedPingLocation, which stays null). + void _minimizePingFocus(DateTime timestamp) { + context.read().isFocusModeActive = true; + setState(() { + _focusedPingTimestamp = timestamp; + _focusPanelMinimized = true; + }); } void _reshowFocusPanel() { @@ -7203,12 +7365,32 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Pill for a minimized cell-summary / repeater-detail popup (parity with /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X /// closes it and runs its cleanup. + /// A minimized-pill control (↑ expand / ✕ close) with a generous ~44px hit + /// target so it's hard to mis-tap. Opaque so the tap never falls through to the + /// map (or the pill body's tap-swallow). + Widget _pillIconButton(IconData icon, VoidCallback onTap) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: 44, + height: 44, + child: Icon(icon, + size: 22, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ); + } + Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { final theme = Theme.of(context); return GestureDetector( - onTap: popup.onReshow, + // Swallow body taps: tapping the pill body must NOT expand (too easy to hit + // when reaching for close) and must NOT fall through to the map. Expand is + // only via the ↑ button; close only via the ✕ button. + behavior: HitTestBehavior.opaque, + onTap: () {}, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.fromLTRB(12, 2, 4, 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), @@ -7227,25 +7409,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Row 1: identity + expand/close controls. + // Row 1: identity + expand/close controls (each ~44px hit target). Row( children: [ Expanded(child: popup.title), - const SizedBox(width: 8), - GestureDetector( - onTap: popup.onReshow, - child: Icon(Icons.keyboard_arrow_up, - size: 20, color: theme.colorScheme.onSurfaceVariant), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: popup.onClose, - child: Icon(Icons.close, - size: 18, color: theme.colorScheme.onSurfaceVariant), - ), + const SizedBox(width: 4), + _pillIconButton(Icons.keyboard_arrow_up, popup.onReshow), + _pillIconButton(Icons.close, popup.onClose), ], ), - const SizedBox(height: 8), // Row 2: stat chips (wraps if the row is too narrow). popup.statsBuilder(context), ], @@ -7471,19 +7643,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return const SizedBox.shrink(); } - final repeaterCount = _focusedRepeaters.length; + final theme = Theme.of(context); final timeStr = _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; return GestureDetector( - onTap: _reshowFocusPanel, + // Swallow body taps (no accidental expand / no fall-through to the map). + // Expand only via ↑, close only via ✕ — parity with _buildMinimizedInfoPill. + behavior: HitTestBehavior.opaque, + onTap: () {}, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.fromLTRB(12, 2, 4, 8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + color: theme.colorScheme.outline.withValues(alpha: 0.5), ), boxShadow: [ BoxShadow( @@ -7493,61 +7668,120 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 8), - Text( - timeStr, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (repeaterCount > 0) ...[ - const SizedBox(width: 8), - Text( - '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + // Row 1: type icon + title + timestamp + expand/close (each ~44px hit). + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), ), - ), - ], - const SizedBox(width: 8), - GestureDetector( - onTap: _reshowFocusPanel, - child: Icon( - Icons.keyboard_arrow_up, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: _dismissPingFocus, - child: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + const SizedBox(width: 8), + Expanded( + child: Text( + timeStr, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + _pillIconButton(Icons.keyboard_arrow_up, _reshowFocusPanel), + _pillIconButton(Icons.close, _dismissPingFocus), + ], ), + // Row 2: summary stat chips computed synchronously from the source. + _buildFocusPingStats(source), ], ), ), ); } + /// Row-2 summary chips for the minimized ping pill, computed synchronously from + /// the focused ping [source] (parity with the cell/repeater pill's stats row). + /// Best-of (max) SNR/RSSI across all heard repeaters for multi-repeater TX/DISC; + /// the single value for RX/TRACE. "Missed" pings show a short miss label. + Widget _buildFocusPingStats(Object source) { + final missColor = Theme.of(context).colorScheme.onSurfaceVariant; + final chips = []; + + Widget snrChip(double snr) => _pillStat( + Icons.signal_cellular_alt, + '${snr.toStringAsFixed(1)} SNR', + iconColor: PingColors.snrColor(snr), + valueColor: PingColors.snrColor(snr), + ); + Widget rssiChip(int rssi) => _pillStat( + Icons.cell_tower, + '$rssi dBm', + iconColor: PingColors.rssiColor(rssi), + valueColor: PingColors.rssiColor(rssi), + ); + + if (source is TxPing) { + final reps = source.heardRepeaters; + if (reps.isEmpty) { + chips.add(_pillStat(Icons.close, 'Not heard', iconColor: missColor)); + } else { + final direct = reps.where((r) => r.pathHops == null).length; + final multi = reps.length - direct; + if (direct > 0) { + chips.add(_pillStat(Icons.arrow_upward, '$direct direct')); + } + if (multi > 0) { + chips.add(_pillStat(Icons.alt_route, '$multi multi')); + } + final snrs = reps.map((r) => r.snr).whereType(); + if (snrs.isNotEmpty) chips.add(snrChip(snrs.reduce(math.max))); + final rssis = reps.map((r) => r.rssi).whereType(); + if (rssis.isNotEmpty) chips.add(rssiChip(rssis.reduce(math.max))); + } + } else if (source is RxPing) { + chips.add(snrChip(source.snr)); + chips.add(rssiChip(source.rssi)); + if (source.pathHops.isNotEmpty) { + chips.add(_pillStat(Icons.alt_route, '${source.pathHops.length} hops')); + } + } else if (source is DiscLogEntry) { + final nodes = source.discoveredNodes; + if (nodes.isEmpty) { + chips.add(_pillStat(Icons.close, 'No response', iconColor: missColor)); + } else { + chips.add(_pillStat(Icons.radar, + '${nodes.length} node${nodes.length != 1 ? 's' : ''}')); + chips.add(snrChip(nodes.map((n) => n.localSnr).reduce(math.max))); + chips.add(rssiChip(nodes.map((n) => n.localRssi).reduce(math.max))); + } + } else if (source is TraceLogEntry) { + if (!source.success) { + chips.add(_pillStat(Icons.close, 'No response', iconColor: missColor)); + } else { + chips.add(_pillStat(Icons.check_circle, 'Success', + iconColor: PingColors.txSuccess)); + if (source.localSnr != null) chips.add(snrChip(source.localSnr!)); + if (source.remoteSnr != null) { + chips.add(_pillStat( + Icons.arrow_upward, 'TX ${source.remoteSnr!.toStringAsFixed(1)}')); + } + if (source.localRssi != null) chips.add(rssiChip(source.localRssi!)); + } + } + + if (chips.isEmpty) return const SizedBox.shrink(); + return Wrap(spacing: 14, runSpacing: 6, children: chips); + } + void _showHistoryMarkerAsLive(PingEventMarker marker) { if (marker.latitude == null || marker.longitude == null) return; final lat = marker.latitude!; @@ -7748,12 +7982,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : <_ResolvedRepeater>[]; final hasAmbiguous = resolved.any((r) => r.ambiguous); - _focusedPingSource = ping; - - // Activate focus mode if the ping was heard by known repeaters - if (!fromMinimized && resolved.isNotEmpty) { + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true, via _reshowFocusPanel). _activatePingFocus + // engages focus when the ping was heard, or clears any prior focus / open + // community view when it wasn't; set _focusedPingSource AFTER it (its missed + // branch dismisses a prior ping, which would otherwise clear a source set + // earlier). + if (!fromMinimized) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _focusedPingSource = ping; + _minimizePingFocus(ping.timestamp); + return; } showModalBottomSheet( @@ -8270,11 +8510,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { snrValues: [ping.snr], ); - _focusedPingSource = ping; - - if (!fromMinimized && resolved.isNotEmpty) { + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). Set _focusedPingSource AFTER + // _activatePingFocus (its missed branch dismisses a prior ping, clearing the + // source). RX is always a reception, so it always has located repeater data. + if (!fromMinimized) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _focusedPingSource = ping; + _minimizePingFocus(ping.timestamp); + return; } showModalBottomSheet( @@ -8564,20 +8809,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Show DISC ping details popup void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for discovered nodes with known repeater positions - _focusedPingSource = entry; - - if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - entry.discoveredNodes.map((n) => n.repeaterId).toList(), - fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: - entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). _activatePingFocus engages focus for + // located nodes or clears any prior focus / community view (empty list when + // nothing was discovered); set _focusedPingSource AFTER it (its missed branch + // dismisses a prior ping, which would otherwise clear the source). + if (!fromMinimized) { + final resolved = entry.discoveredNodes.isNotEmpty + ? _resolveRepeatersByHexIds( + entry.discoveredNodes.map((n) => n.repeaterId).toList(), + fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + ) + : const <_ResolvedRepeater>[]; + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _focusedPingSource = entry; + _minimizePingFocus(entry.timestamp); + return; } showModalBottomSheet( @@ -8927,20 +9177,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bool isolate = true, bool expand = false, Future? cachedStats}) { - // Supersede any currently-minimized popup (clears a prior cell footprint/dim). - // No-op on the internal pill<->sheet re-entries (nothing is minimized then). - _minimizedInfoPopup?.onClose(); - // Drop any lingering Feature A cell fan-out fade so it doesn't reappear when - // this repeater's isolation is later cleared (the fade is suppressed while a - // repeater is isolated, but the set would otherwise linger). - _restoreFadedRepeaters(); - // Focus this repeater: hide every OTHER repeater while its detail sheet/pill // is open (web setSoloCircle parity) and track it as the active selection - // (Feature B draw guard + teardown). Restored by _clearRepeaterIsolation on - // sheet/pill close or empty-map tap. - if (isolate && _isolatedRepeaterId != repeater.id) { - _isolatedRepeaterId = repeater.id; + // (Feature B draw guard + teardown). Claim it as the active view BEFORE + // tearing down any prior view, so the shared focus camera isn't restored + // mid-switch (it stays engaged and re-fits to this repeater below). Restored + // by _clearRepeaterIsolation on sheet/pill close or empty-map tap. + final wasCell = _cellPopupActive; + final prevRepeater = _isolatedRepeaterId; + if (isolate) _isolatedRepeaterId = repeater.id; + _clearMinimizedInfoPopup(); // drop the prior pill widget only + if (wasCell) { + // Switching from a tile view: tear down its footprint/dim/fade/lines. + _clearCellHighlight(); + } else if (prevRepeater != null && prevRepeater != repeater.id) { + // Switching repeater->repeater: tear down the old cells/lines/dim WITHOUT + // clearing the (now new) _isolatedRepeaterId via _clearRepeaterIsolation. + _clearCoverageLines(); + _clearCoverageCells(); + _restoreCoverageBackdropForRepeater(); + } + // Drop any lingering Feature A fade so it doesn't reappear when this + // repeater's isolation is later cleared. + _restoreFadedRepeaters(); + // Switching from a minimized ping focus: tear it down so its pill doesn't + // linger and overlap this repeater view's pill at the shared bottom slot + // (symmetric to _enterPingFocus). When isolating, the repeater view is + // already claimed (_isolatedRepeaterId set above), so this dismiss's + // _exitFocusCameraIfDone no-ops and the shared focus camera stays engaged. + _dismissPingFocus(); + if (isolate) { + _enterFocusCamera(); // save the pre-focus snapshot once; north-up, stop follow _syncRepeaterSymbols(context.read()); } @@ -8989,7 +9256,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (mounted && _isolatedRepeaterId == repeater.id && repeater.hasLocation) { - _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize); + _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize) + .then((cells) { + if (!mounted || _isolatedRepeaterId != repeater.id) return; + // Match ping focus: frame the repeater + its whole coverage + // footprint (no-op when it heard nothing — single point). + _fitCameraToPoints([ + LatLng(repeater.lat, repeater.lon), + for (final c in cells) LatLng(c.centerLat, c.centerLon), + ]); + }); } return res.stats; }).catchError((Object e) { From 63038d362c0b76c720e2bc5c157b880f87da034f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 21:33:38 -0400 Subject: [PATCH 152/360] feat(offline): per-region placement summary after upload The server now routes each offline ping to the region it occurred in and returns placement_counts + too_far_region per batch. Parse and accumulate these across all batches of an upload, store them on the uploaded OfflineSession, and show a summary ("Uploaded - DSA 88 - EMA 157 - too far 3") that is tappable for a per-region breakdown dialog. Add a defensive guard in WireTagCodec.encode() that rejects offline-/non-region session ids (offline is passive-only and is never wire-tag encoded; this also prevents a RangeError on a malformed id). Session id stays opaque otherwise, so the upload loop is unchanged and older behaviour is preserved. --- lib/providers/app_state_provider.dart | 33 +++++++++- lib/screens/settings_screen.dart | 78 ++++++++++++++++++++++- lib/services/api_service.dart | 8 ++- lib/services/meshcore/wire_tag_codec.dart | 8 +++ lib/services/offline_session_service.dart | 29 ++++++++- 5 files changed, 145 insertions(+), 11 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index daf227e6..4702bdc1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4982,6 +4982,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { var uploadedCount = 0; final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; + // Accumulate the server's per-region placement summary across all batches so the uploaded + // session can show where its pings landed (e.g. "DSA 88 · EMA 157 · too far 3"). Offline + // uploads route each ping to its own region server-side; these come back per batch. + final Map placementTotals = {}; + var tooFarTotal = 0; + void accumulatePlacement(Map resp) { + final pc = resp['placement_counts']; + if (pc is Map) { + pc.forEach((k, v) { + final n = (v is int) ? v : (int.tryParse('$v') ?? 0); + placementTotals[k.toString()] = (placementTotals[k.toString()] ?? 0) + n; + }); + } + final tf = resp['too_far_region']; + if (tf is int) { + tooFarTotal += tf; + } else if (tf != null) { + tooFarTotal += int.tryParse('$tf') ?? 0; + } + } + // Backoff (seconds) for session-propagation / transient errors. The first batch // absorbs the session-propagation delay, so it gets a much longer budget than // later batches (which only see a session error if it genuinely expired/revoked). @@ -4996,7 +5017,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final backoff = i == 0 ? firstBatchBackoff : laterBatchBackoff; var result = - await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId, + onResponse: accumulatePlacement); // Retry only session-propagation / transient errors. nonRetryable // (data/zone/key) errors are NOT retried — we stop and preserve instead. @@ -5013,7 +5035,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches (retry ${retry + 1})'); await Future.delayed(Duration(seconds: delay)); result = - await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId, + onResponse: accumulatePlacement); } if (result == UploadResult.success) { @@ -5051,7 +5074,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final remainingPings = pings.length - uploadedCount; if (remainingPings <= 0) { - await _offlineSessionService.markAsUploaded(filename); + await _offlineSessionService.markAsUploaded( + filename, + placementCounts: placementTotals.isNotEmpty ? placementTotals : null, + tooFarRegion: tooFarTotal, + ); debugLog( '[OFFLINE] Session complete: $uploadedCount uploaded from $filename'); notifyListeners(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ea943ea1..f1dedd0e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2869,11 +2869,83 @@ class _OfflineSessionTile extends StatelessWidget { required this.onDownload, }); + bool get _hasSummary => + (session.placementCounts?.isNotEmpty ?? false) || + ((session.tooFarRegion ?? 0) > 0); + + /// e.g. "DSA 88 · EMA 157 · too far 3" + String _placementSummary() { + final parts = []; + final pc = session.placementCounts; + if (pc != null && pc.isNotEmpty) { + final entries = pc.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + parts.addAll(entries.map((e) => '${e.key} ${e.value}')); + } + final tf = session.tooFarRegion ?? 0; + if (tf > 0) parts.add('too far $tf'); + return parts.join(' · '); + } + + void _showSummaryDialog(BuildContext context) { + final pc = session.placementCounts ?? {}; + final tf = session.tooFarRegion ?? 0; + final entries = pc.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Upload Summary'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(session.filename, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + if (entries.isEmpty) + const Text('No regional placement recorded.') + else + ...entries.map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(e.key, + style: + const TextStyle(fontWeight: FontWeight.w600)), + Text('${e.value} pings'), + ], + ), + )), + if (tf > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '$tf ping(s) dropped — more than 50 km outside every region', + style: TextStyle(color: Colors.orange.shade800, fontSize: 12), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final isUploaded = session.uploaded; return ListTile( + onTap: (isUploaded && _hasSummary) + ? () => _showSummaryDialog(context) + : null, leading: Icon( isUploaded ? Icons.cloud_done : Icons.cloud_off, color: isUploaded ? Colors.green : Colors.orange, @@ -2884,9 +2956,9 @@ class _OfflineSessionTile extends StatelessWidget { children: [ Text('${session.pingCount} pings • ${session.displayDate}'), if (isUploaded) - const Text( - 'Uploaded', - style: TextStyle( + Text( + _hasSummary ? 'Uploaded · ${_placementSummary()}' : 'Uploaded', + style: const TextStyle( color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 22985506..3d39a60d 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1230,8 +1230,9 @@ class ApiService { /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode Future uploadBatchWithSessionId( List> pings, - String sessionId, - ) async { + String sessionId, { + void Function(Map response)? onResponse, + }) async { if (pings.isEmpty) return UploadResult.success; try { @@ -1243,6 +1244,9 @@ class ApiService { } if (result['success'] == true) { + // Surface the server's per-batch placement_counts / too_far_region + // (offline routing) so the caller can accumulate an upload summary. + onResponse?.call(result); debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); return UploadResult.success; } diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart index c11f8185..61f4994c 100644 --- a/lib/services/meshcore/wire_tag_codec.dart +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -106,6 +106,14 @@ class WireTagCodec { /// fallback (still coord-free, just not key-protected). static String encode(String sessionId, int counter, String? key) { final parts = sessionId.split('-'); + // Defensive guard: offline-upload ids ("offline-YYYYMMDD-NNNN") are passive-only and must + // never be wire-tag encoded — parts[0] would be the literal "offline", not a region code. + // The offline flow never calls encode() (only the live TX path does); this protects against + // an accidental future caller and a malformed id (which would otherwise RangeError on parts[2]). + if (parts.length < 3 || parts[0].toLowerCase() == 'offline') { + throw ArgumentError( + 'Cannot wire-tag encode an offline / non-region session id: $sessionId'); + } final v = _regionPack(parts[0]) * _pow25 + int.parse(parts[2]) * _pow11 + counter; diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index ecd0acaf..78fead9a 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -18,6 +18,9 @@ class OfflineSession { final double? powerLevel; // TX power level (watts) captured at record time final String? appVersion; // App version captured at record time final bool uploaded; // Track upload status + final Map? + placementCounts; // Per-IATA counts the server routed this upload into + final int? tooFarRegion; // Pings dropped for being >50km outside every region OfflineSession({ required this.filename, @@ -32,6 +35,8 @@ class OfflineSession { this.powerLevel, this.appVersion, this.uploaded = false, + this.placementCounts, + this.tooFarRegion, }); /// Create from stored JSON @@ -49,6 +54,9 @@ class OfflineSession { powerLevel: (json['powerLevel'] as num?)?.toDouble(), appVersion: json['appVersion'] as String?, uploaded: json['uploaded'] as bool? ?? false, + placementCounts: (json['placementCounts'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), (v as num).toInt())), + tooFarRegion: json['tooFarRegion'] as int?, ); } @@ -67,6 +75,8 @@ class OfflineSession { 'powerLevel': powerLevel, 'appVersion': appVersion, 'uploaded': uploaded, + 'placementCounts': placementCounts, + 'tooFarRegion': tooFarRegion, }; } @@ -75,6 +85,8 @@ class OfflineSession { bool? uploaded, Map? data, int? pingCount, + Map? placementCounts, + int? tooFarRegion, }) { return OfflineSession( filename: filename, @@ -89,6 +101,8 @@ class OfflineSession { powerLevel: powerLevel, appVersion: appVersion, uploaded: uploaded ?? this.uploaded, + placementCounts: placementCounts ?? this.placementCounts, + tooFarRegion: tooFarRegion ?? this.tooFarRegion, ); } @@ -325,15 +339,24 @@ class OfflineSessionService { } } - /// Mark a session as uploaded without deleting it - Future markAsUploaded(String filename) async { + /// Mark a session as uploaded without deleting it. Optionally records the + /// per-region placement summary + too-far count returned by the server. + Future markAsUploaded( + String filename, { + Map? placementCounts, + int? tooFarRegion, + }) async { final index = _sessions.indexWhere((s) => s.filename == filename); if (index == -1) { debugLog('[OFFLINE] Session not found for marking uploaded: $filename'); return; } - _sessions[index] = _sessions[index].copyWith(uploaded: true); + _sessions[index] = _sessions[index].copyWith( + uploaded: true, + placementCounts: placementCounts, + tooFarRegion: tooFarRegion, + ); await _saveSessions(); debugLog('[OFFLINE] Marked session as uploaded: $filename'); } From 6e1332533a0c1d01c2f521c89e12decf25a3bcbd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 21:37:57 -0400 Subject: [PATCH 153/360] Fix recurring iOS launch/focus crash: map fit-to-bounds could send MapLibre a non-finite zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camera fit-to-bounds paths could hand MapLibre a zero-area box (coincident points) or edge padding larger than the visible map (the tall coverage/repeater bottom sheet), both of which make its projection compute a non-finite zoom and abort the app via an uncaught native LatLng throw (SIGABRT) — the same crash signature as an invalid coordinate. All newLatLngBounds fits now route through a guarded _animateFitBounds helper that centers degenerate boxes (isDegenerateBounds) and clamps padding to the live map size (clampFitPadding). The earlier lat/lon-only guard (813273d) never covered zoom/bounds/padding, so the same crash survived in the coverage focus-camera feature. Adds unit tests for the new helpers. --- lib/utils/geo_validation.dart | 58 ++++++++++++++++++ lib/widgets/map_widget.dart | 91 +++++++++++++++++++++++------ test/utils/geo_validation_test.dart | 58 ++++++++++++++++++ 3 files changed, 189 insertions(+), 18 deletions(-) diff --git a/lib/utils/geo_validation.dart b/lib/utils/geo_validation.dart index 274f6be6..61514816 100644 --- a/lib/utils/geo_validation.dart +++ b/lib/utils/geo_validation.dart @@ -12,3 +12,61 @@ /// validated against the WGS84 domain first. bool isValidLatLng(double lat, double lon) => lat.isFinite && lon.isFinite && lat.abs() <= 90 && lon.abs() <= 180; + +/// Whether a lat/lon bounding box is zero/near-zero area (a single point, or a +/// cluster of coincident points). +/// +/// Fitting the camera to a degenerate box makes MapLibre divide by a ~0 span, +/// producing a non-finite zoom that propagates into its `unproject` math and +/// aborts the app via the same `LatLng` throw as invalid coordinates. Callers +/// must detect this and fall back to a plain center+zoom move instead. +/// [epsilonDeg] defaults to 1e-6° (≈0.1 m) — below this a fit is meaningless. +bool isDegenerateBounds( + double minLat, + double maxLat, + double minLon, + double maxLon, { + double epsilonDeg = 1e-6, +}) => + (maxLat - minLat).abs() < epsilonDeg && (maxLon - minLon).abs() < epsilonDeg; + +/// Clamp fit-bounds edge padding so it can never meet or exceed the map's +/// rendered size. +/// +/// MapLibre fits a bounds into `size - padding`; if the horizontal or vertical +/// padding sums to ≥ the map dimension the available area goes to zero/negative, +/// yielding a non-finite zoom that aborts the app inside `unproject`. This keeps +/// at least [minVisible] logical pixels visible on each axis, shrinking each +/// side proportionally when the requested padding is too large. When [width] or +/// [height] is unknown (≤ 0, e.g. before first layout) it returns small safe +/// defaults rather than trusting the caller's values. +({double left, double top, double right, double bottom}) clampFitPadding( + double left, + double top, + double right, + double bottom, + double width, + double height, { + double minVisible = 40, +}) { + // Unknown/zero viewport: don't trust the requested padding at all. + if (!width.isFinite || !height.isFinite || width <= 0 || height <= 0) { + return (left: 8, top: 8, right: 8, bottom: 8); + } + + ({double a, double b}) clampPair(double a, double b, double extent) { + a = a.isFinite && a > 0 ? a : 0; + b = b.isFinite && b > 0 ? b : 0; + final budget = extent - minVisible; + if (budget <= 0) return (a: 0, b: 0); + final sum = a + b; + if (sum <= budget) return (a: a, b: b); + // Shrink proportionally to fit the budget (sum > 0 here since sum > budget ≥ 0). + final scale = budget / sum; + return (a: a * scale, b: b * scale); + } + + final h = clampPair(left, right, width); + final v = clampPair(top, bottom, height); + return (left: h.a, top: v.a, right: h.b, bottom: v.b); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 77071fae..8147141d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1051,15 +1051,77 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (p.longitude < minLon) minLon = p.longitude; if (p.longitude > maxLon) maxLon = p.longitude; } - final bounds = LatLngBounds( - southwest: LatLng(minLat, minLon), - northeast: LatLng(maxLat, maxLon), - ); + // Leave room for the bottom sheet/pill that accompanies focus views. final bottomPad = MediaQuery.of(context).size.height * 0.4; + _animateFitBounds( + minLat: minLat, + maxLat: maxLat, + minLon: minLon, + maxLon: maxLon, + leftPad: 60, + topPad: 60, + rightPad: 60, + bottomPad: bottomPad, + ); + } + + /// Fit the camera to a lat/lon box, guarding the two inputs MapLibre will + /// choke on: a degenerate (zero-area) box and edge padding that exceeds the + /// map's rendered size. Either yields a non-finite zoom that propagates into + /// MapLibre's native `unproject` and aborts the app with an uncaught C++ + /// `LatLng` throw (SIGABRT) — the same failure mode as an invalid coordinate, + /// which a plain lat/lon validity check does not catch. A degenerate box falls + /// back to a plain center+zoom move; padding is clamped to the live map size. + void _animateFitBounds({ + required double minLat, + required double maxLat, + required double minLon, + required double maxLon, + double leftPad = 60, + double topPad = 60, + double rightPad = 60, + double bottomPad = 60, + }) { + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } + final centerLat = (minLat + maxLat) / 2; + final centerLon = (minLon + maxLon) / 2; + + // A single point (or coincident cluster) has no span to frame — center on it + // instead of asking MapLibre to fit a zero-area box. + if (isDegenerateBounds(minLat, maxLat, minLon, maxLon)) { + _animateToPositionWithZoom( + LatLng(centerLat, centerLon), 16.0 - _zoomEpsilon); + return; + } + + // Belt-and-suspenders: never hand MapLibre a non-finite corner. + if (!isValidLatLng(minLat, minLon) || !isValidLatLng(maxLat, maxLon)) { + debugWarn('[MAP] Skipping fit-bounds with invalid corners ' + '($minLat,$minLon)-($maxLat,$maxLon)'); + return; + } + + final size = context.size ?? MediaQuery.of(context).size; + final pad = clampFitPadding( + leftPad, topPad, rightPad, bottomPad, size.width, size.height); + _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, - left: 60, top: 60, right: 60, bottom: bottomPad), + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ), + left: pad.left, + top: pad.top, + right: pad.right, + bottom: pad.bottom, + ), duration: const Duration(milliseconds: 500), ); } @@ -5573,18 +5635,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (p.longitude! > maxLon) maxLon = p.longitude!; } - _mapController!.animateCamera( - CameraUpdate.newLatLngBounds( - LatLngBounds( - southwest: LatLng(minLat, minLon), - northeast: LatLng(maxLat, maxLon), - ), - left: 60, - top: 60, - right: 60, - bottom: 60, - ), - duration: const Duration(milliseconds: 500), + _animateFitBounds( + minLat: minLat, + maxLat: maxLat, + minLon: minLon, + maxLon: maxLon, ); } diff --git a/test/utils/geo_validation_test.dart b/test/utils/geo_validation_test.dart index b2aac142..b1f6e87e 100644 --- a/test/utils/geo_validation_test.dart +++ b/test/utils/geo_validation_test.dart @@ -37,4 +37,62 @@ void main() { expect(isValidLatLng(0, 200), isFalse); }); }); + + group('isDegenerateBounds', () { + test('true for identical / coincident points', () { + expect(isDegenerateBounds(45.0, 45.0, -75.0, -75.0), isTrue); + // Sub-0.1m jitter still counts as degenerate. + expect(isDegenerateBounds(45.0, 45.0000001, -75.0, -75.0000001), isTrue); + }); + + test('false for a real spread', () { + expect(isDegenerateBounds(45.0, 45.1, -75.0, -75.1), isFalse); + // Degenerate on one axis only is still a fit-able line. + expect(isDegenerateBounds(45.0, 45.0, -75.0, -75.1), isFalse); + expect(isDegenerateBounds(45.0, 45.1, -75.0, -75.0), isFalse); + }); + }); + + group('clampFitPadding', () { + test('passes through padding that fits', () { + final p = clampFitPadding(60, 60, 60, 60, 400, 800); + expect(p.left, 60); + expect(p.top, 60); + expect(p.right, 60); + expect(p.bottom, 60); + }); + + test('shrinks oversized vertical padding to keep the map visible', () { + // bottom alone exceeds the height budget (the reported crash shape). + final p = clampFitPadding(60, 60, 60, 1000, 400, 800); + expect(p.top + p.bottom, lessThanOrEqualTo(800 - 40)); + expect(p.top, greaterThan(0)); + expect(p.bottom, greaterThan(0)); + // Horizontal axis untouched. + expect(p.left, 60); + expect(p.right, 60); + }); + + test('zero/negative budget yields no padding on that axis', () { + final p = clampFitPadding(60, 60, 60, 60, 400, 30); + expect(p.top, 0); + expect(p.bottom, 0); + }); + + test('unknown/zero viewport returns small safe defaults', () { + final p = clampFitPadding(60, 60, 60, 1000, 0, 0); + expect(p.left, 8); + expect(p.top, 8); + expect(p.right, 8); + expect(p.bottom, 8); + }); + + test('non-finite padding is treated as zero', () { + final p = clampFitPadding(double.nan, 60, double.infinity, 60, 400, 800); + expect(p.left, 0); + expect(p.right, 0); + expect(p.top, 60); + expect(p.bottom, 60); + }); + }); } From 0e22a3f7bcdc9c2fde64c4b5a918cc2497d93ed8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 22:26:06 -0400 Subject: [PATCH 154/360] Resume ping counter from /auth resume_counter on session reuse On a fresh /auth, continue the wire-tag ping counter from the server's resume_counter (sent when the session was reused after a force-close + reconnect) instead of resetting to 0, which re-minted duplicate wire-tags the subscriber + coverage dedup silently dropped. Absent/0 -> reset as before. --- lib/services/api_service.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 3d39a60d..ec98dc11 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -433,10 +433,17 @@ class ApiService { _rxAllowed = data['rx_allowed'] == true; _sessionExpiresAt = data['expires_at'] as int?; - // TX wire-tag key + per-session ping counter (counter resets on a fresh /auth). - // Log only receipt + length — NEVER the raw key (debug logs are uploadable). + // TX wire-tag key + per-session ping counter. The counter RESUMES from the server's + // resume_counter when our session was reused (force-close + reconnect): resetting to 0 + // here would re-mint wire-tags identical to the pre-restart pings, which the subscriber + // + coverage dedup then silently drop. Absent (new session / old server) -> 0, the + // original behaviour. Log only receipt + length — NEVER the raw key (uploadable logs). _wireKey = data['wire_key'] as String?; - _pingCounter = 0; + final resumeCounter = (data['resume_counter'] as num?)?.toInt() ?? 0; + _pingCounter = resumeCounter; + if (resumeCounter > 0) { + debugLog('[AUTH] resumed ping counter at $resumeCounter (reused session)'); + } if (_wireKey != null) { debugLog('[AUTH] wire-tag key received (len=${_wireKey!.length})'); } else { From da66ff7a8505bc4143ec6339339ac23450457cbd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 23:42:48 -0400 Subject: [PATCH 155/360] Vendor patched maplibre_gl 0.25.0 with camera-viewport crash guard MapLibre's transform unprojects against the live viewport. On a launch where the GL surface is degenerate/zero-sized (tiles never render a real frame), the first animated flyTo/setCamera makes unproject return NaN and mbgl::LatLng throws an uncaught C++ std::domain_error -> SIGABRT. That throw crosses the native->Dart boundary and cannot be caught from Dart, so the fix has to live inside the plugin. - Vendor maplibre_gl 0.25.0 under third_party/maplibre_gl, consumed via pubspec.yaml dependency_overrides (no longer from pub.dev). - Native MESHMAPPER GUARD in the camera handlers of MapLibreMapController (.swift / .java): bail out (still completing the method-channel result) when the map view has no usable size. - Dart defense-in-depth: _mapHasRenderedOnce (set on first onMapIdle) is folded into _canAnimateCamera, so no programmatic camera move is even attempted until the map has rendered once; the one-shot initial GPS zoom re-attempts on later ticks instead of burning. - analysis_options.yaml excludes third_party/** from our lints. - DEVELOPMENT.md documents the vendoring and the re-apply-on-upgrade step. --- DEVELOPMENT.md | 29 +- analysis_options.yaml | 4 + lib/widgets/map_widget.dart | 32 +- pubspec.lock | 7 +- pubspec.yaml | 12 + third_party/maplibre_gl/CHANGELOG.md | 669 +++++ third_party/maplibre_gl/LICENSE | 181 ++ third_party/maplibre_gl/README.md | 394 +++ third_party/maplibre_gl/analysis_options.yaml | 1 + third_party/maplibre_gl/android/build.gradle | 62 + .../maplibre_gl/android/gradle.properties | 2 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + .../maplibre_gl/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../java/org/maplibre/maplibregl/Convert.java | 325 +++ .../maplibregl/GlobalMethodHandler.java | 157 + .../maplibregl/LayerPropertyConverter.java | 659 +++++ .../maplibregl/LocationEngineFactory.kt | 38 + .../MapLibreCustomHttpInterceptor.java | 74 + .../maplibregl/MapLibreGPSLocationEngine.java | 140 + .../maplibregl/MapLibreHttpRequestUtil.java | 43 + .../maplibregl/MapLibreMapBuilder.java | 253 ++ .../maplibregl/MapLibreMapController.java | 2555 +++++++++++++++++ .../maplibregl/MapLibreMapFactory.java | 48 + .../maplibregl/MapLibreMapOptionsSink.kt | 56 + .../maplibregl/MapLibreMapsPlugin.java | 104 + .../maplibre/maplibregl/MapLibreUtils.java | 12 + .../maplibregl/OfflineChannelHandlerImpl.java | 55 + .../maplibregl/OfflineManagerUtils.java | 337 +++ .../maplibregl/SourcePropertyConverter.java | 232 ++ .../org/maplibre/maplibregl/setMapLanguage.kt | 31 + .../maplibre_gl/ios/maplibre_gl.podspec | 23 + .../maplibre_gl/ios/maplibre_gl/Package.swift | 30 + .../Sources/maplibre_gl/Constants.swift | 51 + .../Sources/maplibre_gl/Convert.swift | 229 ++ .../Sources/maplibre_gl/Enums.swift | 3 + .../Sources/maplibre_gl/Extensions.swift | 148 + .../maplibre_gl/LayerPropertyConverter.swift | 536 ++++ .../maplibre_gl/MGLMapView+setLanguage.swift | 55 + .../maplibre_gl/MapLibreCustomHeaders.swift | 39 + .../maplibre_gl/MapLibreMapController.swift | 2131 ++++++++++++++ .../maplibre_gl/MapLibreMapFactory.swift | 25 + .../maplibre_gl/MapLibreMapOptionsSink.swift | 23 + .../maplibre_gl/MapLibreMapsPlugin.swift | 170 ++ .../Sources/maplibre_gl/MethodCallError.swift | 98 + .../maplibre_gl/OfflineChannelHandler.swift | 69 + .../maplibre_gl/OfflineManagerUtils.swift | 101 + .../OfflinePackDownloadManager.swift | 223 ++ .../Sources/maplibre_gl/OfflineRegion.swift | 57 + .../maplibre_gl/OfflineRegionDefinition.swift | 56 + .../maplibre_gl/SourcePropertyConverter.swift | 241 ++ third_party/maplibre_gl/lib/maplibre_gl.dart | 109 + .../lib/src/annotation_manager.dart | 376 +++ .../maplibre_gl/lib/src/color_tools.dart | 12 + .../maplibre_gl/lib/src/controller.dart | 1721 +++++++++++ .../lib/src/download_region_status.dart | 25 + third_party/maplibre_gl/lib/src/global.dart | 136 + .../lib/src/layer_expressions.dart | 657 +++++ .../maplibre_gl/lib/src/layer_properties.dart | 2380 +++++++++++++++ .../maplibre_gl/lib/src/maplibre_map.dart | 563 ++++ .../maplibre_gl/lib/src/maplibre_styles.dart | 15 + .../maplibre_gl/lib/src/offline_region.dart | 76 + third_party/maplibre_gl/lib/src/util.dart | 14 + third_party/maplibre_gl/pubspec.yaml | 35 + 65 files changed, 16944 insertions(+), 7 deletions(-) create mode 100644 third_party/maplibre_gl/CHANGELOG.md create mode 100644 third_party/maplibre_gl/LICENSE create mode 100644 third_party/maplibre_gl/README.md create mode 100644 third_party/maplibre_gl/analysis_options.yaml create mode 100644 third_party/maplibre_gl/android/build.gradle create mode 100644 third_party/maplibre_gl/android/gradle.properties create mode 100644 third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 third_party/maplibre_gl/android/settings.gradle create mode 100644 third_party/maplibre_gl/android/src/main/AndroidManifest.xml create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt create mode 100644 third_party/maplibre_gl/ios/maplibre_gl.podspec create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Package.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift create mode 100644 third_party/maplibre_gl/lib/maplibre_gl.dart create mode 100644 third_party/maplibre_gl/lib/src/annotation_manager.dart create mode 100644 third_party/maplibre_gl/lib/src/color_tools.dart create mode 100644 third_party/maplibre_gl/lib/src/controller.dart create mode 100644 third_party/maplibre_gl/lib/src/download_region_status.dart create mode 100644 third_party/maplibre_gl/lib/src/global.dart create mode 100644 third_party/maplibre_gl/lib/src/layer_expressions.dart create mode 100644 third_party/maplibre_gl/lib/src/layer_properties.dart create mode 100644 third_party/maplibre_gl/lib/src/maplibre_map.dart create mode 100644 third_party/maplibre_gl/lib/src/maplibre_styles.dart create mode 100644 third_party/maplibre_gl/lib/src/offline_region.dart create mode 100644 third_party/maplibre_gl/lib/src/util.dart create mode 100644 third_party/maplibre_gl/pubspec.yaml diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 784e1b91..ae072d18 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -457,13 +457,40 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) - `geolocator`: GPS/Location -- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) +- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) — **vendored & patched**, see below - `hive`: Local storage - `provider`: State management - `http`: API requests - `pointycastle`: Encryption (AES-ECB, SHA-256) - `usb_serial`: USB Serial communication on Android (USB OTG) +### Vendored `maplibre_gl` (`third_party/maplibre_gl`) + +`maplibre_gl` is consumed from an in-repo copy of the pub.dev `0.25.0` release via +`dependency_overrides` in `pubspec.yaml`, **not** from pub. The ONLY delta from upstream is a +native camera-viewport guard. + +**Why:** MapLibre's transform unprojects against the live viewport. When the GL surface is +degenerate/zero-sized (e.g. a launch where tiles never finish loading, so the surface never renders +a real frame), the very first animated `flyTo`/`setCamera` makes `unproject` produce NaN, and +`mbgl::LatLng`'s constructor throws an **uncaught C++ `std::domain_error` → SIGABRT**. That throw +crosses the Obj-C++→Swift/JNI boundary and **cannot be caught from Dart**, so the only place it can +be reliably stopped is inside the plugin's camera handlers. + +**The patch:** the `camera#animate` / `camera#move` / `camera#ease` cases in +`MapLibreMapController.swift` (iOS) and `MapLibreMapController.java` (Android) bail (completing the +method-channel result so the Dart `await` returns) when the map view has no usable size +(`bounds.width/height < 1` / `getWidth()/getHeight() < 1`). Search the patch with the tag +`MESHMAPPER GUARD`. + +The Dart side (`map_widget.dart`) is defense-in-depth: `_mapHasRenderedOnce` (set on the first +`onMapIdle`) is folded into `_canAnimateCamera`, so no programmatic camera move is even attempted +until the map has rendered once; the one-shot initial GPS zoom re-attempts on later ticks instead of +burning. See the `_canAnimateCamera` getter and `_onMapIdle`. + +**On upgrade:** re-apply the `MESHMAPPER GUARD` blocks to the new plugin version (or drop the +override if upstream gains an equivalent guard). + ## Development Workflow Requirements ### Debug Logging Convention (MANDATORY) diff --git a/analysis_options.yaml b/analysis_options.yaml index 9fd3c2c4..3d54c641 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,3 +18,7 @@ linter: analyzer: errors: invalid_annotation_target: ignore + exclude: + # Vendored, patched copy of maplibre_gl (see pubspec.yaml dependency_overrides). + # Analyze it under its own options, not ours. + - third_party/** diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 8147141d..139f9354 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -453,9 +453,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // after resume lets the surface reach a valid state first. AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; bool _cameraAnimationReady = false; + + // Set true the first time the map reports idle (`_onMapIdle`). The map only + // goes idle AFTER it has actually rendered a frame / loaded tiles, so this is + // the throw-safe Dart signal that the native viewport is non-degenerate. The + // one-frame `_cameraAnimationReady` latch above proved insufficient: a bad + // launch (tiles never load → viewport stays degenerate for the whole session) + // kept _cameraAnimationReady=true yet still aborted the very first flyTo. Until + // the map renders, every programmatic camera move is held (and the one-shot + // initial zoom re-attempts on later ticks instead of burning). The native + // viewport guard in the vendored maplibre_gl plugin is the final backstop for + // the case where the map reports idle on a still-degenerate surface. + bool _mapHasRenderedOnce = false; + bool get _canAnimateCamera => _appLifecycleState == AppLifecycleState.resumed && - _cameraAnimationReady; + _cameraAnimationReady && + _mapHasRenderedOnce; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -1427,7 +1441,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } // Auto-follow: bundle pan, zoom, and bearing into one animateCamera call. - if (_autoFollow && _isMapReady && _cameraAnimationReady) { + // Gate on _canAnimateCamera (not just _cameraAnimationReady) so the first + // follow tick is also held until the map has rendered once — same backstop + // as the initial zoom against the degenerate-viewport flyTo abort. + if (_autoFollow && _isMapReady && _canAnimateCamera) { final newPosition = center; if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || @@ -2969,6 +2986,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// that the new tiles have rendered. void _onMapIdle() { _tileLoadTimeoutTimer?.cancel(); + + // First idle = the GL surface has rendered → the native viewport is now + // valid and camera animations are safe (see _mapHasRenderedOnce). Nudge a + // rebuild so the deferred one-shot initial zoom (held while the map was not + // yet rendered) re-attempts via build()'s _handleGpsPosition safety-net. + if (!_mapHasRenderedOnce && mounted) { + _mapHasRenderedOnce = true; + debugLog('[MAP] First map idle — camera animations enabled'); + setState(() {}); + } + if (_consecutiveTileLoadFailures > 0 && mounted) { if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { debugLog('[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); diff --git a/pubspec.lock b/pubspec.lock index 9a5dbf7a..c63f0b25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -707,10 +707,9 @@ packages: maplibre_gl: dependency: "direct main" description: - name: maplibre_gl - sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 - url: "https://pub.dev" - source: hosted + path: "third_party/maplibre_gl" + relative: true + source: path version: "0.25.0" maplibre_gl_platform_interface: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 80b57d4d..adba3007 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,18 @@ dev_dependencies: hive_generator: ^2.0.0 flutter_launcher_icons: ^0.14.3 +# Vendored, patched copy of maplibre_gl 0.25.0. The ONLY delta from the +# upstream pub.dev release is a native guard in the camera handlers +# (ios/.../MapLibreMapController.swift + android/.../MapLibreMapController.java) +# that refuses camera moves while the map view has no usable viewport — +# MapLibre's transform unprojects against the viewport and aborts the app with +# an uncaught C++ std::domain_error (SIGABRT) when it is degenerate/zero-sized. +# That throw cannot be caught from Dart, so the fix must live in the plugin. +# Re-apply the guard when bumping maplibre_gl. See DEVELOPMENT.md. +dependency_overrides: + maplibre_gl: + path: third_party/maplibre_gl + flutter_launcher_icons: android: true ios: true diff --git a/third_party/maplibre_gl/CHANGELOG.md b/third_party/maplibre_gl/CHANGELOG.md new file mode 100644 index 00000000..806122f1 --- /dev/null +++ b/third_party/maplibre_gl/CHANGELOG.md @@ -0,0 +1,669 @@ +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +### Added +* Logo customization options including visibility and position settings (#b4fb174). +* Explicit annotation manager initialization with clear error handling (#668). +* iOS: Attribution support for tile and raster sources with HTML link parsing. + +### Changed +* MapLibre Android SDK upgraded from `11.13.5` to `12.3.0` (#690). + - Includes synchronous GeoJSON source updates + - Support for MLT-format vector tile sources + - Better frustum offset support + - See [MapLibre Native Android 12.3.0 release notes](https://github.com/maplibre/maplibre-native/releases/tag/android-v12.3.0) +* OkHttp updated from `4.12.0` to `5.3.2` for Node.js 24 compatibility (#676, #700). +* Kotlin updated to `2.3.0` (#697, #698). +* Android Gradle Plugin updated to `8.13.2` (#695, #674). +* Android Application Plugin updated to `8.13.2` (#696, #689). +* GitHub Actions: `actions/checkout` updated from v5 to v6 (#672, #693). +* GitHub Actions: `actions/upload-artifact` updated from v4 to v6 (#688, #694). + +### Fixed +* Min/max zoom preference on iOS (#5230fab). +* `queryRenderedFeatures` now returns all targets when supplying empty layers list on iOS, aligning behavior with Android (#680). +* iOS: Enhanced LayerPropertyConverter to handle null values and improve expression parsing (#98660dc). +* Fixed `lineDasharray` and patterns reset to null in layer properties (#2b550ed). +* Improved MapLibreMapController disposing to prevent memory leaks. +* Removed unnecessary disposing of mapController in example app (#f989797). +* Fixed `setLayerProperties` and pattern images on web and Android (#9ce52a6). + - Pattern images now correctly converted to RGBA format on web + - Fixed mismatched image size error when loading pattern images + +### Refactor +* Complete refactor of example app with new UI and improved user experience (#ac877a4). +* Refactored `cameraTargetBounds` implementation on Android and iOS for consistent behavior (#8bcd74a). + +**Full Changelog**: [v0.24.1...v0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) + +## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) + +### Fixed +* Annotation tap call callbacks twice. (#652) +* Annotation APIs: use null-aware access for manager-backed collections (symbols, lines, circles, fills) to avoid null errors before style load. (#657) +* Add methods enforce explicit manager initialization with clear exceptions when style is not loaded. (#657) +* Calling add* before style load now fails fast with a clear Exception instead of risking null dereferences or silent failures. (#657) + +### Changed +* Rollback maplibre-gl to `4.7.1` version. (#660) + +### Added +* Added `onCameraMove` callback in the controller and in MapLibreMap class. (#643) + +## [0.24.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.23.0...v0.24.0) +> **Note**: This release has breaking changes.\ +> We apologize for the quick change in 0.24.0: this version definitively stabilizes the signatures of feature interaction callbacks. + +This release restores the **feature id** and makes the `Annotation` parameter **nullable** for all feature interaction callbacks (`tap` / `drag` / `hover`).\ +This unblocks interaction with style-layer features not managed by annotation managers (i.e. added via `addLayer*` / style APIs). + +### Breaking Changes + * **Tap**: `OnFeatureInteractionCallback` → `(Point point, LatLng coordinates, String id, String layerId, Annotation? annotation)`. + +* **Drag**: `OnFeatureDragCallback` → `(Point point, LatLng origin, LatLng current, LatLng delta, String id, Annotation? annotation, DragEventType eventType)`. + +* **Hover**: `OnFeatureHoverCallback` → `Point point, LatLng coordinates, String id, Annotation? annotation, HoverEventType eventType)`. + +* **Update existing listeners**: The short‑lived 0.23.0-only signatures (without `id`) are removed. + * For unmanaged style layer features `annotation` is `null` (`unmanaged` means sources/layers you add via style APIs like `addGeoJsonSource` + `addSymbolLayer`). + * For managed annotations it is the `Annotation` object. + +### Reasoning +In 0.23.0 the move to annotation objects inadvertently dropped interaction for unmanaged style features. Reintroducing `id` (and making `annotation` nullable) normalizes all three interaction paths without creating phantom annotation wrappers. + +### Migration Example +Before (0.23.0): +``` +controller.onFeatureTapped.add((p, latLng, annotation, layerId) { + print(annotation.id); +}); +``` +After (>=0.24.0): +``` +controller.onFeatureTapped.add((p, latLng, id, layerId, annotation) { + print('feature id=$id managed=${annotation != null}'); +}); +``` + +### Refactor / Quality +* (web) Refactored `onMapClick` (degenerate bbox + interactive layer filter) to surface features inserted via style APIs (unmanaged style-layer features) in `onFeatureTapped` (previously skipped; returned now with `id`, `layerId` and `annotation = null`) (#646). +* (web) Ensure map container stretches vertically by adding `style.height = '100%'` to the registered div (prevents occasional zero-height layout issues in flexible parents) (#641) + +**Full Changelog**: [v0.23.0...v0.24.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.23.0...v0.24.0) + +## [0.23.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.22.0...v0.23.0) +> **Note**: This release has breaking changes. + +This release aligns the plugin with the latest MapLibre Native (Android 11.9.0 / iOS 6.14.0), introduces runtime style switching APIs, hover interaction callbacks, and several annotation interaction improvements. It also contains a small breaking change for feature interaction callbacks. + +A big thank you to everyone who contributed to this update! + +### Breaking Changes +* `onFeatureDrag` / `onFeatureTapped` callback signatures now provide an `Annotation annotation` object instead of an `id` parameter. Update your handlers to remove the `id` argument and use `annotation.id` (or other annotation fields) as needed. + +### Highlights +* Runtime style switching via controller (`setStyle…`) without tearing down the map (#444, #603). +* Hover interaction events (`onFeatureHover`) for richer desktop/web UX (#614). +* Improved event handling reliability (cancellation & consumption fixes) (#621, #623). +* Offline region download crash fix in example (#569) and style loaded safety checks (#563). +* Updated MapLibre Native bringing PMTiles & performance improvements (#552, #582). + +### Added / Updated +* **Feature:** added set style method on controller (#444) & support setting raw style JSON on iOS/web (#603). +* **Feature:** expose hovering events (`onFeatureHover`) (#614). +* **Update:** bump Android to 11.9.0 & iOS to 6.14.0 (#582). +* **Update:** update maplibre-native to the latest versions / PMTiles support (#552). +* **CI/Tooling:** upgrade Flutter Gradle Plugin & compatibility with Flutter 3.29.0 (#542). + +### Fixed +* iOS code generation: corrected handling of Offset / Translate / expression arrays in generated bindings (#481). +* Annotation tap consumption now respected (`annotationConsumeTapEvents`). +* Prevent calling `notifyListeners()` after controller disposal (#621). +* Web: event listener cancellation & hover handling robustness (#623). +* Example: offline region download crash (#569). +* Added style readiness checks before access (#563). + +### Refactor / Quality +* Enable and fix additional lint rules to enforce consistency (#452). + +**Full Changelog**: [v0.22.0...v0.23.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.22.0...v0.23.0) + +## [0.22.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.21.0...v0.22.0) + +### Breaking changes + +* Updated maplibre-native for iOS to v6.14.0. This mainly introduces PMTiles support. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/ios/CHANGELOG.md#6121) + for more information. +* Updated maplibre-native for Android to v11.9.0. This mainly introduces PMTiles support. + Flutter version packed with OpenGL ES 3.0 build for now, later we could probably switch to Vulkan. + See the [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/android/CHANGELOG.md#1181) + for more information. +* queryRenderedFeaturesInRect support string feature ids on web (#576). + +### Changed + +* Added `await` to all `addLayer` calls (#558). + +### Fixed + +* Fixed `Unsupported operation` error on web (#551). + +## [0.21.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.20.0...v0.21.0) + +### Added + +* added the `clearAmbientCache` functionality (#502). +* added the `contains` functionality to `LatLngBounds` (#498). +* added the possibility to set `LocationEnginePlatforms` properties for better device tracking on Android (#510). + +### Changed + +* BREAKING: `onFeatureTap` returns the `layerId` (#475). +* Changed iOS package name to support Swift Package Manager (#467). +* Move the `maplibre_gl` package to a subdirectory of the repository and add + melos to orchestrate all packages (#453). + +### Removed + +* Removed support for Dart SDKs older than `3.4.0` (`Flutter SDK 3.22.0`) (#542) + +### Fixed + +* Fixed exception when destroying mapView on Android by reordering cleanup (#459). + + +## 0.20.0 + +A lot of files/classes have been renamed and moved around in this release. +If you notice any build errors, please make sure to run `flutter clean`. + +### Breaking changes + +* All Dart enums have been migrated from mixed cases to lower camelcase + according to the `camel_case_types` lint rule. +* Move `MapLibreStyles` to the main `maplibre_gl` package. You can now use the + demo style without adding `maplibre_gl_platform_interface` as a dependency. +* Updated maplibre-native for ios to v6.5.0. This introduces the new + iOS Metal renderer and the OpenGL ES renderer now uses OpenGL ES 3.0. Only + iOS Devices with an Apple A7 GPU or later are supported onwards. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/ios/CHANGELOG.md#600) + for more information. +* Updated maplibre-native for android to v11.0.0. This version uses + OpenGL ES 3.0. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/android/CHANGELOG.md#1100) + for more information. +* Renamed the method channel to `plugins.flutter.io/maplibre_gl_*` in all + packages. +* Renamed "Maplibre" to "MapLibre" to be in line with maplibre-native + (affects for example the classes `MaplibreMap` and `MaplibreMapController`). + +### Changes + +* Added support for Swift Package Manager usage on iOS. +* Migrated main iOS plugin class from Objective-C to Swift. +* Renamed iOS plugin classes from `Mapbox` to `MapLibre`. +* Removed support for Kotlin versions older than `1.9.0` (#460). + +**Full Changelog**: +[v0.19.0+2...v0.20.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.19.0+2...v0.20.0) + +## 0.19.0 + +This is the first version where all packages are published on pub.dev. Please +use the `maplibre_gl` package directly from pub.dev from now on. Check the +`README` documentation on how to include it in your project. + +### Changes + +* Bump min Dart SDK to 3.0.0 (this was already implicitly required by transitive + dependencies) +* Prepare all packages for publishing + to [pub.dev](https://pub.dev/packages/maplibre_gl) +* Add support for + the [Fill Extrusion layer](https://maplibre.org/maplibre-style-spec/layers/#fill-extrusion) +* Add support for + the [Heatmap layer](https://maplibre.org/maplibre-style-spec/layers/#heatmap) +* Update documentation +* (android) Bump Android `compileSdkVersion` to 34 +* (iOS) Add `iosLongClickDuration` as parameter to customize the long click + duration. +* (web) Loosen the dependency constraint of [js](https://pub.dev/packages/js) to + allow `0.6.x` and `0.7.x`. + +### Bug Fixes + +* (android) Fix support for newer gradle versions (Add support for Gradle/AGP + namespace configuration) +* (android) Fix `NullPointerException` when changing the visibility of a layer ( + layer#setVisibility) +* (android) Fix enum parsing for the `onCameraTracking` callback +* (iOS) Fix tap detection on features if the feature id is null +* (web) Fix flickering when the style takes time to load + +**Full Changelog**: +[0.18.0...v0.19.0](https://github.com/maplibre/flutter-maplibre-gl/compare/0.18.0...v0.19.0) + +## 0.18.0 + +### Breaking Change: + +Already since 0.17.0, developers do not need to adapt their Podfile for iOS apps +anymore as it was previously described in the Readme. Developers who previously +added these lines should remove them, since not removing these lines may cause a +build failure on iOS. (This change actually already landed in 0.17.0, but it may +not have been sufficiently clear that not removing these lines might break +builds). + +### Other Changes: + +* new feature: set arbitrary layer properties by @m0nac0 + in [#303](https://github.com/maplibre/flutter-maplibre-gl/pull/303) +* Update release process by @m0nac0 + in [#315](https://github.com/maplibre/flutter-maplibre-gl/pull/315) +* Add workflows for automated publishing to pub.dev by @m0nac0 + in [#328](https://github.com/maplibre/flutter-maplibre-gl/pull/328) +* Fix example app pubspec by @m0nac0 + in [#329](https://github.com/maplibre/flutter-maplibre-gl/pull/329) +* Updated location plugin version for example app by @varunlohade + in [#334](https://github.com/maplibre/flutter-maplibre-gl/pull/334) +* Housekeeping: Improve docs and update outdated references to point to MapLibre + by @m0nac0 in [#330](https://github.com/maplibre/flutter-maplibre-gl/pull/330) + +**Full Changelog**: +[0.17.0...0.18.0](https://github.com/maplibre/flutter-maplibre-gl/compare/0.17.0...0.18.0) + +## 0.17.0 + +* **Repository transfer**: The project repository was transferred to the + MapLibre GitHub organization. More information + at [#221](https://github.com/maplibre/flutter-maplibre-gl/issues/221) +* Developers do not need to adapt their Podfile for iOS apps anymore as it was + previously described in the + Readme. [#278](https://github.com/maplibre/flutter-maplibre-gl/pull/278) + +### Breaking Change: + +* `maplibre_gl/mapbox_gl.dart` was renamed to `maplibre_gl/maplibre_gl.dart`. + You can do a replace-all from `import 'package:maplibre_gl/mapbox_gl.dart';` + to `import 'package:maplibre_gl/maplibre_gl.dart';` +* `useDelayedDisposal` was removed since its now fixed + in [#259](https://github.com/maplibre/flutter-maplibre-gl/pull/259) +* `useHybridCompositionOverride` was removed since it was added in the following + fix: [#203](https://github.com/maplibre/flutter-maplibre-gl/pull/203) and we + reverted + the fix and used another approach to fix the actual issue. +* The default for `myLocationRenderMode` was changed from `COMPASS` to `NORMAL` + in [#244](https://github.com/maplibre/flutter-maplibre-gl/pull/244), since the + previous default value of `COMPASS` implicitly enables displaying the location + on iOS, which could crash apps that didn't want to display the device + location. If you want to continue to use `MyLocationRenderMode.COMPASS`, + please explicitly specify it in the constructor like this: + +```dart +@override +Widget build() { + return MapLibreMap( + myLocationRenderMode: MyLocationRenderMode.COMPASS, + // ... + ); +} +``` + +* The old api `registerWith` was removed from the MapboxMapsPlugin.java, since + there is no need for that. +* The `minSdkVersion` was bumped to at least 21 now, since the native android + sdk constraint expect that. +* Changed the minimum Dart version from sdk: `2.12.0` to `2.14.0` + in `maplibre_gl_platform_interface/pubspec.yaml`. + +### Further changes + +Note: This list only contains a subset of all contributions, notably excluding +those that e.g. only affect the GitHub Actions CI or documentation. See the link +at the end for a full changelog. + +* feat: add support for reading style json from file in ios by @TimAlber + in [#132](https://github.com/maplibre/flutter-maplibre-gl/pull/132) +* Add podspecs in correct Cocoapods layout by @kuhnroyal + in [#128](https://github.com/maplibre/flutter-maplibre-gl/pull/128) +* fix: fix the queryRenderedFeatures code on iOS by @TimAlber + in [#137](https://github.com/maplibre/flutter-maplibre-gl/pull/137) +* feat: Set layer visibility by @m0nac0 + in [#138](https://github.com/maplibre/flutter-maplibre-gl/pull/138) +* feat: add support for changing the receiver’s viewport to fit given bounds by + @TimAlber in [#133](https://github.com/maplibre/flutter-maplibre-gl/pull/133) +* Change feature JSON encoding from .ascii to .utf8 by @SunBro-Marko + in [#142](https://github.com/maplibre/flutter-maplibre-gl/pull/142) +* web: implement setCameraBounds by @m0nac0 + in [#145](https://github.com/maplibre/flutter-maplibre-gl/pull/145) +* Use offical maplibre-gl.js and add README info by @Robbendebiene + in [#163](https://github.com/maplibre/flutter-maplibre-gl/pull/163) +* android: adding tileSize to raster source by @mariusvn + in [#166](https://github.com/maplibre/flutter-maplibre-gl/pull/166) +* Readme: document git default values for codespaces by @m0nac0 + in [#170](https://github.com/maplibre/flutter-maplibre-gl/pull/170) +* query source features by @Grodien + in [#154](https://github.com/maplibre/flutter-maplibre-gl/pull/154) +* Trimming styleString to simplify the JSON detection by @mariusvn + in [#175](https://github.com/maplibre/flutter-maplibre-gl/pull/175) +* Fix getVisibleRegion method by @BartoszStasiurka + in [#179](https://github.com/maplibre/flutter-maplibre-gl/pull/179) +* Reenable textureMode which was disabled in f8b2d1 by @maxammann + in [#194](https://github.com/maplibre/flutter-maplibre-gl/pull/194) +* android: Bump MapLibre SDK to 9.6.0 & OkHttp to 4.9.3 by @mariusvn + in [#184](https://github.com/maplibre/flutter-maplibre-gl/pull/184) +* Added getSourceIds to the controller by @mariusvn + in [#197](https://github.com/maplibre/flutter-maplibre-gl/pull/197) +* Moved EventChannel creation in the downloadOfflineRegion method by @mariusvn + in [#205](https://github.com/maplibre/flutter-maplibre-gl/pull/205) +* Fix crash android dispose nullpointerdereference by @GaelleJoubert + in [#203](https://github.com/maplibre/flutter-maplibre-gl/pull/203) +* Migrate links in README, pubspec to MapLibre by @kuhnroyal + in [#224](https://github.com/maplibre/flutter-maplibre-gl/pull/224) +* Update LICENSE file by @mariusvn + in [#230](https://github.com/maplibre/flutter-maplibre-gl/pull/230) +* upgrade dependency image by @m0nac0 + in [#248](https://github.com/maplibre/flutter-maplibre-gl/pull/248) +* fix-example-app by @JulianBissekkou + in [#261](https://github.com/maplibre/flutter-maplibre-gl/pull/261) +* 162-animate-camera-on-web-fix by @JulianBissekkou + in [#254](https://github.com/maplibre/flutter-maplibre-gl/pull/254) +* 243-fix-crash-when-no-location-permission by @JulianBissekkou + in [#244](https://github.com/maplibre/flutter-maplibre-gl/pull/244) +* 182-disposal-null-ref-crash by @JulianBissekkou + in [#259](https://github.com/maplibre/flutter-maplibre-gl/pull/259) +* New android sdk version by @stefanschaller + in [#270](https://github.com/maplibre/flutter-maplibre-gl/pull/270) +* 250-change-language-fixes by @stefanschaller + in [#275](https://github.com/maplibre/flutter-maplibre-gl/pull/275) +* upgrade-ios-version by @JulianBissekkou + in [#277](https://github.com/maplibre/flutter-maplibre-gl/pull/277) +* Simplify iOS usage instructions and example podfile by @m0nac0 + in [#278](https://github.com/maplibre/flutter-maplibre-gl/pull/278) +* Add opportunity to use map in widget tests by @ManoyloK + in [#281](https://github.com/maplibre/flutter-maplibre-gl/pull/281) +* fix-layers-prod-build by @stefanschaller + in [#291](https://github.com/maplibre/flutter-maplibre-gl/pull/291) +* Fix the codespace by upgrading the docker image by @ouvreboite + in [#297](https://github.com/maplibre/flutter-maplibre-gl/pull/297) +* Add `updateImageSource`. by @CaviarChen + in [#271](https://github.com/maplibre/flutter-maplibre-gl/pull/271) +* fix "unexpected null value" error when onStyleLoadedCallback is null by + @m0nac0 in [#307](https://github.com/maplibre/flutter-maplibre-gl/pull/307) +* attributionButtonPosition for web by @ouvreboite + in [#304](https://github.com/maplibre/flutter-maplibre-gl/pull/304) + +**Full Changelog**: +https://github.com/maplibre/flutter-maplibre-gl/compare/0.16.0...0.17.0 + +## 0.16.0, Jun 28, 2022 + +* cherry-picked all commits from upstream up + to [https://github.com/flutter-mapbox-gl/maps/commit/3496907955cd4b442e4eb905d67e8d46692174f1), + including up to release 0.16.0 from upstream +* updated MapLibre GL JS for web + +## 0.15.1, May 24, 2022 + +* cherry-picked all commits from upstream up + to [upstream release 0.15.0](https://github.com/flutter-mapbox-gl/maps/releases/tag/0.15.0) +* improved documentation +* betted adapted the example app to MapLibre +* hide logo on Android/iOS to match web + +## 0.15.0, Oct 26, 2021 + +* Fix bug when changing line color (see #448) by @vberthet + in [#15](https://github.com/m0nac0/flutter-maplibre-gl/pull/15) +* Remove unnecessary imports by @m0nac0 + in [#34](https://github.com/m0nac0/flutter-maplibre-gl/pull/34) +* Update example with Flutter 2.5.3 by @kuhnroyal + in [#35](https://github.com/m0nac0/flutter-maplibre-gl/pull/35) +* CI: Use separate scheduled pipeline for Flutter beta builds by @kuhnroyal + in [#28](https://github.com/m0nac0/flutter-maplibre-gl/pull/28) +* Null safety (cherry-pick from upstream) by @m0nac0 + in [#31](https://github.com/m0nac0/flutter-maplibre-gl/pull/31) +* [web] add missing removeLines, removeCircles and removeFills (cherry-pick + tobrun#622) by @m0nac0 + in [#32](https://github.com/m0nac0/flutter-maplibre-gl/pull/32) +* Replace style string in local style example by @m0nac0 + in [#33](https://github.com/m0nac0/flutter-maplibre-gl/pull/33) +* [web] add getSymbolLatLng and getLineLatLngs by @m0nac0 + in [#37](https://github.com/m0nac0/flutter-maplibre-gl/pull/37) + +## 0.14.0 + +### Breaking changes: + +* Remove access token, update libraries, replace example + styles [#25](https://github.com/m0nac0/flutter-maplibre-gl/pull/25) (also + see [#21](https://github.com/m0nac0/flutter-maplibre-gl/issues/21)) + * The parameter `accessToken` of class `MaplibreMap` was removed. If you + want to continue using a tile provider that requires an API key, specify + that key directly in the URL of the tile source ( + see [https://github.com/m0nac0/flutter-maplibre-gl#tile-sources-requiring-an-api-key](https://github.com/m0nac0/flutter-maplibre-gl#tile-sources-requiring-an-api-key)) + * The built-in constants for specific styles were also removed. You can + continue using these styles by using the styles' URL + +### Other changes: + +* Remove warning about missing access token on + Android [#22](https://github.com/m0nac0/flutter-maplibre-gl/pull/22) +* Example: use maplibre styles and add new demo + style [#23](https://github.com/m0nac0/flutter-maplibre-gl/pull/23) +* Add about button to example + app [#26](https://github.com/m0nac0/flutter-maplibre-gl/pull/26) +* various improvements to the CI +* fixed formatting for some files that were not correctly formatted + +## 0.13.0, Oct 6, 2021 + +🎉 The first release of flutter-maplibre-gl with the complete transition to +MapLibre libraries. 🎉 + +Further improvements: + +* Update to MapLibre-Android-SDK 9.4.2 +* Update to MapLibre-iOS-SDK 5.12.0 +* Fix onUserLocationUpdated not firing on + android [#14](https://github.com/m0nac0/flutter-maplibre-gl/pull/14) +* Add speed to + UserLocation [#11](https://github.com/m0nac0/flutter-maplibre-gl/pull/11) +* Fix queryRenderedFeaturesInRect for + iOS [#10](https://github.com/m0nac0/flutter-maplibre-gl/pull/10) + +### Changes cherry-picked/ported from tobrun/flutter-mapbox-gl:0.12.0 + +* Batch creation/removal for circles, fills and + lines [#576](https://github.com/tobrun/flutter-mapbox-gl/pull/576) +* Dependencies: updated image + package [#598](https://github.com/tobrun/flutter-mapbox-gl/pull/598) +* Improve description to enable location + features [#596](https://github.com/tobrun/flutter-mapbox-gl/pull/596) +* Fix feature manager on release + build [#593](https://github.com/tobrun/flutter-mapbox-gl/pull/593) +* Emit onTap only for the feature above the + others [#589](https://github.com/tobrun/flutter-mapbox-gl/pull/589) +* Add annotationOrder to + web [#588](https://github.com/tobrun/flutter-mapbox-gl/pull/588) + +### Changes cherry-picked/ported from tobrun/flutter-mapbox-gl:0.11.0 + +* Fixed issues caused by new android + API [#544](https://github.com/tobrun/flutter-mapbox-gl/pull/544) +* Add option to set maximum offline tile + count [#549](https://github.com/tobrun/flutter-mapbox-gl/pull/549) +* Fixed web build failure due to http package + upgrade [#550](https://github.com/tobrun/flutter-mapbox-gl/pull/550) +* Update OfflineRegion/OfflineRegionDefinition interfaces, synchronize with iOS + and Android [#545](https://github.com/tobrun/flutter-mapbox-gl/pull/545) +* Fix Mapbox GL JS CSS embedding on + web [#551](https://github.com/tobrun/flutter-mapbox-gl/pull/551) +* Update Podfile to fix iOS + CI [#565](https://github.com/tobrun/flutter-mapbox-gl/pull/565) +* Update deprecated patterns to fix CI static + analysis [#568](https://github.com/tobrun/flutter-mapbox-gl/pull/568) +* Add setOffline method on + Android [#537](https://github.com/tobrun/flutter-mapbox-gl/pull/537) +* Add batch mode of screen + locations [#554](https://github.com/tobrun/flutter-mapbox-gl/pull/554) +* Define which annotations consume the tap + events [#575](https://github.com/tobrun/flutter-mapbox-gl/pull/575) +* Remove failed offline region + downloads [#583](https://github.com/tobrun/flutter-mapbox-gl/pull/583) + +## Below is the original changelog of the tobrun/flutter-mapbox-gl project, before the fork. + +## 0.10.0, February 12, 2020 + +* Merge offline + regions [#532](https://github.com/tobrun/flutter-mapbox-gl/pull/532) +* Update offline region + metadata [#530](https://github.com/tobrun/flutter-mapbox-gl/pull/530) +* Added web support for + fills [#501](https://github.com/tobrun/flutter-mapbox-gl/pull/501) +* Support styleString as "Documents directory/Temporary + directory" [#520](https://github.com/tobrun/flutter-mapbox-gl/pull/520) +* Use offline region + ids [#491](https://github.com/tobrun/flutter-mapbox-gl/pull/491) +* Ability to define annotation layer + order [#523](https://github.com/tobrun/flutter-mapbox-gl/pull/523) +* Clear fills API [#527](https://github.com/tobrun/flutter-mapbox-gl/pull/527) +* Add heading to UserLocation and expose UserLocation + type [#522](https://github.com/tobrun/flutter-mapbox-gl/pull/522) +* Patch addFill with data + parameter [#524](https://github.com/tobrun/flutter-mapbox-gl/pull/524) +* Fix style annotation is not deselected on + iOS [#512](https://github.com/tobrun/flutter-mapbox-gl/pull/512) +* Update tracked camera position in + camera#onIdle [#500](https://github.com/tobrun/flutter-mapbox-gl/pull/500) +* Fix iOS implementation of map#toLatLng on + iOS [#495](https://github.com/tobrun/flutter-mapbox-gl/pull/495) +* Migrate to new Android flutter plugin + architecture [#488](https://github.com/tobrun/flutter-mapbox-gl/pull/488) +* Update readme to fix + UnsatisfiedLinkError [#422](https://github.com/tobrun/flutter-mapbox-gl/pull/442) +* Improved Image Source + Support [#469](https://github.com/tobrun/flutter-mapbox-gl/pull/469) +* Avoid white space when resizing map on + web [#474](https://github.com/tobrun/flutter-mapbox-gl/pull/474) +* Allow MapboxMap() to override Widget + Key. [#475](https://github.com/tobrun/flutter-mapbox-gl/pull/475) +* Offline region + feature [#336](https://github.com/tobrun/flutter-mapbox-gl/pull/336) +* Fix iOS symbol tapped + interaction [#443](https://github.com/tobrun/flutter-mapbox-gl/pull/443) + +## 0.9.0, October 24. 2020 + +* Fix data parameter for addLine and + addCircle [#388](https://github.com/tobrun/flutter-mapbox-gl/pull/388) +* Re-enable attribution on + Android [#383](https://github.com/tobrun/flutter-mapbox-gl/pull/383) +* Upgrade annotation plugin to + v0.9 [#381](https://github.com/tobrun/flutter-mapbox-gl/pull/381) +* Breaking change: CameraUpdate.newLatLngBounds() now supports setting different + padding values for left, top, right, bottom with default of 0 for all. + Implementations using the old approach with only one padding value for all + edges have to be + updated. [#382](https://github.com/tobrun/flutter-mapbox-gl/pull/382) +* web:ignore myLocationTrackingMode if myLocationEnabled is + false [#363](https://github.com/tobrun/flutter-mapbox-gl/pull/363) +* Add methods to access + projection [#380](https://github.com/tobrun/flutter-mapbox-gl/pull/380) +* Add fill API support for Android and + iOS [#49](https://github.com/tobrun/flutter-mapbox-gl/pull/49) +* Listen to OnUserLocationUpdated to provide user location to + app [#237](https://github.com/tobrun/flutter-mapbox-gl/pull/237) +* Correct integration in Activity lifecycle on + Android [#266](https://github.com/tobrun/flutter-mapbox-gl/pull/266) +* Add support for custom font stackn in symbol + options [#359](https://github.com/tobrun/flutter-mapbox-gl/pull/359) +* Fix memory leak on iOS caused by strong self + reference [#370](https://github.com/tobrun/flutter-mapbox-gl/pull/370) +* Basic ImageSource + Support [#409](https://github.com/tobrun/flutter-mapbox-gl/pull/409) +* Get meters per pixel at + latitude [#416](https://github.com/tobrun/flutter-mapbox-gl/pull/416) +* Fix + onStyleLoadedCallback [#418](https://github.com/tobrun/flutter-mapbox-gl/pull/418) + +## 0.8.0, August 22, 2020 + +- implementation of feature + querying [#177](https://github.com/tobrun/flutter-mapbox-gl/pull/177) +- Batch create/delete of + symbols [#279](https://github.com/tobrun/flutter-mapbox-gl/pull/279) +- Add multi map + support [#315](https://github.com/tobrun/flutter-mapbox-gl/pull/315) +- Fix OnCameraIdle not being + invoked [#313](https://github.com/tobrun/flutter-mapbox-gl/pull/313) +- Fix android zIndex symbol + option [#312](https://github.com/tobrun/flutter-mapbox-gl/pull/312) +- Set dependencies from + git [#319](https://github.com/tobrun/flutter-mapbox-gl/pull/319) +- Add line#getGeometry and + symbol#getGeometry [#281](https://github.com/tobrun/flutter-mapbox-gl/pull/281) + +## 0.7.0, June 6, 2020 + +* Introduction of mapbox_gl_platform_interface library +* Introduction of mapbox_gl_web library +* Integrate web support through mapbox-gl-js +* Add icon-allow-overlap configurations + +## 0.0.6, May 31, 2020 + +* Update mapbox depdendency to 9.2.0 (android) and 5.6.0 (iOS) +* Long press handlers for both iOS as Android +* Change default location tracking to none +* OnCameraIdle listener support +* Add image to style +* Add animation duration to animateCamera +* Content insets +* Visible region support on iOS +* Numerous bug fixes + +## 0.0.5, December 21, 2019 + +* iOS support for annotation extensions (circle, symbol, line) +* Update SDK to 8.5.0 (Android) and 5.5.0 (iOS) +* Integrate style loaded callback api +* Add Map click event (iOS) +* Cache management API (Android/iOS) +* Various fixes to showing user location and configurations (Android/iOS) +* Last location API (Android) +* Throttle max FPS of user location component (Android) +* Fix for handling permission handling of the test application (Android) +* Support for loading symbol images from assets (iOS/Android) + +## v0.0.4, Nov 2, 2019 + +* Update SDK to 8.4.0 (Android) and 5.4.0 (iOS) +* Add support for sideloading offline maps (Android/iOS) +* Add user tracking mode (iOS) +* Invert compassView.isHidden logic (iOS) +* Specific swift version (iOS) + +## v0.0.3, Mar 30, 2019 + +* Camera API (iOS) +* Line API (Android) +* Update codebase to AndroidX +* Update Mapbox Maps SDK for Android to v7.3.0 + +## v0.0.2, Mar 23, 2019 + +* Support for iOS +* Migration to embedded Android and iOS SDK View system +* Style URL API +* Style JSON API (Android) +* Gesture support +* Gesture restrictions (Android) +* Symbol API (Android) +* Location component (Android) +* Camera API (Android) + +## v0.0.1, May 7, 2018 + +* Initial Android surface rendering POC diff --git a/third_party/maplibre_gl/LICENSE b/third_party/maplibre_gl/LICENSE new file mode 100644 index 00000000..daaf99df --- /dev/null +++ b/third_party/maplibre_gl/LICENSE @@ -0,0 +1,181 @@ +flutter-maplibre-gl Copyright (c) 2023, MapLibre contributors. +flutter-maplibre-gl Copyright (c) 2021, m0nac0. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project was forked from (and therefore contains a lot of modified and unmodified code from) https://github.com/tobrun/flutter-mapbox-gl which at the time of the fork (March 6th, 2021) contained the following LICENSE file: +(Additionally more changes / code was later cherry-picked / ported from that repository (https://github.com/tobrun/flutter-mapbox-gl), while the license continued to be the same as at the time of the fork, which is:) +------------------------------------------------------------------------------------------------------ + +flutter-mapbox-gl copyright (c) 2018, Mapbox. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project uses the MapLibre GL native libraries for Android and iOS from https://github.com/maplibre/maplibre-gl-native, which contain the following LICENSE: +------------------------------------------------------------------------------------------------------ +BSD 2-Clause License + +Copyright (c) 2021 MapLibre contributors + +Copyright (c) 2018-2021 MapTiler.com + +Copyright (c) 2014-2020 Mapbox + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project uses MapLibre GL JS library from https://github.com/maplibre/maplibre-gl-js, which contains the following LICENSE: +------------------------------------------------------------------------------------------------------ +Copyright (c) 2020, MapLibre contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of MapLibre GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from mapbox-gl-js v1.13 and earlier + +Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license + +Copyright (c) 2020, Mapbox +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Mapbox GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from glfx.js + +Copyright (C) 2011 by Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-------------------------------------------------------------------------------- + +Contains a portion of d3-color https://github.com/d3/d3-color + +Copyright 2010-2016 Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/maplibre_gl/README.md b/third_party/maplibre_gl/README.md new file mode 100644 index 00000000..7d323c9f --- /dev/null +++ b/third_party/maplibre_gl/README.md @@ -0,0 +1,394 @@ +

+ + + + MapLibre Logo + +

+ +# Flutter MapLibre GL + +[![Pub Version](https://img.shields.io/pub/v/maplibre_gl)](https://pub.dev/packages/maplibre_gl) +[![likes](https://img.shields.io/pub/likes/maplibre_gl?logo=flutter)](https://pub.dev/packages/maplibre_gl) +[![Pub Points](https://img.shields.io/pub/points/maplibre_gl)](https://pub.dev/packages/maplibre_gl/score) +[![stars](https://badgen.net/github/stars/maplibre/flutter-maplibre-gl?label=stars&color=green&icon=github)](https://github.com/josxha/flutter-maplibre-gl/stargazers) +[![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) + +Flutter MapLibre GL lets you embed **interactive, vector-tile based, fully styleable maps** directly inside your Flutter app across mobile & web. + +This project is a fork of [flutter-mapbox-gl](https://github.com/tobrun/flutter-mapbox-gl), replacing its usage of Mapbox GL libraries with the open source [MapLibre GL](https://github.com/maplibre) libraries. + +--- + +### Table of Contents + +
+Click to expand + + +* [Features overview](#features-overview) +* [Why MapLibre?](#why-maplibre) +* [Supported Platforms & API Coverage](#supported-platforms--api-coverage) +* [Installation](#installation) + * [Platform Setup](#platform-setup) + * [iOS](#ios) + * [Android](#android) + * [Web](#web) +* [Quick Start](#quick-start) +* [Usage Highlights](#usage-highlights) + * [Camera](#camera) + * [Annotations & Layers](#annotations--layers) + * [Map Styles](#map-styles) + * [Sources needing API keys](#tile-sources-requiring-an-api-key) +* [Advanced Topics](#advanced-topics) + * [Offline usage / mbtiles](#offline-usage--mbtiles) + * [PMTiles](#pmtiles) + * [Expressions & Styling](#expressions--styling) + * [Generated Code](#generated-code) + * [Architecture Overview](#architecture-overview) +* [Migration from flutter-mapbox-gl](#migration-from-flutter-mapbox-gl) +* [Performance Tips](#performance-tips) +* [Security & API Keys](#security--api-keys) +* [Troubleshooting / FAQ](#troubleshooting--faq) +* [Documentation & Examples](#documentation--examples) +* [Versioning & Changelog](#versioning--changelog) +* [Contributing](#contributing) +* [Getting Help](#getting-help) +* [License](#license) + + +
+ +--- + +## Features overview + +| Capability | Description | +|------------|-------------| +| Vector tile rendering | High quality, styleable vector maps via MapLibre engines | +| Dynamic styling | Swap or mutate styles at runtime, apply expressions | +| Camera control | Smooth programmatic & gesture driven map camera updates | +| User location | Native location indicator & tracking (permissions required) | +| Annotations | Symbols, circles, lines, fills, fill extrusions, heatmaps | +| Web support | Backed by `maplibre-gl-js` for web targets | +| Offline friendly | Patterns for mbtiles / cached assets (see advanced topics) | +| Extensible | Separate platform interface & web implementation packages | + +--- + +## Why MapLibre? + +MapLibre is a **vendor-neutral, open source** set of mapping libraries born from the community. Using MapLibre helps you: + +* Avoid vendor lock-in & proprietary billing tie-ins +* Host your own tiles or mix commercial/open sources +* Keep transparent, auditable code in production +* Align with OSS licensing and long-term sustainability +* One of the fastest map SDKs available. Capable of drawing a large number of vector objects. + +If you previously used `flutter-mapbox-gl`, you can migrate with minimal changes (see [migration guide](#migration-from-flutter-mapbox-gl)). + +--- + +## Supported Platforms & API Coverage + +Underlying engines: +* **Android / iOS** — [maplibre-native](https://github.com/maplibre/maplibre-native) +* **Web** — [maplibre-gl-js](https://github.com/maplibre/maplibre-gl-js) + +> **Note**: Only a subset of the native SDK APIs are currently exposed. PRs to extend surface area are welcome. + +| Feature | Android | iOS | Web | +|----------------|:-------:|:---:|:---:| +| Style | ✅ | ✅ | ✅ | +| Camera | ✅ | ✅ | ✅ | +| Gesture | ✅ | ✅ | ✅ | +| User Location | ✅ | ✅ | ✅ | +| Symbol | ✅ | ✅ | ✅ | +| Circle | ✅ | ✅ | ✅ | +| Line | ✅ | ✅ | ✅ | +| Fill | ✅ | ✅ | ✅ | +| Fill Extrusion | ✅ | ✅ | ✅ | +| Heatmap Layer | ✅ | ✅ | ✅ | + +--- + +## Installation + +Add the dependency: + +```yaml +dependencies: + maplibre_gl: ^LATEST_VERSION +``` + +(See the current version badge above or on [pub.dev](https://pub.dev/packages/maplibre_gl)). + +Then run: +```bash +flutter pub get +``` + +### Platform setup + +#### iOS + +Add permission usage description for location (if using location features) to `ios/Runner/Info.plist`: + +```xml +NSLocationWhenInUseUsageDescription +[Explain why the app needs the user's location] +``` + +#### Android + +Minimum supported Kotlin version: `2.1.0`. In `android/settings.gradle`: + +```groovy +plugins { + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} +``` + +Add location permissions if required in `android/app/src/main/AndroidManifest.xml`: + +```xml + + +``` + +Request permissions at runtime yourself (e.g. via the [`location`](https://pub.dev/packages/location) plugin). The plugin does not prompt automatically. + +#### Web + +Include the following JavaScript and CSS files in the `` of your `web/index.html` file: + +```html + + +``` + +--- + +## Quick Start + +```dart +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class SimpleMapPage extends StatefulWidget { + const SimpleMapPage({super.key}); + @override + State createState() => _SimpleMapPageState(); +} + +class _SimpleMapPageState extends State { + final _controllerCompleter = Completer(); + bool _styleLoaded = false; + + static const _initial = CameraPosition(target: LatLng(0, 0), zoom: 2); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('MapLibre Quick Start')), + floatingActionButton: _styleLoaded + ? FloatingActionButton.small( + onPressed: _goHome, + child: const Icon(Icons.explore), + ) + : null, + body: MapLibreMap( + initialCameraPosition: _initial, + onMapCreated: (c) => _controllerCompleter.complete(c), + onStyleLoadedCallback: () => setState(() => _styleLoaded = true), + ), + ); + } + + Future _goHome() async { + final c = await _controllerCompleter.future; + await c.animateCamera(CameraUpdate.newCameraPosition(_initial)); + } +} +``` + +Check the [example project](./maplibre_gl_example) for richer demos (markers, layers, offline patterns, PMTiles, etc.). + +--- + +## Usage Highlights + +### Camera + +```dart +await controller.animateCamera( + CameraUpdate.newLatLngBounds(bounds, left: 24, top: 24, right: 24, bottom: 24), +); +``` + +### Annotations & Layers + +Add symbols (markers): +```dart +await controller.addSymbol(SymbolOptions( + geometry: LatLng(37.7749, -122.4194), + iconImage: 'assets/icon_pin.png', // ensure added as style image first + iconSize: 1.2, +)); +``` + +Add line layer (via geojson source) – see example app for full workflow. + +### Map Styles + +Provide a `styleString`: +1. Remote URL (`https://.../style.json`) +2. App asset (`assets/map_style.json` with pubspec asset registration) +3. Local file system absolute path +4. Raw JSON string + +### Tile sources requiring an API key + +Embed the key directly in the vector tile URL: + +``` +https://tiles.example.com/{z}/{x}/{y}.vector.pbf?api_key=YOUR_KEY +``` + +--- + +## Advanced Topics + +### Offline usage / mbtiles +Copy mbtiles (or sprites/glyphs) from bundled assets to a writable directory (e.g. cache) and point your style sources there. See issues [#338](https://github.com/maplibre/flutter-maplibre-gl/issues/338) & [#318](https://github.com/maplibre/flutter-maplibre-gl/issues/318) for community approaches. + +### PMTiles +The example app includes a `pmtiles` usage sample demonstrating how to load datasets via a custom protocol handler / tile source. (Look for `pmtiles.dart` in `maplibre_gl_example`.) + +### Expressions & Styling +Use data‑driven styling with expressions similar to Mapbox / MapLibre style spec. Some platform discrepancies exist (see FAQ around `!has`). For cross‑platform safety prefer form: `["!", ["has", "field"]]`. + +### Generated Code +Layer/source property helpers & expression utilities are generated from templates under `scripts/`. Do **not** edit generated files directly—run: + +``` +melos run generate +melos format-all +``` + +### Architecture Overview + +This repository is a multi-package workspace: +* `maplibre_gl` – main Flutter plugin (mobile/native bindings) +* `maplibre_gl_web` – web implementation +* `maplibre_gl_platform_interface` – shared platform interface (enables adding alternative implementations) +* `scripts/` – code generation templates & tooling + +--- + +## Migration from flutter-mapbox-gl + +Most APIs are source-compatible. Key differences to watch: +* Dependency name changes to `maplibre_gl`. +* Remove any Mapbox token initialization (MapLibre uses open assets or your own tile endpoints). +* If you referenced Mapbox-specific style endpoints, replace with self-hosted / MapLibre friendly ones. +* Audit expressions for iOS compatibility (`["!has", ...]` variant change noted in FAQ). + +--- + +## Performance Tips + +* Reuse a single `MapLibreMap` widget when possible instead of recreating it in tab/page switches. +* Batch style mutations (e.g. add sources before adding dependent layers) to avoid intermediate layout recalculations. +* Use simpler geometries or tiling strategies for very dense data. +* Avoid large uncompressed GeoJSON inline; host as a URL source if size is large. +* Defer camera animations until style load (`onStyleLoadedCallback`). + +--- + +## Security & API Keys + +If you embed API keys in style URLs for third-party tiles, ensure you: +* Restrict keys at the provider (domain / referer / usage caps). +* Avoid shipping unnecessary privileges (read-only tile scopes where possible). +Environment variable injection at build time is recommended for CI-driven apps—avoid committing raw secrets. + +--- + +## Troubleshooting / FAQ + +### Loading .mbtiles / sprites / glyphs from app assets +Copy them to a writable directory (cache/documents) first, then reference the new path in the style or source configuration. See issues [#338](https://github.com/maplibre/flutter-maplibre-gl/issues/338) & [#318](https://github.com/maplibre/flutter-maplibre-gl/issues/318). + +### Android UnsatisfiedLinkError +Ensure `abiFilters` include the ABIs you intend to ship: +```gradle +buildTypes { + release { + ndk { abiFilters 'armeabi-v7a','arm64-v8a','x86_64','x86' } + } +} +``` + +### iOS crash when using location features +Add `NSLocationWhenInUseUsageDescription` (see [iOS setup](#ios)). + + +### iOS filter expression error +Error: `Invalid filter value: filter property must be a string`. Replace `["!has", "value"]` with `["!", ["has", "value"]]`. + +--- + +## Documentation & Examples + +* Minimal example (for pub.dev & quick start): see [`example/lib/main.dart`](./example/lib/main.dart) +* Full featured example app: [`maplibre_gl_example`](./maplibre_gl_example) +* API docs: https://pub.dev/documentation/maplibre_gl/latest/ +* MapLibre upstream projects: [maplibre-gl-js](https://github.com/maplibre/maplibre-gl-js), [maplibre-native](https://github.com/maplibre/maplibre-native) + +--- + +## Versioning & Changelog + +Releases follow semantic versioning as practical; breaking changes are documented in the [CHANGELOG](./CHANGELOG.md) (root and per package). Always consult the changelog when upgrading. + +--- + +## Contributing + +This is a multi-package workspace managed by [melos](https://melos.invertase.dev/~melos-latest/getting-started). + +Basic flow: +``` +dart pub global activate melos # activate melos package + +melos bootstrap # initialize & link local packages +``` +Then open & run the example app to validate and check changes. + +Please read the full [CONTRIBUTING.md](./CONTRIBUTING.md) before submitting a PR. + +> Generated code: Some API surface (layer/source property helpers, expression utilities) is produced via a generator under `scripts/`. Do not modify generated files directly — see [Code Generation & Formatting](#generated-code) section for workflow. + +--- + +## Getting Help +* Join our [Slack](https://slack.openstreetmap.us/) channel +* StackOverflow tag: [#maplibre](https://stackoverflow.com/questions/tagged/maplibre) +* Discussions: https://github.com/maplibre/flutter-maplibre-gl/discussions +* Bugs / Features: [Open an issue](https://github.com/maplibre/flutter-maplibre-gl/issues/new) (include reproduction details/logs where possible) + +--- + +## License + +See [LICENSE](./LICENSE) for details. + +--- + +\ +If this plugin helps you build something cool, consider starring the repo to support visibility. diff --git a/third_party/maplibre_gl/analysis_options.yaml b/third_party/maplibre_gl/analysis_options.yaml new file mode 100644 index 00000000..0caccc81 --- /dev/null +++ b/third_party/maplibre_gl/analysis_options.yaml @@ -0,0 +1 @@ +include: ../analysis_options.yaml \ No newline at end of file diff --git a/third_party/maplibre_gl/android/build.gradle b/third_party/maplibre_gl/android/build.gradle new file mode 100644 index 00000000..114f4ec2 --- /dev/null +++ b/third_party/maplibre_gl/android/build.gradle @@ -0,0 +1,62 @@ +group 'org.maplibre.maplibregl' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '2.3.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.13.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'org.maplibre.maplibregl' + } + compileSdkVersion 36 + buildToolsVersion "36.0.0" + + ndkVersion "28.1.13356709" + + defaultConfig { + minSdkVersion 21 + compileSdk 36 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + lintOptions { + disable 'InvalidPackage' + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } + dependencies { + implementation 'org.maplibre.gl:android-sdk:12.3.1' + implementation 'org.maplibre.gl:android-plugin-annotation-v9:3.0.2' + implementation 'org.maplibre.gl:android-plugin-offline-v9:3.0.2' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.17.0' +} diff --git a/third_party/maplibre_gl/android/gradle.properties b/third_party/maplibre_gl/android/gradle.properties new file mode 100644 index 00000000..48989cb9 --- /dev/null +++ b/third_party/maplibre_gl/android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx1536M diff --git a/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 0 HcmV?d00001 diff --git a/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..4a202fd8 --- /dev/null +++ b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Thu Mar 06 17:48:28 CET 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/maplibre_gl/android/settings.gradle b/third_party/maplibre_gl/android/settings.gradle new file mode 100644 index 00000000..2ea73f08 --- /dev/null +++ b/third_party/maplibre_gl/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'maplibre_gl' diff --git a/third_party/maplibre_gl/android/src/main/AndroidManifest.xml b/third_party/maplibre_gl/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1d2dc33d --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java new file mode 100644 index 00000000..7e070ecc --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java @@ -0,0 +1,325 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import android.content.Context; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.util.Log; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.geojson.Polygon; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdate; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.maps.MapLibreMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Conversions between JSON-like values and MapLibreMaps data types. */ +class Convert { + + private static final String TAG = "Convert"; + + static boolean toBoolean(Object o) { + return (Boolean) o; + } + + static CameraPosition toCameraPosition(Object o) { + final Map data = toMap(o); + final CameraPosition.Builder builder = new CameraPosition.Builder(); + builder.bearing(toFloat(data.get("bearing"))); + builder.target(toLatLng(data.get("target"))); + builder.tilt(toFloat(data.get("tilt"))); + builder.zoom(toFloat(data.get("zoom"))); + return builder.build(); + } + + static boolean isScrollByCameraUpdate(Object o) { + return toString(toList(o).get(0)).equals("scrollBy"); + } + + static CameraUpdate toCameraUpdate(Object o, MapLibreMap maplibreMap, float density) { + final List data = toList(o); + switch (toString(data.get(0))) { + case "newCameraPosition": + return CameraUpdateFactory.newCameraPosition(toCameraPosition(data.get(1))); + case "newLatLng": + return CameraUpdateFactory.newLatLng(toLatLng(data.get(1))); + case "newLatLngBounds": + return CameraUpdateFactory.newLatLngBounds( + toLatLngBounds(data.get(1)), + toPixels(data.get(2), density), + toPixels(data.get(3), density), + toPixels(data.get(4), density), + toPixels(data.get(5), density)); + case "newLatLngZoom": + return CameraUpdateFactory.newLatLngZoom(toLatLng(data.get(1)), toFloat(data.get(2))); + case "scrollBy": + maplibreMap.scrollBy( + toFractionalPixels(data.get(1), density), toFractionalPixels(data.get(2), density)); + return null; + case "zoomBy": + if (data.size() == 2) { + return CameraUpdateFactory.zoomBy(toFloat(data.get(1))); + } else { + return CameraUpdateFactory.zoomBy(toFloat(data.get(1)), toPoint(data.get(2), density)); + } + case "zoomIn": + return CameraUpdateFactory.zoomIn(); + case "zoomOut": + return CameraUpdateFactory.zoomOut(); + case "zoomTo": + return CameraUpdateFactory.zoomTo(toFloat(data.get(1))); + case "bearingTo": + return CameraUpdateFactory.bearingTo(toFloat(data.get(1))); + case "tiltTo": + return CameraUpdateFactory.tiltTo(toFloat(data.get(1))); + default: + throw new IllegalArgumentException("Cannot interpret " + o + " as CameraUpdate"); + } + } + + static double toDouble(Object o) { + return ((Number) o).doubleValue(); + } + + static float toFloat(Object o) { + return ((Number) o).floatValue(); + } + + static Float toFloatWrapper(Object o) { + return (o == null) ? null : toFloat(o); + } + + static int toInt(Object o) { + return ((Number) o).intValue(); + } + + static Object toJson(CameraPosition position) { + if (position == null) { + return null; + } + final Map data = new HashMap<>(); + data.put("bearing", position.bearing); + data.put("target", toJson(position.target)); + data.put("tilt", position.tilt); + data.put("zoom", position.zoom); + return data; + } + + private static Object toJson(LatLng latLng) { + return Arrays.asList(latLng.getLatitude(), latLng.getLongitude()); + } + + static LatLng toLatLng(Object o) { + final List data = toList(o); + return new LatLng(toDouble(data.get(0)), toDouble(data.get(1))); + } + + static LatLngBounds toLatLngBounds(Object o) { + if (o == null) { + return null; + } + final List data = toList(o); + LatLng[] boundsArray = new LatLng[] {toLatLng(data.get(0)), toLatLng(data.get(1))}; + List bounds = Arrays.asList(boundsArray); + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + builder.includes(bounds); + return builder.build(); + } + +static LocationEngineRequest toLocationEngineRequest(Object o) { + if (o == null) { + return null; + } + List data = toList(o); + return new LocationEngineRequest.Builder(toInt(data.get(0))) + .setPriority(toInt(data.get(1))) + .setDisplacement(toInt(data.get(2))) + .build(); +} + + static List toLatLngList(Object o, boolean flippedOrder) { + if (o == null) { + return null; + } + final List data = toList(o); + List latLngList = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + final List coords = toList(data.get(i)); + if (flippedOrder) { + latLngList.add(new LatLng(toDouble(coords.get(1)), toDouble(coords.get(0)))); + } else { + latLngList.add(new LatLng(toDouble(coords.get(0)), toDouble(coords.get(1)))); + } + } + return latLngList; + } + + private static List> toLatLngListList(Object o) { + if (o == null) { + return null; + } + final List data = toList(o); + List> latLngListList = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + List latLngList = toLatLngList(data.get(i), false); + latLngListList.add(latLngList); + } + return latLngListList; + } + + static Polygon interpretListLatLng(List> geometry) { + List> points = new ArrayList<>(geometry.size()); + for (List innerGeometry : geometry) { + List innerPoints = new ArrayList<>(innerGeometry.size()); + for (LatLng latLng : innerGeometry) { + innerPoints.add( + org.maplibre.geojson.Point.fromLngLat(latLng.getLongitude(), latLng.getLatitude())); + } + points.add(innerPoints); + } + return Polygon.fromLngLats(points); + } + + static List toList(Object o) { + return (List) o; + } + + static long toLong(Object o) { + return ((Number) o).longValue(); + } + + static Map toMap(Object o) { + return (Map) o; + } + + private static float toFractionalPixels(Object o, float density) { + return toFloat(o) * density; + } + + static int toPixels(Object o, float density) { + return (int) toFractionalPixels(o, density); + } + + private static Point toPoint(Object o, float density) { + final List data = toList(o); + return new Point(toPixels(data.get(0), density), toPixels(data.get(1), density)); + } + + static String toString(Object o) { + return (String) o; + } + + static void interpretMapLibreMapOptions(Object o, MapLibreMapOptionsSink sink, Context context) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final Map data = toMap(o); + + final Object locationEngineProperties = data.get("locationEngineProperties"); + if (locationEngineProperties != null) { + final List locationEnginePropertiesList = toList(locationEngineProperties); + sink.setLocationEngineProperties(toLocationEngineRequest(locationEnginePropertiesList)); + } + final Object cameraTargetBounds = data.get("cameraTargetBounds"); + if (cameraTargetBounds != null) { + final List targetData = toList(cameraTargetBounds); + sink.setCameraTargetBounds(toLatLngBounds(targetData.get(0))); + } + final Object compassEnabled = data.get("compassEnabled"); + if (compassEnabled != null) { + sink.setCompassEnabled(toBoolean(compassEnabled)); + } + final Object styleString = data.get("styleString"); + if (styleString != null) { + sink.setStyleString(toString(styleString)); + } + final Object minMaxZoomPreference = data.get("minMaxZoomPreference"); + if (minMaxZoomPreference != null) { + final List zoomPreferenceData = toList(minMaxZoomPreference); + sink.setMinMaxZoomPreference( // + toFloatWrapper(zoomPreferenceData.get(0)), // + toFloatWrapper(zoomPreferenceData.get(1))); + } + final Object rotateGesturesEnabled = data.get("rotateGesturesEnabled"); + if (rotateGesturesEnabled != null) { + sink.setRotateGesturesEnabled(toBoolean(rotateGesturesEnabled)); + } + final Object scrollGesturesEnabled = data.get("scrollGesturesEnabled"); + if (scrollGesturesEnabled != null) { + sink.setScrollGesturesEnabled(toBoolean(scrollGesturesEnabled)); + } + final Object tiltGesturesEnabled = data.get("tiltGesturesEnabled"); + if (tiltGesturesEnabled != null) { + sink.setTiltGesturesEnabled(toBoolean(tiltGesturesEnabled)); + } + final Object trackCameraPosition = data.get("trackCameraPosition"); + if (trackCameraPosition != null) { + sink.setTrackCameraPosition(toBoolean(trackCameraPosition)); + } + final Object zoomGesturesEnabled = data.get("zoomGesturesEnabled"); + if (zoomGesturesEnabled != null) { + sink.setZoomGesturesEnabled(toBoolean(zoomGesturesEnabled)); + } + final Object myLocationEnabled = data.get("myLocationEnabled"); + if (myLocationEnabled != null) { + sink.setMyLocationEnabled(toBoolean(myLocationEnabled)); + } + final Object myLocationTrackingMode = data.get("myLocationTrackingMode"); + if (myLocationTrackingMode != null) { + sink.setMyLocationTrackingMode(toInt(myLocationTrackingMode)); + } + final Object myLocationRenderMode = data.get("myLocationRenderMode"); + if (myLocationRenderMode != null) { + sink.setMyLocationRenderMode(toInt(myLocationRenderMode)); + } + final Object logoEnabled = data.get("logoEnabled"); + if (logoEnabled != null) { + sink.setLogoEnabled(toBoolean(logoEnabled)); + } + final Object logoViewGravity = data.get("logoViewPosition"); + if (logoViewGravity != null) { + sink.setLogoViewGravity(toInt(logoViewGravity)); + } + final Object logoViewMargins = data.get("logoViewMargins"); + if (logoViewMargins != null) { + final List logoViewMarginsData = toList(logoViewMargins); + final Point point = toPoint(logoViewMarginsData, metrics.density); + sink.setLogoViewMargins(point.x, point.y); + } + final Object compassGravity = data.get("compassViewPosition"); + if (compassGravity != null) { + sink.setCompassGravity(toInt(compassGravity)); + } + final Object compassViewMargins = data.get("compassViewMargins"); + if (compassViewMargins != null) { + final List compassViewMarginsData = toList(compassViewMargins); + final Point point = toPoint(compassViewMarginsData, metrics.density); + sink.setCompassViewMargins(point.x, point.y); + } + final Object attributionButtonGravity = data.get("attributionButtonPosition"); + if (attributionButtonGravity != null) { + sink.setAttributionButtonGravity(toInt(attributionButtonGravity)); + } + final Object attributionButtonMargins = data.get("attributionButtonMargins"); + if (attributionButtonMargins != null) { + final List attributionButtonMarginsData = toList(attributionButtonMargins); + final Point point = toPoint(attributionButtonMarginsData, metrics.density); + sink.setAttributionButtonMargins(point.x, point.y); + } + final Object foregroundLoadColor = data.get("foregroundLoadColor"); + if (foregroundLoadColor != null) { + sink.setForegroundLoadColor(toInt(foregroundLoadColor)); + } + final Object translucentTextureSurface = data.get("translucentTextureSurface"); + if (translucentTextureSurface != null) { + sink.setTranslucentTextureSurface(toBoolean(translucentTextureSurface)); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java new file mode 100644 index 00000000..edb8d2ee --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java @@ -0,0 +1,157 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.maplibre.android.net.ConnectivityReceiver; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +class GlobalMethodHandler implements MethodChannel.MethodCallHandler { + private static final String TAG = GlobalMethodHandler.class.getSimpleName(); + private static final String DATABASE_NAME = "mbgl-offline.db"; + private static final int BUFFER_SIZE = 1024 * 2; + @NonNull private final Context context; + @NonNull private final BinaryMessenger messenger; + @Nullable private FlutterPlugin.FlutterAssets flutterAssets; + @Nullable private OfflineChannelHandlerImpl downloadOfflineRegionChannelHandler; + + + GlobalMethodHandler(@NonNull FlutterPlugin.FlutterPluginBinding binding) { + this.context = binding.getApplicationContext(); + this.flutterAssets = binding.getFlutterAssets(); + this.messenger = binding.getBinaryMessenger(); + } + + private static void copy(InputStream input, OutputStream output) throws IOException { + final byte[] buffer = new byte[BUFFER_SIZE]; + final BufferedInputStream in = new BufferedInputStream(input, BUFFER_SIZE); + final BufferedOutputStream out = new BufferedOutputStream(output, BUFFER_SIZE); + int count = 0; + int n = 0; + try { + while ((n = in.read(buffer, 0, BUFFER_SIZE)) != -1) { + out.write(buffer, 0, n); + count += n; + } + out.flush(); + } finally { + try { + out.close(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + try { + in.close(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + } + } + + @Override + public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) { + MapLibreUtils.getMapLibre(context); + + switch (methodCall.method) { + case "installOfflineMapTiles": + String tilesDb = methodCall.argument("tilesdb"); + installOfflineMapTiles(tilesDb); + result.success(null); + break; + case "setOffline": + boolean offline = methodCall.argument("offline"); + ConnectivityReceiver.instance(context).setConnected(offline ? false : null); + result.success(null); + break; + case "mergeOfflineRegions": + OfflineManagerUtils.mergeRegions(result, context, methodCall.argument("path")); + break; + case "setOfflineTileCountLimit": + OfflineManagerUtils.setOfflineTileCountLimit( + result, context, methodCall.argument("limit").longValue()); + break; + case "setHttpHeaders": + Map headers = (Map) methodCall.argument("headers"); + MapLibreHttpRequestUtil.setHttpHeaders(headers, result); + break; + case "downloadOfflineRegion#setup": + String channelName = methodCall.argument("channelName"); + // Prepare args + downloadOfflineRegionChannelHandler = new OfflineChannelHandlerImpl(messenger, channelName); + result.success(null); + break; + case "downloadOfflineRegion": + // Get args from caller + Map definitionMap = (Map) methodCall.argument("definition"); + Map metadataMap = (Map) methodCall.argument("metadata"); + + if (downloadOfflineRegionChannelHandler == null) { + result.error( + "downloadOfflineRegion#setup NOT CALLED", + "The setup has not been called, please call downloadOfflineRegion#setup before", + null); + break; + } + + // Start downloading + OfflineManagerUtils.downloadRegion( + result, context, definitionMap, metadataMap, downloadOfflineRegionChannelHandler); + downloadOfflineRegionChannelHandler = null; + break; + case "getListOfRegions": + OfflineManagerUtils.regionsList(result, context); + break; + case "updateOfflineRegionMetadata": + // Get download region arguments from caller + Map metadata = (Map) methodCall.argument("metadata"); + OfflineManagerUtils.updateRegionMetadata( + result, context, methodCall.argument("id").longValue(), metadata); + break; + case "deleteOfflineRegion": + OfflineManagerUtils.deleteRegion( + result, context, methodCall.argument("id").longValue()); + break; + default: + result.notImplemented(); + break; + } + } + + private void installOfflineMapTiles(String tilesDb) { + final File dest = new File(context.getFilesDir(), DATABASE_NAME); + try (InputStream input = openTilesDbFile(tilesDb); + OutputStream output = new FileOutputStream(dest)) { + copy(input, output); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private InputStream openTilesDbFile(String tilesDb) throws IOException { + if (tilesDb.startsWith("/")) { // Absolute path. + return new FileInputStream(new File(tilesDb)); + } else { + String assetKey; + if (flutterAssets != null) { + assetKey = flutterAssets.getAssetFilePathByName(tilesDb); + } else { + throw new IllegalStateException(); + } + return context.getAssets().open(assetKey); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java new file mode 100644 index 00000000..2531e908 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java @@ -0,0 +1,659 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +package org.maplibre.maplibregl; + +import org.maplibre.android.style.expressions.Expression; +import org.maplibre.android.style.layers.PropertyFactory; +import org.maplibre.android.style.layers.PropertyValue; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import static org.maplibre.maplibregl.Convert.toMap; + +class LayerPropertyConverter { + static PropertyValue[] interpretSymbolLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "icon-opacity": + properties.add(PropertyFactory.iconOpacity(expression)); + break; + case "icon-color": + properties.add(PropertyFactory.iconColor(expression)); + break; + case "icon-halo-color": + properties.add(PropertyFactory.iconHaloColor(expression)); + break; + case "icon-halo-width": + properties.add(PropertyFactory.iconHaloWidth(expression)); + break; + case "icon-halo-blur": + properties.add(PropertyFactory.iconHaloBlur(expression)); + break; + case "icon-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.iconTranslate(floatArray)); + } else { + properties.add(PropertyFactory.iconTranslate(expression)); + } + } else { + properties.add(PropertyFactory.iconTranslate(expression)); + } + break; + case "icon-translate-anchor": + properties.add(PropertyFactory.iconTranslateAnchor(expression)); + break; + case "text-opacity": + properties.add(PropertyFactory.textOpacity(expression)); + break; + case "text-color": + properties.add(PropertyFactory.textColor(expression)); + break; + case "text-halo-color": + properties.add(PropertyFactory.textHaloColor(expression)); + break; + case "text-halo-width": + properties.add(PropertyFactory.textHaloWidth(expression)); + break; + case "text-halo-blur": + properties.add(PropertyFactory.textHaloBlur(expression)); + break; + case "text-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.textTranslate(floatArray)); + } else { + properties.add(PropertyFactory.textTranslate(expression)); + } + } else { + properties.add(PropertyFactory.textTranslate(expression)); + } + break; + case "text-translate-anchor": + properties.add(PropertyFactory.textTranslateAnchor(expression)); + break; + case "symbol-placement": + properties.add(PropertyFactory.symbolPlacement(expression)); + break; + case "symbol-spacing": + properties.add(PropertyFactory.symbolSpacing(expression)); + break; + case "symbol-avoid-edges": + properties.add(PropertyFactory.symbolAvoidEdges(expression)); + break; + case "symbol-sort-key": + properties.add(PropertyFactory.symbolSortKey(expression)); + break; + case "symbol-z-order": + properties.add(PropertyFactory.symbolZOrder(expression)); + break; + case "icon-allow-overlap": + properties.add(PropertyFactory.iconAllowOverlap(expression)); + break; + case "icon-ignore-placement": + properties.add(PropertyFactory.iconIgnorePlacement(expression)); + break; + case "icon-optional": + properties.add(PropertyFactory.iconOptional(expression)); + break; + case "icon-rotation-alignment": + properties.add(PropertyFactory.iconRotationAlignment(expression)); + break; + case "icon-size": + properties.add(PropertyFactory.iconSize(expression)); + break; + case "icon-text-fit": + properties.add(PropertyFactory.iconTextFit(expression)); + break; + case "icon-text-fit-padding": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.iconTextFitPadding(floatArray)); + } else { + properties.add(PropertyFactory.iconTextFitPadding(expression)); + } + } else { + properties.add(PropertyFactory.iconTextFitPadding(expression)); + } + break; + case "icon-image": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.iconImage(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.iconImage(expression)); + } + break; + case "icon-rotate": + properties.add(PropertyFactory.iconRotate(expression)); + break; + case "icon-padding": + properties.add(PropertyFactory.iconPadding(expression)); + break; + case "icon-keep-upright": + properties.add(PropertyFactory.iconKeepUpright(expression)); + break; + case "icon-offset": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.iconOffset(floatArray)); + } else { + properties.add(PropertyFactory.iconOffset(expression)); + } + } else { + properties.add(PropertyFactory.iconOffset(expression)); + } + break; + case "icon-anchor": + properties.add(PropertyFactory.iconAnchor(expression)); + break; + case "icon-pitch-alignment": + properties.add(PropertyFactory.iconPitchAlignment(expression)); + break; + case "text-pitch-alignment": + properties.add(PropertyFactory.textPitchAlignment(expression)); + break; + case "text-rotation-alignment": + properties.add(PropertyFactory.textRotationAlignment(expression)); + break; + case "text-field": + properties.add(PropertyFactory.textField(expression)); + break; + case "text-font": + properties.add(PropertyFactory.textFont(expression)); + break; + case "text-size": + properties.add(PropertyFactory.textSize(expression)); + break; + case "text-max-width": + properties.add(PropertyFactory.textMaxWidth(expression)); + break; + case "text-line-height": + properties.add(PropertyFactory.textLineHeight(expression)); + break; + case "text-letter-spacing": + properties.add(PropertyFactory.textLetterSpacing(expression)); + break; + case "text-justify": + properties.add(PropertyFactory.textJustify(expression)); + break; + case "text-radial-offset": + properties.add(PropertyFactory.textRadialOffset(expression)); + break; + case "text-variable-anchor": + properties.add(PropertyFactory.textVariableAnchor(expression)); + break; + case "text-anchor": + properties.add(PropertyFactory.textAnchor(expression)); + break; + case "text-max-angle": + properties.add(PropertyFactory.textMaxAngle(expression)); + break; + case "text-writing-mode": + properties.add(PropertyFactory.textWritingMode(expression)); + break; + case "text-rotate": + properties.add(PropertyFactory.textRotate(expression)); + break; + case "text-padding": + properties.add(PropertyFactory.textPadding(expression)); + break; + case "text-keep-upright": + properties.add(PropertyFactory.textKeepUpright(expression)); + break; + case "text-transform": + properties.add(PropertyFactory.textTransform(expression)); + break; + case "text-offset": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.textOffset(floatArray)); + } else { + properties.add(PropertyFactory.textOffset(expression)); + } + } else { + properties.add(PropertyFactory.textOffset(expression)); + } + break; + case "text-allow-overlap": + properties.add(PropertyFactory.textAllowOverlap(expression)); + break; + case "text-ignore-placement": + properties.add(PropertyFactory.textIgnorePlacement(expression)); + break; + case "text-optional": + properties.add(PropertyFactory.textOptional(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretCircleLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "circle-radius": + properties.add(PropertyFactory.circleRadius(expression)); + break; + case "circle-color": + properties.add(PropertyFactory.circleColor(expression)); + break; + case "circle-blur": + properties.add(PropertyFactory.circleBlur(expression)); + break; + case "circle-opacity": + properties.add(PropertyFactory.circleOpacity(expression)); + break; + case "circle-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.circleTranslate(floatArray)); + } else { + properties.add(PropertyFactory.circleTranslate(expression)); + } + } else { + properties.add(PropertyFactory.circleTranslate(expression)); + } + break; + case "circle-translate-anchor": + properties.add(PropertyFactory.circleTranslateAnchor(expression)); + break; + case "circle-pitch-scale": + properties.add(PropertyFactory.circlePitchScale(expression)); + break; + case "circle-pitch-alignment": + properties.add(PropertyFactory.circlePitchAlignment(expression)); + break; + case "circle-stroke-width": + properties.add(PropertyFactory.circleStrokeWidth(expression)); + break; + case "circle-stroke-color": + properties.add(PropertyFactory.circleStrokeColor(expression)); + break; + case "circle-stroke-opacity": + properties.add(PropertyFactory.circleStrokeOpacity(expression)); + break; + case "circle-sort-key": + properties.add(PropertyFactory.circleSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretLineLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "line-opacity": + properties.add(PropertyFactory.lineOpacity(expression)); + break; + case "line-color": + properties.add(PropertyFactory.lineColor(expression)); + break; + case "line-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.lineTranslate(floatArray)); + } else { + properties.add(PropertyFactory.lineTranslate(expression)); + } + } else { + properties.add(PropertyFactory.lineTranslate(expression)); + } + break; + case "line-translate-anchor": + properties.add(PropertyFactory.lineTranslateAnchor(expression)); + break; + case "line-width": + properties.add(PropertyFactory.lineWidth(expression)); + break; + case "line-gap-width": + properties.add(PropertyFactory.lineGapWidth(expression)); + break; + case "line-offset": + properties.add(PropertyFactory.lineOffset(expression)); + break; + case "line-blur": + properties.add(PropertyFactory.lineBlur(expression)); + break; + case "line-dasharray": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.lineDasharray(floatArray)); + } else { + properties.add(PropertyFactory.lineDasharray(expression)); + } + } else { + properties.add(PropertyFactory.lineDasharray(expression)); + } + break; + case "line-pattern": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.linePattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.linePattern(expression)); + } + break; + case "line-gradient": + properties.add(PropertyFactory.lineGradient(expression)); + break; + case "line-cap": + properties.add(PropertyFactory.lineCap(expression)); + break; + case "line-join": + properties.add(PropertyFactory.lineJoin(expression)); + break; + case "line-miter-limit": + properties.add(PropertyFactory.lineMiterLimit(expression)); + break; + case "line-round-limit": + properties.add(PropertyFactory.lineRoundLimit(expression)); + break; + case "line-sort-key": + properties.add(PropertyFactory.lineSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretFillLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "fill-antialias": + properties.add(PropertyFactory.fillAntialias(expression)); + break; + case "fill-opacity": + properties.add(PropertyFactory.fillOpacity(expression)); + break; + case "fill-color": + properties.add(PropertyFactory.fillColor(expression)); + break; + case "fill-outline-color": + properties.add(PropertyFactory.fillOutlineColor(expression)); + break; + case "fill-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.fillTranslate(floatArray)); + } else { + properties.add(PropertyFactory.fillTranslate(expression)); + } + } else { + properties.add(PropertyFactory.fillTranslate(expression)); + } + break; + case "fill-translate-anchor": + properties.add(PropertyFactory.fillTranslateAnchor(expression)); + break; + case "fill-pattern": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.fillPattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.fillPattern(expression)); + } + break; + case "fill-sort-key": + properties.add(PropertyFactory.fillSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretFillExtrusionLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "fill-extrusion-opacity": + properties.add(PropertyFactory.fillExtrusionOpacity(expression)); + break; + case "fill-extrusion-color": + properties.add(PropertyFactory.fillExtrusionColor(expression)); + break; + case "fill-extrusion-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.fillExtrusionTranslate(floatArray)); + } else { + properties.add(PropertyFactory.fillExtrusionTranslate(expression)); + } + } else { + properties.add(PropertyFactory.fillExtrusionTranslate(expression)); + } + break; + case "fill-extrusion-translate-anchor": + properties.add(PropertyFactory.fillExtrusionTranslateAnchor(expression)); + break; + case "fill-extrusion-pattern": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.fillExtrusionPattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.fillExtrusionPattern(expression)); + } + break; + case "fill-extrusion-height": + properties.add(PropertyFactory.fillExtrusionHeight(expression)); + break; + case "fill-extrusion-base": + properties.add(PropertyFactory.fillExtrusionBase(expression)); + break; + case "fill-extrusion-vertical-gradient": + properties.add(PropertyFactory.fillExtrusionVerticalGradient(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretRasterLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "raster-opacity": + properties.add(PropertyFactory.rasterOpacity(expression)); + break; + case "raster-hue-rotate": + properties.add(PropertyFactory.rasterHueRotate(expression)); + break; + case "raster-brightness-min": + properties.add(PropertyFactory.rasterBrightnessMin(expression)); + break; + case "raster-brightness-max": + properties.add(PropertyFactory.rasterBrightnessMax(expression)); + break; + case "raster-saturation": + properties.add(PropertyFactory.rasterSaturation(expression)); + break; + case "raster-contrast": + properties.add(PropertyFactory.rasterContrast(expression)); + break; + case "raster-resampling": + properties.add(PropertyFactory.rasterResampling(expression)); + break; + case "raster-fade-duration": + properties.add(PropertyFactory.rasterFadeDuration(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretHillshadeLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "hillshade-illumination-direction": + properties.add(PropertyFactory.hillshadeIlluminationDirection(expression)); + break; + case "hillshade-illumination-anchor": + properties.add(PropertyFactory.hillshadeIlluminationAnchor(expression)); + break; + case "hillshade-exaggeration": + properties.add(PropertyFactory.hillshadeExaggeration(expression)); + break; + case "hillshade-shadow-color": + properties.add(PropertyFactory.hillshadeShadowColor(expression)); + break; + case "hillshade-highlight-color": + properties.add(PropertyFactory.hillshadeHighlightColor(expression)); + break; + case "hillshade-accent-color": + properties.add(PropertyFactory.hillshadeAccentColor(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretHeatmapLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "heatmap-radius": + properties.add(PropertyFactory.heatmapRadius(expression)); + break; + case "heatmap-weight": + properties.add(PropertyFactory.heatmapWeight(expression)); + break; + case "heatmap-intensity": + properties.add(PropertyFactory.heatmapIntensity(expression)); + break; + case "heatmap-color": + properties.add(PropertyFactory.heatmapColor(expression)); + break; + case "heatmap-opacity": + properties.add(PropertyFactory.heatmapOpacity(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + private static boolean isNumber(JsonElement element) { + return element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber(); + } + + private static Float[] convertJsonToFloatArray(JsonElement jsonElement) { + final JsonArray jsonArray = jsonElement.getAsJsonArray(); + Float[] floatArray = new Float[jsonArray.size()]; + + for (int i = 0; i < jsonArray.size(); i++) { + if (jsonArray.get(i).isJsonPrimitive() && jsonArray.get(i).getAsJsonPrimitive().isNumber()) { + floatArray[i] = jsonArray.get(i).getAsFloat(); + } else { + return null; + } + } + return floatArray; + } +} \ No newline at end of file diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt new file mode 100644 index 00000000..766b865b --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt @@ -0,0 +1,38 @@ +package org.maplibre.maplibregl + +import android.content.Context +import org.maplibre.android.location.LocationComponent +import org.maplibre.android.location.engine.LocationEngine +import org.maplibre.android.location.engine.LocationEngineDefault.getDefaultLocationEngine +import org.maplibre.android.location.engine.LocationEngineProxy +import org.maplibre.android.location.engine.LocationEngineRequest + +class LocationEngineFactory { + + private var locationEngineRequest: LocationEngineRequest? = null + + fun getLocationEngine(context: Context): LocationEngine { + if (locationEngineRequest?.priority == LocationEngineRequest.PRIORITY_HIGH_ACCURACY) { + return LocationEngineProxy( + MapLibreGPSLocationEngine(context) + ) + } + return getDefaultLocationEngine(context) + } + + fun initLocationComponent( + context: Context, + locationComponent: LocationComponent?, + locationEngineRequest: LocationEngineRequest? + ) { + if (locationEngineRequest != null) { + this.locationEngineRequest = locationEngineRequest + } + if (locationComponent != null) { + locationComponent.locationEngine = getLocationEngine(context) + locationEngineRequest?.let { locationEngineRequest -> + locationComponent.locationEngineRequest = locationEngineRequest + } + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java new file mode 100644 index 00000000..69684a2a --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java @@ -0,0 +1,74 @@ +package org.maplibre.maplibregl; + +import org.maplibre.android.module.http.HttpRequestUtil; +import io.flutter.plugin.common.MethodChannel; +import java.util.Map; +import java.util.HashMap; +import java.util.List; +import java.util.ArrayList; +import java.util.regex.Pattern; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import android.util.Log; + +public class MapLibreCustomHttpInterceptor { + private static final String TAG = "MapLibreCustomHttpInterceptor"; + public static final HashMap CustomHeaders = new HashMap<>(); + public static final List Filter = new ArrayList<>(); + + public static void setCustomHeaders(Map headers, List filter, MethodChannel.Result result) { + CustomHeaders.clear(); + Filter.clear(); + + for (Map.Entry entry : headers.entrySet()) { + CustomHeaders.put(entry.getKey(), entry.getValue()); + Log.d(TAG, "Setting " + entry.getKey() + " to " + entry.getValue()); + } + + for (String pattern : filter) { + Filter.add(pattern); + } + + HttpRequestUtil.setOkHttpClient(getOkHttpClient().build()); + result.success(null); + } + + private static OkHttpClient.Builder getOkHttpClient() { + try { + return new OkHttpClient.Builder() + .addNetworkInterceptor( + chain -> { + Request.Builder builder = chain.request().newBuilder(); + String url = chain.request().url().toString(); + + // Check if URL matches any filter pattern + boolean shouldApplyHeaders = Filter.isEmpty(); + for (String pattern : Filter) { + if (Pattern.matches(pattern, url)) { + shouldApplyHeaders = true; + break; + } + } + + if (shouldApplyHeaders) { + for (Map.Entry header : CustomHeaders.entrySet()) { + if (header.getKey() == null || header.getKey().trim().isEmpty()) { + continue; + } + if (header.getValue() == null || header.getValue().trim().isEmpty()) { + builder.removeHeader(header.getKey()); + } else { + builder.header(header.getKey(), header.getValue()); + } + } + } + + return chain.proceed(builder.build()); + }); + } catch (Exception e) { + Log.e(TAG, "Error creating HTTP client: " + e.getMessage()); + throw new RuntimeException(e); + } + } +} + diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java new file mode 100644 index 00000000..2dba9674 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java @@ -0,0 +1,140 @@ +package org.maplibre.maplibregl; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.maplibre.android.location.engine.LocationEngineCallback; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.android.location.engine.LocationEngineResult; +import org.maplibre.android.location.engine.LocationEngineImpl; + + +public class MapLibreGPSLocationEngine implements LocationEngineImpl { + private static final String TAG = "GPSLocationEngine"; + final LocationManager locationManager; + + String currentProvider = LocationManager.PASSIVE_PROVIDER; + + public MapLibreGPSLocationEngine(@NonNull Context context) { + locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + } + + @NonNull + @Override + public LocationListener createListener(LocationEngineCallback callback) { + return new AndroidLocationEngineCallbackTransport(callback); + } + + @Override + public void getLastLocation(@NonNull LocationEngineCallback callback) + throws SecurityException { + Location lastLocation = getLastLocationFor(currentProvider); + if (lastLocation != null) { + callback.onSuccess(LocationEngineResult.create(lastLocation)); + return; + } + + for (String provider : locationManager.getAllProviders()) { + lastLocation = getLastLocationFor(provider); + if (lastLocation != null) { + callback.onSuccess(LocationEngineResult.create(lastLocation)); + return; + } + } + callback.onFailure(new Exception("Last location unavailable")); + } + + @SuppressLint("MissingPermission") + Location getLastLocationFor(String provider) throws SecurityException { + Location location = null; + try { + location = locationManager.getLastKnownLocation(provider); + } catch (IllegalArgumentException iae) { + Log.e(TAG, iae.toString()); + } + return location; + } + + @SuppressLint("MissingPermission") + @Override + public void requestLocationUpdates(@NonNull LocationEngineRequest request, + @NonNull LocationListener listener, + @Nullable Looper looper) throws SecurityException { + currentProvider = getBestProvider(request.getPriority()); + locationManager.requestLocationUpdates(currentProvider, request.getInterval(), request.getDisplacement(), + listener, looper); + } + + @SuppressLint("MissingPermission") + @Override + public void requestLocationUpdates(@NonNull LocationEngineRequest request, + @NonNull PendingIntent pendingIntent) throws SecurityException { + currentProvider = getBestProvider(request.getPriority()); + locationManager.requestLocationUpdates(currentProvider, request.getInterval(), + request.getDisplacement(), pendingIntent); + } + + @SuppressLint("MissingPermission") + @Override + public void removeLocationUpdates(@NonNull LocationListener listener) { + if (listener != null) { + locationManager.removeUpdates(listener); + } + } + + @Override + public void removeLocationUpdates(PendingIntent pendingIntent) { + if (pendingIntent != null) { + locationManager.removeUpdates(pendingIntent); + } + } + + private String getBestProvider(int priority) { + String provider = null; + if (priority != LocationEngineRequest.PRIORITY_NO_POWER) { + provider = LocationManager.GPS_PROVIDER; + } + return provider != null ? provider : LocationManager.PASSIVE_PROVIDER; + } + + + @VisibleForTesting + static final class AndroidLocationEngineCallbackTransport implements LocationListener { + private final LocationEngineCallback callback; + + AndroidLocationEngineCallbackTransport(LocationEngineCallback callback) { + this.callback = callback; + } + + @Override + public void onLocationChanged(Location location) { + callback.onSuccess(LocationEngineResult.create(location)); + } + + @Override + public void onStatusChanged(String s, int i, Bundle bundle) { + // noop + } + + @Override + public void onProviderEnabled(String s) { + // noop + } + + @Override + public void onProviderDisabled(String s) { + callback.onFailure(new Exception("Current provider disabled")); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java new file mode 100644 index 00000000..5a796e6f --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java @@ -0,0 +1,43 @@ +package org.maplibre.maplibregl; + +import org.maplibre.android.module.http.HttpRequestUtil; +import io.flutter.plugin.common.MethodChannel; +import java.util.Map; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +abstract class MapLibreHttpRequestUtil { + + public static void setHttpHeaders(Map headers, MethodChannel.Result result) { + HttpRequestUtil.setOkHttpClient(getOkHttpClient(headers, result).build()); + result.success(null); + } + + private static OkHttpClient.Builder getOkHttpClient( + Map headers, MethodChannel.Result result) { + try { + return new OkHttpClient.Builder() + .addNetworkInterceptor( + chain -> { + Request.Builder builder = chain.request().newBuilder(); + for (Map.Entry header : headers.entrySet()) { + if (header.getKey() == null || header.getKey().trim().isEmpty()) { + continue; + } + if (header.getValue() == null || header.getValue().trim().isEmpty()) { + builder.removeHeader(header.getKey()); + } else { + builder.header(header.getKey(), header.getValue()); + } + } + return chain.proceed(builder.build()); + }); + } catch (Exception e) { + result.error( + "OK_HTTP_CLIENT_ERROR", + "An unexcepted error happened during creating http " + "client" + e.getMessage(), + null); + throw new RuntimeException(e); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java new file mode 100644 index 00000000..98cc2dad --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java @@ -0,0 +1,253 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import android.content.Context; +import android.view.Gravity; +import androidx.annotation.NonNull; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.android.maps.MapLibreMapOptions; +import io.flutter.plugin.common.BinaryMessenger; + +class MapLibreMapBuilder implements MapLibreMapOptionsSink { + public final String TAG = getClass().getSimpleName(); + private final MapLibreMapOptions options = + new MapLibreMapOptions().attributionEnabled(true).logoEnabled(false).textureMode(true); + private boolean trackCameraPosition = false; + private boolean myLocationEnabled = false; + private boolean dragEnabled = true; + private int myLocationTrackingMode = 0; + private int myLocationRenderMode = 0; + private String styleString = ""; + private LatLngBounds bounds = null; + private LocationEngineRequest locationEngineRequest = null; + + MapLibreMapController build( + int id, + Context context, + BinaryMessenger messenger, + MapLibreMapsPlugin.LifecycleProvider lifecycleProvider) { + + final MapLibreMapController controller = + new MapLibreMapController( + id, context, messenger, lifecycleProvider, options, styleString, dragEnabled); + controller.init(); + controller.setMyLocationEnabled(myLocationEnabled); + controller.setMyLocationTrackingMode(myLocationTrackingMode); + controller.setMyLocationRenderMode(myLocationRenderMode); + controller.setTrackCameraPosition(trackCameraPosition); + + if (null != bounds) { + controller.setCameraTargetBounds(bounds); + } + + if(null != locationEngineRequest ){ + controller.setLocationEngineProperties(locationEngineRequest); + } + + return controller; + } + + public void setInitialCameraPosition(CameraPosition position) { + options.camera(position); + } + + @Override + public void setCompassEnabled(boolean compassEnabled) { + options.compassEnabled(compassEnabled); + } + + @Override + public void setCameraTargetBounds(@NonNull LatLngBounds bounds) { + this.bounds = bounds; + } + + @Override + public void setStyleString(@NonNull String styleString) { + this.styleString = styleString; + } + + @Override + public void setMinMaxZoomPreference(Float min, Float max) { + if (min != null) { + options.minZoomPreference(min); + } + if (max != null) { + options.maxZoomPreference(max); + } + } + + @Override + public void setTrackCameraPosition(boolean trackCameraPosition) { + this.trackCameraPosition = trackCameraPosition; + } + + @Override + public void setRotateGesturesEnabled(boolean rotateGesturesEnabled) { + options.rotateGesturesEnabled(rotateGesturesEnabled); + } + + @Override + public void setScrollGesturesEnabled(boolean scrollGesturesEnabled) { + options.scrollGesturesEnabled(scrollGesturesEnabled); + } + + @Override + public void setTiltGesturesEnabled(boolean tiltGesturesEnabled) { + options.tiltGesturesEnabled(tiltGesturesEnabled); + } + + @Override + public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { + options.zoomGesturesEnabled(zoomGesturesEnabled); + } + + @Override + public void setMyLocationEnabled(boolean myLocationEnabled) { + this.myLocationEnabled = myLocationEnabled; + } + + @Override + public void setMyLocationTrackingMode(int myLocationTrackingMode) { + this.myLocationTrackingMode = myLocationTrackingMode; + } + + @Override + public void setMyLocationRenderMode(int myLocationRenderMode) { + this.myLocationRenderMode = myLocationRenderMode; + } + + @Override + public void setLogoEnabled(boolean logoEnabled) { + options.logoEnabled(logoEnabled); + } + + @Override + public void setLogoViewGravity(int gravity) { + switch (gravity) { + case 0: + options.logoGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.logoGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.logoGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.logoGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + public void setLogoViewMargins(int x, int y) { + options.logoMargins( + new int[] { + (int) x, // left + (int) 0, // top + (int) 0, // right + (int) y, // bottom + }); + } + + @Override + public void setCompassGravity(int gravity) { + switch (gravity) { + case 0: + options.compassGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.compassGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.compassGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.compassGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setCompassViewMargins(int x, int y) { + switch (options.getCompassGravity()) { + case Gravity.TOP | Gravity.START: + options.compassMargins(new int[] {(int) x, (int) y, 0, 0}); + break; + // If the application code has not specified gravity, assume the platform + // default for the compass which is top-right + default: + case Gravity.TOP | Gravity.END: + options.compassMargins(new int[] {0, (int) y, (int) x, 0}); + break; + case Gravity.BOTTOM | Gravity.START: + options.compassMargins(new int[] {(int) x, 0, 0, (int) y}); + break; + case Gravity.BOTTOM | Gravity.END: + options.compassMargins(new int[] {0, 0, (int) x, (int) y}); + break; + } + } + + @Override + public void setAttributionButtonGravity(int gravity) { + switch (gravity) { + case 0: + options.attributionGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.attributionGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.attributionGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.attributionGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setAttributionButtonMargins(int x, int y) { + switch (options.getAttributionGravity()) { + case Gravity.TOP | Gravity.START: + options.attributionMargins(new int[] {(int) x, (int) y, 0, 0}); + break; + case Gravity.TOP | Gravity.END: + options.attributionMargins(new int[] {0, (int) y, (int) x, 0}); + break; + // If the application code has not specified gravity, assume the platform + // default for the attribution button which is bottom left + default: + case Gravity.BOTTOM | Gravity.START: + options.attributionMargins(new int[] {(int) x, 0, 0, (int) y}); + break; + case Gravity.BOTTOM | Gravity.END: + options.attributionMargins(new int[] {0, 0, (int) x, (int) y}); + break; + } + } + + public void setDragEnabled(boolean enabled) { + this.dragEnabled = enabled; + } + + @Override + public void setLocationEngineProperties(@NonNull LocationEngineRequest locationEngineRequest) { + this.locationEngineRequest = locationEngineRequest; + } + + @Override + public void setForegroundLoadColor(int loadColor) { + options.foregroundLoadColor(loadColor); + } + + @Override + public void setTranslucentTextureSurface(boolean translucentTextureSurface) { + options.translucentTextureSurface(translucentTextureSurface); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java new file mode 100644 index 00000000..e26dbfb3 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java @@ -0,0 +1,2555 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.AssetFileDescriptor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.PointF; +import android.graphics.RectF; +import android.location.Location; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Pair; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.TextureView; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import org.jetbrains.annotations.NotNull; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdate; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.constants.MapLibreConstants; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.geometry.LatLngQuad; +import org.maplibre.android.geometry.VisibleRegion; +import org.maplibre.android.gestures.AndroidGesturesManager; +import org.maplibre.android.gestures.MoveGestureDetector; +import org.maplibre.android.location.LocationComponent; +import org.maplibre.android.location.LocationComponentActivationOptions; +import org.maplibre.android.location.LocationComponentOptions; +import org.maplibre.android.location.OnCameraTrackingChangedListener; +import org.maplibre.android.location.engine.LocationEngineCallback; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.android.location.engine.LocationEngineResult; +import org.maplibre.android.location.modes.CameraMode; +import org.maplibre.android.location.modes.RenderMode; +import org.maplibre.android.maps.MapLibreMap; +import org.maplibre.android.maps.MapLibreMapOptions; +import org.maplibre.android.maps.MapView; +import org.maplibre.android.maps.OnMapReadyCallback; +import org.maplibre.android.maps.Style; +import org.maplibre.android.offline.OfflineManager; +import org.maplibre.android.style.expressions.Expression; +import org.maplibre.android.style.layers.CircleLayer; +import org.maplibre.android.style.layers.FillExtrusionLayer; +import org.maplibre.android.style.layers.FillLayer; +import org.maplibre.android.style.layers.HeatmapLayer; +import org.maplibre.android.style.layers.HillshadeLayer; +import org.maplibre.android.style.layers.Layer; +import org.maplibre.android.style.layers.LineLayer; +import org.maplibre.android.style.layers.Property; +import org.maplibre.android.style.layers.PropertyFactory; +import org.maplibre.android.style.layers.PropertyValue; +import org.maplibre.android.style.layers.RasterLayer; +import org.maplibre.android.style.layers.SymbolLayer; +import org.maplibre.android.style.sources.CustomGeometrySource; +import org.maplibre.android.style.sources.GeoJsonSource; +import org.maplibre.android.style.sources.ImageSource; +import org.maplibre.android.style.sources.Source; +import org.maplibre.android.style.sources.VectorSource; +import org.maplibre.geojson.Feature; +import org.maplibre.geojson.FeatureCollection; +import org.maplibre.android.net.ConnectivityReceiver; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.platform.PlatformView; + + +/** Controller of a single MapLibreMaps MapView instance. */ +@SuppressLint("MissingPermission") +final class MapLibreMapController + implements DefaultLifecycleObserver, + MapLibreMap.OnCameraIdleListener, + MapLibreMap.OnCameraMoveListener, + MapLibreMap.OnCameraMoveStartedListener, + MapView.OnDidBecomeIdleListener, + MapLibreMap.OnMapClickListener, + MapLibreMap.OnMapLongClickListener, + MapLibreMapOptionsSink, + MethodChannel.MethodCallHandler, + OnMapReadyCallback, + OnCameraTrackingChangedListener, + PlatformView { + private static final String TAG = "MapLibreMapController"; + private final int id; + private final MethodChannel methodChannel; + private final MapLibreMapsPlugin.LifecycleProvider lifecycleProvider; + private final float density; + private final Context context; + private final String styleStringInitial; + /** + * This container is returned as the final platform view instead of returning `mapView`. + * See {@link MapLibreMapController#destroyMapViewIfNecessary()} for details. + */ + private FrameLayout mapViewContainer; + private MapView mapView; + private MapLibreMap mapLibreMap; + private boolean trackCameraPosition = false; + private boolean myLocationEnabled = false; + private int myLocationTrackingMode = 0; + private int myLocationRenderMode = 0; + private LocationEngineFactory myLocationEngineFactory = new LocationEngineFactory(); + private boolean disposed = false; + private boolean dragEnabled = true; + private MethodChannel.Result mapReadyResult; + private LocationComponent locationComponent = null; + private LocationEngineCallback locationEngineCallback = null; + private Style style; + private Feature draggedFeature; + private AndroidGesturesManager androidGesturesManager; + + private LatLng dragOrigin; + private LatLng dragPrevious; + + private Set interactiveFeatureLayerIds; + private Map addedFeaturesByLayer; + + private LatLngBounds bounds = null; + Style.OnStyleLoaded onStyleLoadedCallback = + new Style.OnStyleLoaded() { + @Override + public void onStyleLoaded(@NonNull Style style) { + MapLibreMapController.this.style = style; + + // commented out while cherry-picking upstream956 + // if (myLocationEnabled) { + // if (hasLocationPermission()) { + // updateMyLocationEnabled(); + // } + // } + updateMyLocationEnabled(); + + if (null != bounds) { + setCameraTargetBounds(bounds); + } + + mapLibreMap.addOnMapClickListener(MapLibreMapController.this); + mapLibreMap.addOnMapLongClickListener(MapLibreMapController.this); + + methodChannel.invokeMethod("map#onStyleLoaded", null); + } + }; + + MapLibreMapController( + int id, + Context context, + BinaryMessenger messenger, + MapLibreMapsPlugin.LifecycleProvider lifecycleProvider, + MapLibreMapOptions options, + String styleStringInitial, + boolean dragEnabled) { + MapLibreUtils.getMapLibre(context); + this.id = id; + this.context = context; + this.dragEnabled = dragEnabled; + this.styleStringInitial = styleStringInitial; + this.mapViewContainer = new FrameLayout(context); + this.mapView = new MapView(context, options); + this.interactiveFeatureLayerIds = new HashSet<>(); + this.addedFeaturesByLayer = new HashMap(); + this.density = context.getResources().getDisplayMetrics().density; + this.lifecycleProvider = lifecycleProvider; + if (dragEnabled) { + this.androidGesturesManager = new AndroidGesturesManager(this.mapView.getContext(), false); + } + + mapViewContainer.addView(mapView); + methodChannel = new MethodChannel(messenger, "plugins.flutter.io/maplibre_gl_" + id); + methodChannel.setMethodCallHandler(this); + } + + @Override + public View getView() { + return mapViewContainer; + } + + void init() { + lifecycleProvider.getLifecycle().addObserver(this); + mapView.getMapAsync(this); + } + + private void moveCamera(CameraUpdate cameraUpdate) { + mapLibreMap.moveCamera(cameraUpdate); + } + + private void animateCamera(CameraUpdate cameraUpdate) { + mapLibreMap.animateCamera(cameraUpdate); + } + + private CameraPosition getCameraPosition() { + return trackCameraPosition ? mapLibreMap.getCameraPosition() : null; + } + + @Override + public void onMapReady(MapLibreMap mapLibreMap) { + this.mapLibreMap = mapLibreMap; + if (mapReadyResult != null) { + mapReadyResult.success(null); + mapReadyResult = null; + } + mapLibreMap.addOnCameraMoveStartedListener(this); + mapLibreMap.addOnCameraMoveListener(this); + mapLibreMap.addOnCameraIdleListener(this); + + // Apply camera target bounds if set during initialization + if (bounds != null) { + mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + } + + if (androidGesturesManager != null) { + androidGesturesManager.setMoveGestureListener(new MoveGestureListener()); + mapView.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + androidGesturesManager.onTouchEvent(event); + + return draggedFeature != null; + } + }); + } + + mapView.addOnStyleImageMissingListener( + (id) -> { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + final Bitmap bitmap = getScaledImage(id, displayMetrics.density); + if (bitmap != null) { + mapLibreMap.getStyle().addImage(id, bitmap); + } + }); + + mapView.addOnDidBecomeIdleListener(this); + + setStyleString(styleStringInitial); + } + + @Override + public void setStyleString(@NonNull String styleString) { + // clear old layer id from the location Component + clearLocationComponentLayer(); + styleString = styleString.trim(); + + // Prevent race conditions: invalidate current style reference & interactive layers + // Old Style instances become invalid immediately after setStyle is called. + this.style = null; + if (interactiveFeatureLayerIds != null) { + interactiveFeatureLayerIds.clear(); + } + + // Check if json, url, absolute path or asset path: + if (styleString == null || styleString.isEmpty()) { + Log.e(TAG, "setStyleString - string empty or null"); + } else if (styleString.startsWith("{") || styleString.startsWith("[")) { + mapLibreMap.setStyle(new Style.Builder().fromJson(styleString), onStyleLoadedCallback); + } else if (styleString.startsWith("/")) { + // Absolute path + mapLibreMap.setStyle( + new Style.Builder().fromUri("file://" + styleString), onStyleLoadedCallback); + } else if (!styleString.startsWith("http://") + && !styleString.startsWith("https://") + && !styleString.startsWith("mapbox://")) { + // We are assuming that the style will be loaded from an asset here. + String key = MapLibreMapsPlugin.flutterAssets.getAssetFilePathByName(styleString); + mapLibreMap.setStyle(new Style.Builder().fromUri("asset://" + key), onStyleLoadedCallback); + } else { + mapLibreMap.setStyle(new Style.Builder().fromUri(styleString), onStyleLoadedCallback); + } + } + + + + @SuppressWarnings({"MissingPermission"}) + private void enableLocationComponent(@NonNull Style style) { + if (hasLocationPermission()) { + + locationComponent = mapLibreMap.getLocationComponent(); + + LocationComponentActivationOptions options = + LocationComponentActivationOptions + .builder(context, style) + .locationComponentOptions(buildLocationComponentOptions(style)) + .locationEngine(myLocationEngineFactory.getLocationEngine(context)) + .build(); + + locationComponent.activateLocationComponent(options); + locationComponent.setLocationComponentEnabled(true); + locationComponent.setMaxAnimationFps(30); + updateMyLocationTrackingMode(); + updateMyLocationRenderMode(); + locationComponent.addOnCameraTrackingChangedListener(this); + } else { + Log.e(TAG, "missing location permissions"); + } + } + + private void updateLocationComponentLayer() { + if (locationComponent != null && locationComponentRequiresUpdate()) { + locationComponent.applyStyle(buildLocationComponentOptions(style)); + } + } + + private void clearLocationComponentLayer() { + if (locationComponent != null) { + locationComponent.applyStyle(buildLocationComponentOptions(null)); + } + } + + String getLastLayerOnStyle(Style style) { + if (style == null) return null; + if (!style.isFullyLoaded()) { + Log.d(TAG, "getLastLayerOnStyle: style not fully loaded yet"); + return null; + } + + final List layers = style.getLayers(); + if (layers.size() > 0) { + return layers.get(layers.size() - 1).getId(); + } + + return null; + } + + /// only update if the last layer is not the mapbox-location-bearing-layer + boolean locationComponentRequiresUpdate() { + final String lastLayerId = getLastLayerOnStyle(style); + return lastLayerId != null && !lastLayerId.equals("mapbox-location-bearing-layer"); + } + + private LocationComponentOptions buildLocationComponentOptions(Style style) { + final LocationComponentOptions.Builder optionsBuilder = + LocationComponentOptions.builder(context); + optionsBuilder.trackingGesturesManagement(true); + + final String lastLayerId = getLastLayerOnStyle(style); + if (lastLayerId != null) { + optionsBuilder.layerAbove(lastLayerId); + } + return optionsBuilder.build(); + } + + private void onUserLocationUpdate(Location location) { + if (location == null) { + return; + } + + final Map userLocation = new HashMap<>(6); + userLocation.put("position", new double[] {location.getLatitude(), location.getLongitude()}); + userLocation.put("speed", location.getSpeed()); + userLocation.put("altitude", location.getAltitude()); + userLocation.put("bearing", location.getBearing()); + userLocation.put("speed", location.getSpeed()); + userLocation.put("horizontalAccuracy", location.getAccuracy()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + userLocation.put( + "verticalAccuracy", + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + ? location.getVerticalAccuracyMeters() + : null); + } + userLocation.put("timestamp", location.getTime()); + + final Map arguments = new HashMap<>(1); + arguments.put("userLocation", userLocation); + methodChannel.invokeMethod("map#onUserLocationUpdated", arguments); + } + + private void addGeoJsonSource(String sourceName, String source) { + FeatureCollection featureCollection = FeatureCollection.fromJson(source); + GeoJsonSource geoJsonSource = new GeoJsonSource(sourceName, featureCollection); + addedFeaturesByLayer.put(sourceName, featureCollection); + + style.addSource(geoJsonSource); + } + + private void setGeoJsonSource(String sourceName, String geojson) { + FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + GeoJsonSource geoJsonSource = style.getSourceAs(sourceName); + addedFeaturesByLayer.put(sourceName, featureCollection); + + geoJsonSource.setGeoJson(featureCollection); + } + + private void setGeoJsonFeature(String sourceName, String geojsonFeature) { + Feature feature = Feature.fromJson(geojsonFeature); + FeatureCollection featureCollection = addedFeaturesByLayer.get(sourceName); + GeoJsonSource geoJsonSource = style.getSourceAs(sourceName); + if (featureCollection != null && geoJsonSource != null) { + final List features = featureCollection.features(); + for (int i = 0; i < features.size(); i++) { + final String id = features.get(i).id(); + if (id.equals(feature.id())) { + features.set(i, feature); + break; + } + } + + geoJsonSource.setGeoJson(featureCollection); + } + } + + private void addSymbolLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + SymbolLayer symbolLayer = new SymbolLayer(layerName, sourceName); + symbolLayer.setProperties(properties); + if (sourceLayer != null) { + symbolLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + symbolLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + symbolLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + symbolLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(symbolLayer, belowLayerId); + } else { + style.addLayer(symbolLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addLineLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + LineLayer lineLayer = new LineLayer(layerName, sourceName); + lineLayer.setProperties(properties); + if (sourceLayer != null) { + lineLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + lineLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + lineLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + lineLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(lineLayer, belowLayerId); + } else { + style.addLayer(lineLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addFillLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + FillLayer fillLayer = new FillLayer(layerName, sourceName); + fillLayer.setProperties(properties); + if (sourceLayer != null) { + fillLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + fillLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + fillLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + fillLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(fillLayer, belowLayerId); + } else { + style.addLayer(fillLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addFillExtrusionLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + FillExtrusionLayer fillLayer = new FillExtrusionLayer(layerName, sourceName); + fillLayer.setProperties(properties); + if (sourceLayer != null) { + fillLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + fillLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + fillLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + fillLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(fillLayer, belowLayerId); + } else { + style.addLayer(fillLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addCircleLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + CircleLayer circleLayer = new CircleLayer(layerName, sourceName); + circleLayer.setProperties(properties); + if (sourceLayer != null) { + circleLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + circleLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + circleLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + circleLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(circleLayer, belowLayerId); + } else { + style.addLayer(circleLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private Expression parseFilter(String filter) { + JsonParser parser = new JsonParser(); + JsonElement filterJsonElement = parser.parse(filter); + return filterJsonElement.isJsonNull() ? null : Expression.Converter.convert(filterJsonElement); + } + + private void addRasterLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + RasterLayer layer = new RasterLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private void addHillshadeLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + HillshadeLayer layer = new HillshadeLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private void addHeatmapLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + HeatmapLayer layer = new HeatmapLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private Pair firstFeatureOnLayers(RectF in) { + if (style == null) return null; + if (!style.isFullyLoaded()) { + Log.d(TAG, "firstFeatureOnLayers: style not fully loaded yet"); + return null; + } + + final List layers; + try { + layers = style.getLayers(); + } catch (IllegalStateException ex) { + // Style object is stale (a new style is loading/has loaded). Skip querying. + Log.w(TAG, "firstFeatureOnLayers: Style.getLayers() failed: " + ex.getMessage()); + return null; + } + final List layersInOrder = new ArrayList(); + for (Layer layer : layers) { + String id = layer.getId(); + if (interactiveFeatureLayerIds.contains(id)) layersInOrder.add(id); + } + Collections.reverse(layersInOrder); + for (String id : layersInOrder) { + List features = mapLibreMap.queryRenderedFeatures(in, id); + if (!features.isEmpty()) { + return new Pair(features.get(0), id); + } + } + + return null; + } + + @Override + public void onMethodCall(MethodCall call, MethodChannel.Result result) { + switch (call.method) { + case "map#waitForMap": + if (mapLibreMap != null) { + result.success(null); + return; + } + mapReadyResult = result; + break; + case "map#update": + { + Convert.interpretMapLibreMapOptions(call.argument("options"), this, context); + result.success(Convert.toJson(getCameraPosition())); + break; + } + case "map#updateMyLocationTrackingMode": + { + int myLocationTrackingMode = call.argument("mode"); + setMyLocationTrackingMode(myLocationTrackingMode); + result.success(null); + break; + } + case "map#matchMapLanguageWithDeviceDefault": + { + try { + final Locale deviceLocale = Locale.getDefault(); + MapLibreMapUtils.setMapLanguage(mapLibreMap, deviceLocale.getLanguage()); + + result.success(null); + } catch (RuntimeException exception) { + Log.d(TAG, exception.toString()); + result.error("MAPBOX LOCALIZATION PLUGIN ERROR", exception.toString(), null); + } + break; + } + case "map#updateContentInsets": + { + HashMap insets = call.argument("bounds"); + final CameraUpdate cameraUpdate = + CameraUpdateFactory.paddingTo( + Convert.toPixels(insets.get("left"), density), + Convert.toPixels(insets.get("top"), density), + Convert.toPixels(insets.get("right"), density), + Convert.toPixels(insets.get("bottom"), density)); + + if (call.argument("animated")) { + animateCamera(cameraUpdate, null, result); + } else { + moveCamera(cameraUpdate, result); + } + break; + } + case "map#setMapLanguage": + { + final String language = call.argument("language"); + try { + MapLibreMapUtils.setMapLanguage(mapLibreMap, language); + + result.success(null); + } catch (RuntimeException exception) { + Log.d(TAG, exception.toString()); + result.error("MAPBOX LOCALIZATION PLUGIN ERROR", exception.toString(), null); + } + break; + } + case "map#getVisibleRegion": + { + Map reply = new HashMap<>(); + VisibleRegion visibleRegion = mapLibreMap.getProjection().getVisibleRegion(); + reply.put( + "sw", + Arrays.asList( + visibleRegion.latLngBounds.getLatSouth(), visibleRegion.latLngBounds.getLonWest())); + reply.put( + "ne", + Arrays.asList( + visibleRegion.latLngBounds.getLatNorth(), visibleRegion.latLngBounds.getLonEast())); + + result.success(reply); + break; + } + case "map#toScreenLocation": + { + Map reply = new HashMap<>(); + PointF pointf = + mapLibreMap + .getProjection() + .toScreenLocation( + new LatLng(call.argument("latitude"), call.argument("longitude"))); + reply.put("x", pointf.x); + reply.put("y", pointf.y); + result.success(reply); + break; + } + case "map#toScreenLocationBatch": + { + double[] param = (double[]) call.argument("coordinates"); + double[] reply = new double[param.length]; + + for (int i = 0; i < param.length; i += 2) { + PointF pointf = + mapLibreMap.getProjection().toScreenLocation(new LatLng(param[i], param[i + 1])); + reply[i] = pointf.x; + reply[i + 1] = pointf.y; + } + + result.success(reply); + break; + } + case "map#toLatLng": + { + Map reply = new HashMap<>(); + LatLng latlng = + mapLibreMap + .getProjection() + .fromScreenLocation( + new PointF( + ((Double) call.argument("x")).floatValue(), + ((Double) call.argument("y")).floatValue())); + reply.put("latitude", latlng.getLatitude()); + reply.put("longitude", latlng.getLongitude()); + result.success(reply); + break; + } + case "map#getMetersPerPixelAtLatitude": + { + Map reply = new HashMap<>(); + Double retVal = + mapLibreMap + .getProjection() + .getMetersPerPixelAtLatitude((Double) call.argument("latitude")); + reply.put("metersperpixel", retVal); + result.success(reply); + break; + } + case "camera#move": + { + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport. MapLibre's transform unprojects against the viewport and + // can abort with an uncatchable native error when it is + // degenerate/zero-sized. Complete the Future so Dart's await returns. + if (mapView == null || mapView.getWidth() < 1 || mapView.getHeight() < 1) { + result.success(false); + break; + } + final CameraUpdate cameraUpdate = + Convert.toCameraUpdate(call.argument("cameraUpdate"), mapLibreMap, density); + if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.moveCamera( + cameraUpdate, + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }); + + // moveCamera(cameraUpdate); + } else { + result.success(false); + } + break; + } + case "camera#animate": + { + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#move) — prevents the uncatchable native abort. + if (mapView == null || mapView.getWidth() < 1 || mapView.getHeight() < 1) { + result.success(false); + break; + } + final CameraUpdate cameraUpdate = + Convert.toCameraUpdate(call.argument("cameraUpdate"), mapLibreMap, density); + final Integer duration = call.argument("duration"); + + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + if (cameraUpdate != null && duration != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, duration, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + break; + } + case "map#queryRenderedFeatures": + { + Map reply = new HashMap<>(); + List features; + + String[] layerIds = ((List) call.argument("layerIds")).toArray(new String[0]); + + List filter = call.argument("filter"); + JsonElement jsonElement = filter == null ? null : new Gson().toJsonTree(filter); + JsonArray jsonArray = null; + if (jsonElement != null && jsonElement.isJsonArray()) { + jsonArray = jsonElement.getAsJsonArray(); + } + Expression filterExpression = + jsonArray == null ? null : Expression.Converter.convert(jsonArray); + if (call.hasArgument("x")) { + Double x = call.argument("x"); + Double y = call.argument("y"); + PointF pixel = new PointF(x.floatValue(), y.floatValue()); + features = mapLibreMap.queryRenderedFeatures(pixel, filterExpression, layerIds); + } else { + Double left = call.argument("left"); + Double top = call.argument("top"); + Double right = call.argument("right"); + Double bottom = call.argument("bottom"); + RectF rectF = + new RectF( + left.floatValue(), top.floatValue(), right.floatValue(), bottom.floatValue()); + features = mapLibreMap.queryRenderedFeatures(rectF, filterExpression, layerIds); + } + List featuresJson = new ArrayList<>(); + for (Feature feature : features) { + featuresJson.add(feature.toJson()); + } + reply.put("features", featuresJson); + result.success(reply); + break; + } + case "map#setTelemetryEnabled": + { + result.success(null); + break; + } + case "map#getTelemetryEnabled": + { + result.success(false); + break; + } + case "map#setMaximumFps": + { + final int fps = call.argument("fps"); + if (mapView != null) { + mapView.setMaximumFps(fps); + } + result.success(null); + break; + } + case "map#forceOnlineMode": + { + // Force online mode by setting connectivity to true + if (mapView != null) { + ConnectivityReceiver.instance(mapView.getContext()).setConnected(true); + } + result.success(null); + break; + } + case "camera#ease": + { + final CameraUpdate cameraUpdate = Convert.toCameraUpdate(call.argument("cameraUpdate"), mapLibreMap, density); + final Integer duration = call.argument("duration"); + + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + + if (cameraUpdate != null && duration != null && duration > 0) { + // camera transformation not handled yet + mapLibreMap.easeCamera(cameraUpdate, duration, false, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.easeCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + break; + } + case "map#queryCameraPosition": + { + result.success(Convert.toJson(mapLibreMap.getCameraPosition())); + break; + } + case "map#editGeoJsonSource": + { + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + GeoJsonSource source = style.getSourceAs(call.argument("id")); + if (source != null) { + source.setGeoJson((String)call.argument("data")); + ret = true; + } + } catch (Exception e) {} + } + } + Map reply = new HashMap<>(); + reply.put("result", ret); + result.success(reply); + break; + } + case "map#editGeoJsonUrl": + { + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + GeoJsonSource source = style.getSourceAs(call.argument("id")); + if (source != null) { + source.setUrl((String)call.argument("url")); + ret = true; + } + } catch (Exception e) {} + } + } + Map reply = new HashMap<>(); + reply.put("result", ret); + result.success(reply); + break; + } + case "map#setLayerFilter": + { + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + Layer layer = style.getLayer(call.argument("id")); + if (layer != null) { + String filter = call.argument("filter"); + if (filter != null) { + Expression expression = Expression.raw(filter); + if (expression != null) { + if (layer instanceof LineLayer) { + ((LineLayer)layer).setFilter(expression); + ret = true; + } else if (layer instanceof FillLayer) { + ((FillLayer)layer).setFilter(expression); + ret = true; + } else if (layer instanceof SymbolLayer) { + ((SymbolLayer)layer).setFilter(expression); + ret = true; + } + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } + Map reply = new HashMap<>(); + reply.put("result", ret); + result.success(reply); + break; + } + case "map#getStyle": + { + Map reply = new HashMap<>(); + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + String json = style.getJson(); + reply.put("json", json); + ret = true; + } catch (Exception e) {} + } + } + reply.put("result", ret); + result.success(reply); + break; + } + case "map#setCustomHeaders": + { + if (mapLibreMap != null) { + HashMap headers = (HashMap)call.argument("headers"); + List filter = (List)call.argument("filter"); + MapLibreCustomHttpInterceptor.setCustomHeaders(headers, filter, result); + } else { + result.success(null); + } + break; + } + case "map#getCustomHeaders": + { + if (mapLibreMap != null) { + result.success(MapLibreCustomHttpInterceptor.CustomHeaders); + } else { + result.success(null); + } + break; + } + case "map#invalidateAmbientCache": + { + OfflineManager fileSource = OfflineManager.Companion.getInstance(context); + + fileSource.invalidateAmbientCache( + new OfflineManager.FileSourceCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onError(@NonNull String message) { + result.error("MAPBOX CACHE ERROR", message, null); + } + }); + break; + } + case "map#clearAmbientCache": + { + OfflineManager fileSource = OfflineManager.Companion.getInstance(context); + + fileSource.clearAmbientCache( + new OfflineManager.FileSourceCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onError(@NonNull String message) { + result.error("MAPBOX CACHE ERROR", message, null); + } + }); + break; + } + case "source#addGeoJson": + { + final String sourceId = call.argument("sourceId"); + final String geojson = call.argument("geojson"); + addGeoJsonSource(sourceId, geojson); + result.success(null); + break; + } + case "source#setGeoJson": + { + final String sourceId = call.argument("sourceId"); + final String geojson = call.argument("geojson"); + setGeoJsonSource(sourceId, geojson); + result.success(null); + break; + } + case "source#setFeature": + { + final String sourceId = call.argument("sourceId"); + final String geojsonFeature = call.argument("geojsonFeature"); + setGeoJsonFeature(sourceId, geojsonFeature); + result.success(null); + break; + } + case "symbolLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretSymbolLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addSymbolLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "lineLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretLineLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addLineLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "layer#setProperties": { + final String layerId = call.argument("layerId"); + + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + + Layer layer = style.getLayer(layerId); + + if (layer != null) { + final PropertyValue[] properties; + + if (layer instanceof LineLayer) { + properties = LayerPropertyConverter + .interpretLineLayerProperties(call.argument("properties")); + } else if (layer instanceof FillLayer) { + properties = LayerPropertyConverter + .interpretFillLayerProperties(call.argument("properties")); + } else if (layer instanceof CircleLayer) { + properties = LayerPropertyConverter + .interpretCircleLayerProperties(call.argument("properties")); + } else if (layer instanceof SymbolLayer) { + properties = LayerPropertyConverter + .interpretSymbolLayerProperties(call.argument("properties")); + } else if (layer instanceof RasterLayer) { + properties = LayerPropertyConverter + .interpretRasterLayerProperties(call.argument("properties")); + } else if (layer instanceof HillshadeLayer) { + properties = LayerPropertyConverter + .interpretHillshadeLayerProperties(call.argument("properties")); + } else { + result.error("UNSUPPORTED_LAYER_TYPE", "Layer type not supported", null); + return; + } + layer.setProperties(properties); + result.success(null); + } else { + result.error("LAYER_NOT_FOUND_ERROR", "Layer " + layerId + "not found", null); + } + + break; + } + case "fillLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretFillLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addFillLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "fillExtrusionLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretFillExtrusionLayerProperties( + call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addFillExtrusionLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "circleLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretCircleLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addCircleLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "rasterLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretRasterLayerProperties(call.argument("properties")); + addRasterLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "hillshadeLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretHillshadeLayerProperties(call.argument("properties")); + addHillshadeLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "heatmapLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretHeatmapLayerProperties(call.argument("properties")); + addHeatmapLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "locationComponent#getLastLocation": + { + Log.e(TAG, "location component: getLastLocation"); + if (this.myLocationEnabled + && locationComponent != null + && locationComponent.isLocationComponentActivated() + && locationComponent.getLocationEngine() != null) { + Map reply = new HashMap<>(); + + mapLibreMap.getLocationComponent().getLocationEngine().getLastLocation( + new LocationEngineCallback() { + @Override + public void onSuccess(LocationEngineResult locationEngineResult) { + Location lastLocation = locationEngineResult.getLastLocation(); + if (lastLocation != null) { + reply.put("latitude", lastLocation.getLatitude()); + reply.put("longitude", lastLocation.getLongitude()); + reply.put("altitude", lastLocation.getAltitude()); + result.success(reply); + } else { + result.error("", "", null); // ??? + } + } + + @Override + public void onFailure(@NonNull Exception exception) { + result.error("", "", null); // ??? + } + }); + } else { + result.error( + "LOCATION DISABLED", + "Location is disabled or location component is unavailable", + null); + } + break; + } + case "style#addImage": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + // Configure bitmap options to prevent density-based scaling + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = false; // Disable automatic scaling + options.inDensity = 0; // No source density + options.inTargetDensity = 0; // No target density + + Bitmap bitmap = BitmapFactory.decodeByteArray( + call.argument("bytes"), + 0, + call.argument("length"), + options); + + style.addImage( + call.argument("name"), + bitmap, + call.argument("sdf")); + result.success(null); + break; + } + case "style#addImageSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + List coordinates = Convert.toLatLngList(call.argument("coordinates"), false); + style.addSource( + new ImageSource( + call.argument("imageSourceId"), + new LatLngQuad( + coordinates.get(0), + coordinates.get(1), + coordinates.get(2), + coordinates.get(3)), + BitmapFactory.decodeByteArray( + call.argument("bytes"), 0, call.argument("length")))); + result.success(null); + break; + } + case "style#updateImageSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + ImageSource imageSource = style.getSourceAs(call.argument("imageSourceId")); + List coordinates = Convert.toLatLngList(call.argument("coordinates"), false); + if (coordinates != null) { + imageSource.setCoordinates( + new LatLngQuad( + coordinates.get(0), + coordinates.get(1), + coordinates.get(2), + coordinates.get(3))); + } + byte[] bytes = call.argument("bytes"); + if (bytes != null) { + imageSource.setImage(BitmapFactory.decodeByteArray(bytes, 0, call.argument("length"))); + } + result.success(null); + break; + } + case "style#addSource": + { + final String id = Convert.toString(call.argument("sourceId")); + final Map properties = (Map) call.argument("properties"); + SourcePropertyConverter.addSource(id, properties, style); + result.success(null); + break; + } + + case "style#removeSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + style.removeSource((String) call.argument("sourceId")); + result.success(null); + break; + } + case "style#addLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + addRasterLayer( + call.argument("imageLayerId"), + call.argument("imageSourceId"), + call.argument("minzoom") != null + ? ((Double) call.argument("minzoom")).floatValue() + : null, + call.argument("maxzoom") != null + ? ((Double) call.argument("maxzoom")).floatValue() + : null, + null, + new PropertyValue[] {}, + null); + result.success(null); + break; + } + case "style#addLayerBelow": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + addRasterLayer( + call.argument("imageLayerId"), + call.argument("imageSourceId"), + call.argument("minzoom") != null + ? ((Double) call.argument("minzoom")).floatValue() + : null, + call.argument("maxzoom") != null + ? ((Double) call.argument("maxzoom")).floatValue() + : null, + call.argument("belowLayerId"), + new PropertyValue[] {}, + null); + result.success(null); + break; + } + case "style#removeLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + style.removeLayer(layerId); + interactiveFeatureLayerIds.remove(layerId); + + result.success(null); + break; + } + case "map#setCameraBounds": + { + double west = call.argument("west"); + double north = call.argument("north"); + double south = call.argument("south"); + double east = call.argument("east"); + + int padding = call.argument("padding"); + + LatLng locationOne = new LatLng(north, east); + LatLng locationTwo = new LatLng(south, west); + LatLngBounds latLngBounds = new LatLngBounds.Builder() + .include(locationOne) // Northeast + .include(locationTwo) // Southwest + .build(); + mapLibreMap.easeCamera(CameraUpdateFactory.newLatLngBounds(latLngBounds, + padding), 200); + + break; + } + case "style#setFilter": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + String filter = call.argument("filter"); + + Layer layer = style.getLayer(layerId); + + JsonParser parser = new JsonParser(); + JsonElement jsonElement = parser.parse(filter); + Expression expression = Expression.Converter.convert(jsonElement); + + if (layer instanceof CircleLayer) { + ((CircleLayer) layer).setFilter(expression); + } else if (layer instanceof FillExtrusionLayer) { + ((FillExtrusionLayer) layer).setFilter(expression); + } else if (layer instanceof FillLayer) { + ((FillLayer) layer).setFilter(expression); + } else if (layer instanceof HeatmapLayer) { + ((HeatmapLayer) layer).setFilter(expression); + } else if (layer instanceof LineLayer) { + ((LineLayer) layer).setFilter(expression); + } else if (layer instanceof SymbolLayer) { + ((SymbolLayer) layer).setFilter(expression); + } else { + result.error( + "INVALID LAYER TYPE", + String.format("Layer '%s' does not support filtering.", layerId), + null); + break; + } + + result.success(null); + break; + } + case "style#getFilter": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + Map reply = new HashMap<>(); + String layerId = call.argument("layerId"); + Layer layer = style.getLayer(layerId); + + Expression filter; + if (layer instanceof CircleLayer) { + filter = ((CircleLayer) layer).getFilter(); + } else if (layer instanceof FillExtrusionLayer) { + filter = ((FillExtrusionLayer) layer).getFilter(); + } else if (layer instanceof FillLayer) { + filter = ((FillLayer) layer).getFilter(); + } else if (layer instanceof HeatmapLayer) { + filter = ((HeatmapLayer) layer).getFilter(); + } else if (layer instanceof LineLayer) { + filter = ((LineLayer) layer).getFilter(); + } else if (layer instanceof SymbolLayer) { + filter = ((SymbolLayer) layer).getFilter(); + } else { + result.error( + "INVALID LAYER TYPE", + String.format("Layer '%s' does not support filtering.", layerId), + null); + break; + } + + reply.put("filter", filter.toString()); + result.success(reply); + break; + } + case "layer#setVisibility": + { + + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + boolean visible = call.argument("visible"); + + Layer layer = style.getLayer(layerId); + + if (layer != null) { + layer.setProperties(PropertyFactory.visibility(visible ? Property.VISIBLE : Property.NONE)); + } + + result.success(null); + break; + + } + case "map#querySourceFeatures": + { + Map reply = new HashMap<>(); + List features; + + String sourceId = (String) call.argument("sourceId"); + + String sourceLayerId = (String) call.argument("sourceLayerId"); + + List filter = call.argument("filter"); + JsonElement jsonElement = filter == null ? null : new Gson().toJsonTree(filter); + JsonArray jsonArray = null; + if (jsonElement != null && jsonElement.isJsonArray()) { + jsonArray = jsonElement.getAsJsonArray(); + } + Expression filterExpression = + jsonArray == null ? null : Expression.Converter.convert(jsonArray); + + + Source source = style.getSource(sourceId); + if (source instanceof GeoJsonSource) { + features = ((GeoJsonSource) source).querySourceFeatures(filterExpression); + } else if (source instanceof CustomGeometrySource) { + features = ((CustomGeometrySource) source).querySourceFeatures(filterExpression); + } else if (source instanceof VectorSource && sourceLayerId != null) { + features = ((VectorSource) source).querySourceFeatures(new String[] {sourceLayerId}, filterExpression); + } else { + features = Collections.emptyList(); + } + + List featuresJson = new ArrayList<>(); + for (Feature feature : features) { + featuresJson.add(feature.toJson()); + } + reply.put("features", featuresJson); + result.success(reply); + break; + } + case "style#getLayerIds": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + Map reply = new HashMap<>(); + + List layerIds = new ArrayList<>(); + for (Layer layer : style.getLayers()) { + layerIds.add(layer.getId()); + } + + reply.put("layers", layerIds); + result.success(reply); + break; + } + case "style#getSourceIds": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + Map reply = new HashMap<>(); + + List sourceIds = new ArrayList<>(); + for (Source source : style.getSources()) { + sourceIds.add(source.getId()); + } + + reply.put("sources", sourceIds); + result.success(reply); + break; + } + case "style#setStyle": + { + // Getting style json, url, path etc. from the flutter side + String styleString = call.argument("style"); + + // Checking if style is null or not + if (styleString != null) { + // If style is not null setting style + setStyleString(styleString); + result.success(null); + } else { + + // else throwing error + result.error( + "STYLE STRING IS NULL", + "The style string is null.", + null + ); + } + break; + } + default: + result.notImplemented(); + } + } + + @Override + public void onCameraMoveStarted(int reason) { + final Map arguments = new HashMap<>(2); + boolean isGesture = reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE; + arguments.put("isGesture", isGesture); + methodChannel.invokeMethod("camera#onMoveStarted", arguments); + } + + @Override + public void onCameraMove() { + if (!trackCameraPosition) { + return; + } + final Map arguments = new HashMap<>(2); + arguments.put("position", Convert.toJson(mapLibreMap.getCameraPosition())); + methodChannel.invokeMethod("camera#onMove", arguments); + } + + @Override + public void onCameraIdle() { + final Map arguments = new HashMap<>(2); + if (trackCameraPosition) { + arguments.put("position", Convert.toJson(mapLibreMap.getCameraPosition())); + } + methodChannel.invokeMethod("camera#onIdle", arguments); + } + + @Override + public void onCameraTrackingChanged(int currentMode) { + final Map arguments = new HashMap<>(2); + switch (currentMode) { + case CameraMode.NONE: + arguments.put("mode", 0); + break; + case CameraMode.TRACKING: + arguments.put("mode", 1); + break; + case CameraMode.TRACKING_COMPASS: + arguments.put("mode", 2); + break; + case CameraMode.TRACKING_GPS: + arguments.put("mode", 3); + break; + default: + Log.e(TAG, "Unable to map " + currentMode + " to a tracking mode"); + return; + } + + methodChannel.invokeMethod("map#onCameraTrackingChanged", arguments); + } + + @Override + public void onCameraTrackingDismissed() { + this.myLocationTrackingMode = 0; + methodChannel.invokeMethod("map#onCameraTrackingDismissed", new HashMap<>()); + } + + @Override + public void onDidBecomeIdle() { + methodChannel.invokeMethod("map#onIdle", new HashMap<>()); + } + + @Override + public boolean onMapClick(@NonNull LatLng point) { + PointF pointf = mapLibreMap.getProjection().toScreenLocation(point); + RectF rectF = new RectF(pointf.x - 10, pointf.y - 10, pointf.x + 10, pointf.y + 10); + Pair featureLayerPair = firstFeatureOnLayers(rectF); + final Map arguments = new HashMap<>(); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("lng", point.getLongitude()); + arguments.put("lat", point.getLatitude()); + if (featureLayerPair != null && featureLayerPair.first != null) { + arguments.put("layerId", featureLayerPair.second); + arguments.put("id", featureLayerPair.first.id()); + methodChannel.invokeMethod("feature#onTap", arguments); + } else { + methodChannel.invokeMethod("map#onMapClick", arguments); + } + return true; + } + + @Override + public boolean onMapLongClick(@NonNull LatLng point) { + PointF pointf = mapLibreMap.getProjection().toScreenLocation(point); + final Map arguments = new HashMap<>(5); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("lng", point.getLongitude()); + arguments.put("lat", point.getLatitude()); + methodChannel.invokeMethod("map#onMapLongClick", arguments); + return true; + } + + @Override + public void dispose() { + if (disposed) { + return; + } + disposed = true; + methodChannel.setMethodCallHandler(null); + destroyMapViewIfNecessary(); + Lifecycle lifecycle = lifecycleProvider.getLifecycle(); + if (lifecycle != null) { + lifecycle.removeObserver(this); + } + } + + private void moveCamera(CameraUpdate cameraUpdate, MethodChannel.Result result) { + if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.moveCamera( + cameraUpdate, + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }); + + // moveCamera(cameraUpdate); + } else { + result.success(false); + } + } + + private void animateCamera( + CameraUpdate cameraUpdate, Integer duration, MethodChannel.Result result) { + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + if (cameraUpdate != null && duration != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, duration, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + } + + /** + * Destroy the MapView and cleans up listeners. + * It's very important to call mapViewContainer.removeView(mapView) to make sure + * that {@link TextureView#onDetachedFromWindowInternal()} is called which releases the + * underlying surface. + * This is required due to an FlutterEngine change that was introduce when updating from + * Flutter 2.10.5 to Flutter 3.10.0. + * This FlutterEngine change is not calling `removeView` on a PlatformView which causes the issue. + *

+ * For more information check out: + * Flutter issue + * Flutter Engine commit that introduced the issue + * The reported issue in the MapLibre repo + */ + private void destroyMapViewIfNecessary() { + if (mapView == null) { + return; + } + + if (locationComponent != null) { + locationComponent.setLocationComponentEnabled(false); + } + stopListeningForLocationUpdates(); + + mapViewContainer.removeView(mapView); + + mapView.onStop(); + mapView.onDestroy(); + + mapView = null; + } + + @Override + public void onCreate(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onCreate(null); + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onStart(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onResume(); + if (myLocationEnabled) { + startListeningForLocationUpdates(); + } + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onPause(); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onStop(); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + owner.getLifecycle().removeObserver(this); + if (disposed) { + return; + } + destroyMapViewIfNecessary(); + } + + // MapLibreMapOptionsSink methods + + @Override + public void setCameraTargetBounds(LatLngBounds bounds) { + this.bounds = bounds; + if (mapLibreMap != null) { + mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + } + } + + @Override + public void setLocationEngineProperties(@NotNull LocationEngineRequest locationEngineRequest) { + myLocationEngineFactory.initLocationComponent(context, locationComponent, locationEngineRequest); + } + + @Override + public void setCompassEnabled(boolean compassEnabled) { + mapLibreMap.getUiSettings().setCompassEnabled(compassEnabled); + } + + @Override + public void setTrackCameraPosition(boolean trackCameraPosition) { + this.trackCameraPosition = trackCameraPosition; + } + + @Override + public void setRotateGesturesEnabled(boolean rotateGesturesEnabled) { + mapLibreMap.getUiSettings().setRotateGesturesEnabled(rotateGesturesEnabled); + } + + @Override + public void setScrollGesturesEnabled(boolean scrollGesturesEnabled) { + mapLibreMap.getUiSettings().setScrollGesturesEnabled(scrollGesturesEnabled); + } + + @Override + public void setTiltGesturesEnabled(boolean tiltGesturesEnabled) { + mapLibreMap.getUiSettings().setTiltGesturesEnabled(tiltGesturesEnabled); + } + + @Override + public void setMinMaxZoomPreference(Float min, Float max) { + mapLibreMap.setMinZoomPreference(min != null ? min : MapLibreConstants.MINIMUM_ZOOM); + mapLibreMap.setMaxZoomPreference(max != null ? max : MapLibreConstants.MAXIMUM_ZOOM); + } + + @Override + public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { + mapLibreMap.getUiSettings().setZoomGesturesEnabled(zoomGesturesEnabled); + } + + @Override + public void setMyLocationEnabled(boolean myLocationEnabled) { + if (this.myLocationEnabled == myLocationEnabled) { + return; + } + this.myLocationEnabled = myLocationEnabled; + if (mapLibreMap != null) { + updateMyLocationEnabled(); + } + } + + @Override + public void setMyLocationTrackingMode(int myLocationTrackingMode) { + if (mapLibreMap != null) { + // ensure that location is trackable + updateMyLocationEnabled(); + } + if (this.myLocationTrackingMode == myLocationTrackingMode) { + return; + } + this.myLocationTrackingMode = myLocationTrackingMode; + if (mapLibreMap != null && locationComponent != null) { + updateMyLocationTrackingMode(); + } + } + + @Override + public void setMyLocationRenderMode(int myLocationRenderMode) { + if (this.myLocationRenderMode == myLocationRenderMode) { + return; + } + this.myLocationRenderMode = myLocationRenderMode; + if (mapLibreMap != null && locationComponent != null) { + updateMyLocationRenderMode(); + } + } + + @Override + public void setLogoEnabled(boolean logoEnabled) { + mapLibreMap.getUiSettings().setLogoEnabled(logoEnabled); + } + + @Override + public void setLogoViewGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.TOP | Gravity.START); + break; + case 1: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.TOP | Gravity.END); + break; + default: + case 2: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + public void setLogoViewMargins(int x, int y) { + mapLibreMap.getUiSettings().setLogoMargins(x, 0, 0, y); + } + + @Override + public void setCompassGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.TOP | Gravity.START); + break; + default: + case 1: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.TOP | Gravity.END); + break; + case 2: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setCompassViewMargins(int x, int y) { + switch (mapLibreMap.getUiSettings().getCompassGravity()) { + case Gravity.TOP | Gravity.START: + mapLibreMap.getUiSettings().setCompassMargins(x, y, 0, 0); + break; + default: + case Gravity.TOP | Gravity.END: + mapLibreMap.getUiSettings().setCompassMargins(0, y, x, 0); + break; + case Gravity.BOTTOM | Gravity.START: + mapLibreMap.getUiSettings().setCompassMargins(x, 0, 0, y); + break; + case Gravity.BOTTOM | Gravity.END: + mapLibreMap.getUiSettings().setCompassMargins(0, 0, x, y); + break; + } + } + + @Override + public void setAttributionButtonGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.TOP | Gravity.START); + break; + default: + case 1: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.TOP | Gravity.END); + break; + case 2: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setAttributionButtonMargins(int x, int y) { + switch (mapLibreMap.getUiSettings().getAttributionGravity()) { + case Gravity.TOP | Gravity.START: + mapLibreMap.getUiSettings().setAttributionMargins(x, y, 0, 0); + break; + default: + case Gravity.TOP | Gravity.END: + mapLibreMap.getUiSettings().setAttributionMargins(0, y, x, 0); + break; + case Gravity.BOTTOM | Gravity.START: + mapLibreMap.getUiSettings().setAttributionMargins(x, 0, 0, y); + break; + case Gravity.BOTTOM | Gravity.END: + mapLibreMap.getUiSettings().setAttributionMargins(0, 0, x, y); + break; + } + } + + @Override + public void setForegroundLoadColor(int color) { + // foregroundLoadColor is only useful during initial map creation + // not for runtime updates, so this is a no-op + } + + @Override + public void setTranslucentTextureSurface(boolean translucentTextureSurface) { + // translucentTextureSurface is only useful during initial map creation + // not for runtime updates, so this is a no-op + } + + private void updateMyLocationEnabled() { + if (this.locationComponent == null && mapLibreMap.getStyle() != null && myLocationEnabled) { + enableLocationComponent(mapLibreMap.getStyle()); + } + + if (myLocationEnabled) { + startListeningForLocationUpdates(); + } else { + stopListeningForLocationUpdates(); + } + + if (locationComponent != null) { + locationComponent.setLocationComponentEnabled(myLocationEnabled); + } + } + + private void startListeningForLocationUpdates() { + if (locationEngineCallback == null + && locationComponent != null + && locationComponent.isLocationComponentActivated() + && locationComponent.getLocationEngine() != null) { + locationEngineCallback = + new LocationEngineCallback() { + @Override + public void onSuccess(LocationEngineResult result) { + onUserLocationUpdate(result.getLastLocation()); + } + + @Override + public void onFailure(@NonNull Exception exception) {} + }; + locationComponent + .getLocationEngine() + .requestLocationUpdates( + locationComponent.getLocationEngineRequest(), locationEngineCallback, null); + } + } + + private void stopListeningForLocationUpdates() { + if (locationEngineCallback != null + && locationComponent != null + && locationComponent.isLocationComponentActivated() + && locationComponent.getLocationEngine() != null) { + locationComponent.getLocationEngine().removeLocationUpdates(locationEngineCallback); + locationEngineCallback = null; + } + } + + private void updateMyLocationTrackingMode() { + int[] mapboxTrackingModes = + new int[] { + CameraMode.NONE, CameraMode.TRACKING, CameraMode.TRACKING_COMPASS, CameraMode.TRACKING_GPS + }; + locationComponent.setCameraMode(mapboxTrackingModes[this.myLocationTrackingMode]); + } + + private void updateMyLocationRenderMode() { + int[] mapboxRenderModes = new int[] {RenderMode.NORMAL, RenderMode.COMPASS, RenderMode.GPS}; + locationComponent.setRenderMode(mapboxRenderModes[this.myLocationRenderMode]); + } + + private boolean hasLocationPermission() { + return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + private int checkSelfPermission(String permission) { + if (permission == null) { + throw new IllegalArgumentException("permission is null"); + } + return context.checkPermission( + permission, android.os.Process.myPid(), android.os.Process.myUid()); + } + + /** + * Tries to find highest scale image for display type + * + * @param imageId + * @param density + * @return + */ + private Bitmap getScaledImage(String imageId, float density) { + AssetFileDescriptor assetFileDescriptor; + + // Split image path into parts. + List imagePathList = Arrays.asList(imageId.split("/")); + List assetPathList = new ArrayList<>(); + + // "On devices with a device pixel ratio of 1.8, the asset .../2.0x/my_icon.png would be chosen. + // For a device pixel ratio of 2.7, the asset .../3.0x/my_icon.png would be chosen." + // Source: https://flutter.dev/docs/development/ui/assets-and-images#resolution-aware + for (int i = (int) Math.ceil(density); i > 0; i--) { + String assetPath; + if (i == 1) { + // If density is 1.0x then simply take the default asset path + assetPath = MapLibreMapsPlugin.flutterAssets.getAssetFilePathByName(imageId); + } else { + // Build a resolution aware asset path as follows: + // // + // where ratio is 1.0x, 2.0x or 3.0x. + StringBuilder stringBuilder = new StringBuilder(); + for (int j = 0; j < imagePathList.size() - 1; j++) { + stringBuilder.append(imagePathList.get(j)); + stringBuilder.append("/"); + } + stringBuilder.append(((float) i) + "x"); + stringBuilder.append("/"); + stringBuilder.append(imagePathList.get(imagePathList.size() - 1)); + assetPath = MapLibreMapsPlugin.flutterAssets.getAssetFilePathByName(stringBuilder.toString()); + } + // Build up a list of resolution aware asset paths. + assetPathList.add(assetPath); + } + + // Iterate over asset paths and get the highest scaled asset (as a bitmap). + Bitmap bitmap = null; + for (String assetPath : assetPathList) { + try { + // Read path (throws exception if doesn't exist). + assetFileDescriptor = mapView.getContext().getAssets().openFd(assetPath); + InputStream assetStream = assetFileDescriptor.createInputStream(); + bitmap = BitmapFactory.decodeStream(assetStream); + assetFileDescriptor.close(); // Close for memory + break; // If exists, break + } catch (IOException e) { + // Skip + } + } + return bitmap; + } + + boolean onMoveBegin(MoveGestureDetector detector) { + // onMoveBegin gets called even during a move - move end is also not called unless this function + // returns + // true at least once. To avoid redundant queries only check for feature if the previous event + // was ACTION_DOWN + if (detector.getPreviousEvent().getActionMasked() == MotionEvent.ACTION_DOWN + && detector.getPointersCount() == 1) { + PointF pointf = detector.getFocalPoint(); + LatLng origin = mapLibreMap.getProjection().fromScreenLocation(pointf); + RectF rectF = new RectF(pointf.x - 10, pointf.y - 10, pointf.x + 10, pointf.y + 10); + Pair featureLayerPair = firstFeatureOnLayers(rectF); + if (featureLayerPair != null && featureLayerPair.first != null && startDragging(featureLayerPair.first, origin)) { + invokeFeatureDrag(pointf, "start"); + return true; + } + } + return false; + } + + private void invokeFeatureDrag(PointF pointf, String eventType) { + LatLng current = mapLibreMap.getProjection().fromScreenLocation(pointf); + + final Map arguments = new HashMap<>(9); + arguments.put("id", draggedFeature.id()); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("originLng", dragOrigin.getLongitude()); + arguments.put("originLat", dragOrigin.getLatitude()); + arguments.put("currentLng", current.getLongitude()); + arguments.put("currentLat", current.getLatitude()); + arguments.put("eventType", eventType); + arguments.put("deltaLng", current.getLongitude() - dragPrevious.getLongitude()); + arguments.put("deltaLat", current.getLatitude() - dragPrevious.getLatitude()); + dragPrevious = current; + methodChannel.invokeMethod("feature#onDrag", arguments); + } + + boolean onMove(MoveGestureDetector detector) { + if (draggedFeature != null) { + if (detector.getPointersCount() > 1) { + stopDragging(); + return true; + } + PointF pointf = detector.getFocalPoint(); + invokeFeatureDrag(pointf, "drag"); + return false; + } + return true; + } + + void onMoveEnd(MoveGestureDetector detector) { + PointF pointf = detector.getFocalPoint(); + invokeFeatureDrag(pointf, "end"); + stopDragging(); + } + + boolean startDragging(@NonNull Feature feature, @NonNull LatLng origin) { + final boolean draggable = + feature.hasNonNullValueForProperty("draggable") + ? feature.getBooleanProperty("draggable") + : false; + if (draggable) { + draggedFeature = feature; + dragPrevious = origin; + dragOrigin = origin; + return true; + } + return false; + } + + void stopDragging() { + draggedFeature = null; + dragOrigin = null; + dragPrevious = null; + } + + /** Simple Listener to listen for the status of camera movements. */ + public class OnCameraMoveFinishedListener implements MapLibreMap.CancelableCallback { + @Override + public void onFinish() {} + + @Override + public void onCancel() {} + } + + private class MoveGestureListener implements MoveGestureDetector.OnMoveGestureListener { + + @Override + public boolean onMoveBegin(MoveGestureDetector detector) { + return MapLibreMapController.this.onMoveBegin(detector); + } + + @Override + public boolean onMove(MoveGestureDetector detector, float distanceX, float distanceY) { + return MapLibreMapController.this.onMove(detector); + } + + @Override + public void onMoveEnd(MoveGestureDetector detector, float velocityX, float velocityY) { + MapLibreMapController.this.onMoveEnd(detector); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java new file mode 100644 index 00000000..7fdda1d6 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java @@ -0,0 +1,48 @@ +package org.maplibre.maplibregl; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.maplibre.android.camera.CameraPosition; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; +import java.util.Map; + +public class MapLibreMapFactory extends PlatformViewFactory { + + private final BinaryMessenger messenger; + private final MapLibreMapsPlugin.LifecycleProvider lifecycleProvider; + + public MapLibreMapFactory( + BinaryMessenger messenger, MapLibreMapsPlugin.LifecycleProvider lifecycleProvider) { + super(StandardMessageCodec.INSTANCE); + this.messenger = messenger; + this.lifecycleProvider = lifecycleProvider; + } + + @NonNull + @Override + public PlatformView create(Context context, int id, Object args) { + Map params = (Map) args; + final MapLibreMapBuilder builder = new MapLibreMapBuilder(); + + Convert.interpretMapLibreMapOptions(params.get("options"), builder, context); + if (params.containsKey("initialCameraPosition")) { + CameraPosition position = Convert.toCameraPosition(params.get("initialCameraPosition")); + builder.setInitialCameraPosition(position); + } + if (params.containsKey("dragEnabled")) { + boolean dragEnabled = Convert.toBoolean(params.get("dragEnabled")); + builder.setDragEnabled(dragEnabled); + } + if(params.containsKey("styleString")) { + String styleString = Convert.toString(params.get("styleString")); + builder.setStyleString(styleString); + } + + return builder.build(id, context, messenger, lifecycleProvider); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt new file mode 100644 index 00000000..bc1575c8 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt @@ -0,0 +1,56 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package org.maplibre.maplibregl + +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.location.engine.LocationEngineRequest + +/** Receiver of MapLibreMap configuration options. */ +internal interface MapLibreMapOptionsSink { + // todo: dddd replace with CameraPosition.Builder target + fun setCameraTargetBounds(bounds: LatLngBounds) + + fun setCompassEnabled(compassEnabled: Boolean) + + // TODO: styleString is not actually a part of options. consider moving + fun setStyleString(styleString: String) + + fun setMinMaxZoomPreference(min: Float?, max: Float?) + + fun setRotateGesturesEnabled(rotateGesturesEnabled: Boolean) + + fun setScrollGesturesEnabled(scrollGesturesEnabled: Boolean) + + fun setTiltGesturesEnabled(tiltGesturesEnabled: Boolean) + + fun setTrackCameraPosition(trackCameraPosition: Boolean) + + fun setZoomGesturesEnabled(zoomGesturesEnabled: Boolean) + + fun setMyLocationEnabled(myLocationEnabled: Boolean) + + fun setMyLocationTrackingMode(myLocationTrackingMode: Int) + + fun setMyLocationRenderMode(myLocationRenderMode: Int) + + fun setLogoEnabled(logoEnabled: Boolean) + + fun setLogoViewGravity(gravity: Int) + + fun setLogoViewMargins(x: Int, y: Int) + + fun setCompassGravity(gravity: Int) + + fun setCompassViewMargins(x: Int, y: Int) + + fun setAttributionButtonGravity(gravity: Int) + + fun setAttributionButtonMargins(x: Int, y: Int) + + fun setLocationEngineProperties(locationEngineRequest: LocationEngineRequest) + + fun setForegroundLoadColor(loadColor: Int) + + fun setTranslucentTextureSurface(translucentTextureSurface: Boolean) +} \ No newline at end of file diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java new file mode 100644 index 00000000..edf891ca --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java @@ -0,0 +1,104 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.plugins.lifecycle.HiddenLifecycleReference; +import io.flutter.plugin.common.MethodChannel; + +/** + * Plugin for controlling a set of MapLibreMap views to be shown as overlays on top of the Flutter + * view. The overlay should be hidden during transformations or while Flutter is rendering on top of + * the map. A Texture drawn using MapLibreMap bitmap snapshots can then be shown instead of the + * overlay. + */ +public class MapLibreMapsPlugin implements FlutterPlugin, ActivityAware { + + static FlutterAssets flutterAssets; + private Lifecycle lifecycle; + + public MapLibreMapsPlugin() { + // no-op + } + + // New Plugin APIs + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + flutterAssets = binding.getFlutterAssets(); + + MethodChannel methodChannel = + new MethodChannel(binding.getBinaryMessenger(), "plugins.flutter.io/maplibre_gl"); + methodChannel.setMethodCallHandler(new GlobalMethodHandler(binding)); + + binding + .getPlatformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/maplibre_gl", + new MapLibreMapFactory( + binding.getBinaryMessenger(), + new LifecycleProvider() { + @Nullable + @Override + public Lifecycle getLifecycle() { + return lifecycle; + } + })); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + // no-op + } + + @Override + public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { + lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { + onAttachedToActivity(binding); + } + + @Override + public void onDetachedFromActivity() { + lifecycle = null; + } + + + interface LifecycleProvider { + @Nullable + Lifecycle getLifecycle(); + } + + /** Provides a static method for extracting lifecycle objects from Flutter plugin bindings. */ + public static class FlutterLifecycleAdapter { + + /** + * Returns the lifecycle object for the activity a plugin is bound to. + * + *

Returns null if the Flutter engine version does not include the lifecycle extraction code. + * (this probably means the Flutter engine version is too old). + */ + @NonNull + public static Lifecycle getActivityLifecycle( + @NonNull ActivityPluginBinding activityPluginBinding) { + HiddenLifecycleReference reference = + (HiddenLifecycleReference) activityPluginBinding.getLifecycle(); + return reference.getLifecycle(); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java new file mode 100644 index 00000000..dadbd16c --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java @@ -0,0 +1,12 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import org.maplibre.android.MapLibre; + +abstract class MapLibreUtils { + private static final String TAG = "MapLibreMapController"; + + static MapLibre getMapLibre(Context context) { + return MapLibre.getInstance(context); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java new file mode 100644 index 00000000..c29fe17e --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java @@ -0,0 +1,55 @@ +package org.maplibre.maplibregl; + +import androidx.annotation.Nullable; +import com.google.gson.Gson; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import java.util.HashMap; +import java.util.Map; + +public class OfflineChannelHandlerImpl implements EventChannel.StreamHandler { + private EventChannel.EventSink sink; + private Gson gson = new Gson(); + + OfflineChannelHandlerImpl(BinaryMessenger messenger, String channelName) { + EventChannel eventChannel = new EventChannel(messenger, channelName); + eventChannel.setStreamHandler(this); + } + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + sink = events; + } + + @Override + public void onCancel(Object arguments) { + sink = null; + } + + void onError(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { + if (sink == null) return; + sink.error(errorCode, errorMessage, errorDetails); + } + + void onSuccess() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "success"); + sink.success(gson.toJson(body)); + } + + void onStart() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "start"); + sink.success(gson.toJson(body)); + } + + void onProgress(double progress) { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "progress"); + body.put("progress", progress); + sink.success(gson.toJson(body)); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java new file mode 100644 index 00000000..b0585a19 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java @@ -0,0 +1,337 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import android.util.Log; +import com.google.gson.Gson; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.offline.OfflineManager; +import org.maplibre.android.offline.OfflineRegion; +import org.maplibre.android.offline.OfflineRegionDefinition; +import org.maplibre.android.offline.OfflineRegionError; +import org.maplibre.android.offline.OfflineRegionStatus; +import org.maplibre.android.offline.OfflineTilePyramidRegionDefinition; +import io.flutter.plugin.common.MethodChannel; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +abstract class OfflineManagerUtils { + private static final String TAG = "OfflineManagerUtils"; + + static void mergeRegions(MethodChannel.Result result, Context context, String path) { + OfflineManager.Companion.getInstance(context) + .mergeOfflineRegions( + path, + new OfflineManager.MergeOfflineRegionsCallback() { + public void onMerge(OfflineRegion[] offlineRegions) { + if (result == null) return; + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + String json = new Gson().toJson(regionsArgs); + result.success(json); + } + + public void onError(String error) { + if (result == null) return; + result.error("mergeOfflineRegions Error", error, null); + } + }); + } + + static void setOfflineTileCountLimit(MethodChannel.Result result, Context context, long limit) { + OfflineManager.Companion.getInstance(context).setOfflineMapboxTileCountLimit(limit); + result.success(null); + } + + static void downloadRegion( + MethodChannel.Result result, + Context context, + Map definitionMap, + Map metadataMap, + OfflineChannelHandlerImpl channelHandler) { + float pixelDensity = context.getResources().getDisplayMetrics().density; + OfflineRegionDefinition definition = mapToRegionDefinition(definitionMap, pixelDensity); + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + AtomicBoolean isComplete = new AtomicBoolean(false); + // Download region + OfflineManager.Companion.getInstance(context) + .createOfflineRegion( + definition, + metadata.getBytes(), + new OfflineManager.CreateOfflineRegionCallback() { + private OfflineRegion _offlineRegion; + + @Override + public void onCreate(OfflineRegion offlineRegion) { + Map regionData = offlineRegionToMap(offlineRegion); + result.success(new Gson().toJson(regionData)); + + _offlineRegion = offlineRegion; + // Observe downloading state + OfflineRegion.OfflineRegionObserver observer = + new OfflineRegion.OfflineRegionObserver() { + @Override + public void onStatusChanged(OfflineRegionStatus status) { + // Calculate progress of + // downloading + double progress = + calculateDownloadingProgress( + status.getRequiredResourceCount(), + status.getCompletedResourceCount()); + // Check if downloading is + // complete + if (status.isComplete()) { + Log.i(TAG, "Region " + "downloaded " + "successfully."); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + // This can be called + // multiple times, and + // result can be called + // only once, + // so there is need to + // prevent it + if (isComplete.get()) return; + isComplete.set(true); + channelHandler.onSuccess(); + } else { + Log.i(TAG, "Region " + "download " + "progress = " + progress); + channelHandler.onProgress(progress); + } + } + + @Override + public void onError(OfflineRegionError error) { + Log.e(TAG, "onError reason: " + error.getReason()); + Log.e(TAG, "onError message: " + error.getMessage()); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "Downloading error", error.getMessage(), error.getReason()); + } + + @Override + public void mapboxTileCountLimitExceeded(long limit) { + Log.e(TAG, "MapLibre tile count" + " limit exceeded: " + limit); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "mapboxTileCountLimitExceeded", + "MapLibre tile count " + "limit " + "exceeded: " + limit, + null); + // MapLibre even after crash + // and not downloading fully + // region still keeps part + // of it in database, so we + // have to remove it + deleteRegion(null, context, _offlineRegion.getId()); + } + }; + + _offlineRegion.setObserver(observer); + + // Start downloading region + _offlineRegion.setDownloadState(OfflineRegion.STATE_ACTIVE); + channelHandler.onStart(); + } + + /** + * This will be call if given region definition is invalid + * + * @param error + */ + @Override + public void onError(String error) { + Log.e(TAG, "Error: " + error); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + channelHandler.onError("mapboxInvalidRegionDefinition", error, null); + result.error("mapboxInvalidRegionDefinition", error, null); + } + }); + } + + static void regionsList(MethodChannel.Result result, Context context) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + result.success(new Gson().toJson(regionsArgs)); + } + + @Override + public void onError(String error) { + result.error("RegionListError", error, null); + } + }); + } + + static void updateRegionMetadata( + MethodChannel.Result result, Context context, long id, Map metadataMap) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getId() != id) continue; + + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + offlineRegion.updateMetadata( + metadata.getBytes(), + new OfflineRegion.OfflineRegionUpdateMetadataCallback() { + @Override + public void onUpdate(byte[] metadataBytes) { + Map regionData = offlineRegionToMap(offlineRegion); + regionData.put("metadata", metadataBytesToMap(metadataBytes)); + + if (result == null) return; + result.success(new Gson().toJson(regionData)); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("UpdateMetadataError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "UpdateMetadataError", + "There is no " + "region with given id to " + "update.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + static void deleteRegion(MethodChannel.Result result, Context context, long id) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getId() != id) continue; + + offlineRegion.delete( + new OfflineRegion.OfflineRegionDeleteCallback() { + @Override + public void onDelete() { + if (result == null) return; + result.success(null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("DeleteRegionError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "DeleteRegionError", + "There is no " + "region with given id to " + "delete.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + private static double calculateDownloadingProgress( + long requiredResourceCount, long completedResourceCount) { + return requiredResourceCount > 0 + ? (100.0 * completedResourceCount / requiredResourceCount) + : 0.0; + } + + private static OfflineRegionDefinition mapToRegionDefinition( + Map map, float pixelDensity) { + for (Map.Entry entry : map.entrySet()) { + Log.d(TAG, entry.getKey()); + Log.d(TAG, entry.getValue().toString()); + } + // Create a bounding box for the offline region + return new OfflineTilePyramidRegionDefinition( + (String) map.get("mapStyleUrl"), + listToBounds((List>) map.get("bounds")), + ((Number) map.get("minZoom")).doubleValue(), + ((Number) map.get("maxZoom")).doubleValue(), + pixelDensity, + (Boolean) map.get("includeIdeographs")); + } + + private static LatLngBounds listToBounds(List> bounds) { + return new LatLngBounds.Builder() + .include(new LatLng(bounds.get(1).get(0), bounds.get(1).get(1))) // Northeast + .include(new LatLng(bounds.get(0).get(0), bounds.get(0).get(1))) // Southwest + .build(); + } + + private static Map offlineRegionToMap(OfflineRegion region) { + Map result = new HashMap(); + result.put("id", region.getId()); + result.put("definition", offlineRegionDefinitionToMap(region.getDefinition())); + result.put("metadata", metadataBytesToMap(region.getMetadata())); + return result; + } + + private static Map offlineRegionDefinitionToMap( + OfflineRegionDefinition definition) { + Map result = new HashMap(); + result.put("mapStyleUrl", definition.getStyleURL()); + result.put("bounds", boundsToList(definition.getBounds())); + result.put("minZoom", definition.getMinZoom()); + result.put("maxZoom", definition.getMaxZoom()); + result.put("includeIdeographs", definition.getIncludeIdeographs()); + return result; + } + + private static List> boundsToList(LatLngBounds bounds) { + List> boundsList = new ArrayList<>(); + List northeast = Arrays.asList(bounds.getLatNorth(), bounds.getLonEast()); + List southwest = Arrays.asList(bounds.getLatSouth(), bounds.getLonWest()); + boundsList.add(southwest); + boundsList.add(northeast); + return boundsList; + } + + private static Map metadataBytesToMap(byte[] metadataBytes) { + if (metadataBytes != null) { + return new Gson().fromJson(new String(metadataBytes), HashMap.class); + } + return new HashMap(); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java new file mode 100644 index 00000000..de750dd0 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java @@ -0,0 +1,232 @@ +package org.maplibre.maplibregl; + +import android.net.Uri; +import com.google.gson.Gson; +import org.maplibre.geojson.FeatureCollection; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngQuad; +import org.maplibre.android.maps.Style; +import org.maplibre.android.style.sources.GeoJsonOptions; +import org.maplibre.android.style.sources.GeoJsonSource; +import org.maplibre.android.style.sources.ImageSource; +import org.maplibre.android.style.sources.RasterDemSource; +import org.maplibre.android.style.sources.RasterSource; +import org.maplibre.android.style.sources.Source; +import org.maplibre.android.style.sources.TileSet; +import org.maplibre.android.style.sources.VectorSource; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class SourcePropertyConverter { + private static final String TAG = "SourcePropertyConverter"; + + static TileSet buildTileset(Map data) { + final Object tiles = data.get("tiles"); + + // options are only valid with tiles + if (tiles == null) { + return null; + } + + final TileSet tileSet = + new TileSet("2.1.0", (String[]) Convert.toList(tiles).toArray(new String[0])); + + final Object bounds = data.get("bounds"); + if (bounds != null) { + List boundsFloat = new ArrayList(); + for (Object item : Convert.toList(bounds)) { + boundsFloat.add(Convert.toFloat(item)); + } + tileSet.setBounds(boundsFloat.toArray(new Float[0])); + } + + final Object scheme = data.get("scheme"); + if (scheme != null) { + tileSet.setScheme(Convert.toString(scheme)); + } + + final Object minzoom = data.get("minzoom"); + if (minzoom != null) { + tileSet.setMinZoom(Convert.toFloat(minzoom)); + } + + final Object maxzoom = data.get("maxzoom"); + if (maxzoom != null) { + tileSet.setMaxZoom(Convert.toFloat(maxzoom)); + } + + final Object attribution = data.get("attribution"); + if (attribution != null) { + tileSet.setAttribution(Convert.toString(attribution)); + } + return tileSet; + } + + static GeoJsonOptions buildGeojsonOptions(Map data) { + GeoJsonOptions options = new GeoJsonOptions(); + + final Object buffer = data.get("buffer"); + if (buffer != null) { + options = options.withBuffer(Convert.toInt(buffer)); + } + + final Object cluster = data.get("cluster"); + if (cluster != null) { + options = options.withCluster(Convert.toBoolean(cluster)); + } + + final Object clusterMaxZoom = data.get("clusterMaxZoom"); + if (clusterMaxZoom != null) { + options = options.withClusterMaxZoom(Convert.toInt(clusterMaxZoom)); + } + + final Object clusterRadius = data.get("clusterRadius"); + if (clusterRadius != null) { + options = options.withClusterRadius(Convert.toInt(clusterRadius)); + } + + final Object lineMetrics = data.get("lineMetrics"); + if (lineMetrics != null) { + options = options.withLineMetrics(Convert.toBoolean(lineMetrics)); + } + + final Object maxZoom = data.get("maxZoom"); + if (maxZoom != null) { + options = options.withMaxZoom(Convert.toInt(maxZoom)); + } + + final Object minZoom = data.get("minZoom"); + if (minZoom != null) { + options = options.withMinZoom(Convert.toInt(minZoom)); + } + + final Object tolerance = data.get("tolerance"); + if (tolerance != null) { + options = options.withTolerance(Convert.toFloat(tolerance)); + } + return options; + } + + static GeoJsonSource buildGeojsonSource(String id, Map properties) { + final Object data = properties.get("data"); + final GeoJsonOptions options = buildGeojsonOptions(properties); + if (data != null) { + if (data instanceof String) { + try { + final URI uri = new URI(Convert.toString(data)); + return new GeoJsonSource(id, uri, options); + } catch (URISyntaxException e) { + } + } else { + Gson gson = new Gson(); + String geojson = gson.toJson(data); + final FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + return new GeoJsonSource(id, featureCollection, options); + } + } + return null; + } + + static ImageSource buildImageSource(String id, Map properties) { + final Object url = properties.get("url"); + List coordinates = Convert.toLatLngList(properties.get("coordinates"), true); + final LatLngQuad quad = + new LatLngQuad( + coordinates.get(0), coordinates.get(1), coordinates.get(2), coordinates.get(3)); + try { + final URI uri = new URI(Convert.toString(url)); + return new ImageSource(id, quad, uri); + } catch (URISyntaxException e) { + } + return null; + } + + static VectorSource buildVectorSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + final Uri uri = Uri.parse(Convert.toString(url)); + + if (uri != null) { + return new VectorSource(id, uri); + } + return null; + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new VectorSource(id, tileSet) : null; + } + + static RasterSource buildRasterSource(String id, Map properties) { + final Object url = properties.get("url"); + final Object tileSizeObj = properties.get("tileSize"); + if (url != null) { + final String uri = Convert.toString(url); + if (tileSizeObj != null) { + final int tileSize = Convert.toInt(tileSizeObj); + return new RasterSource(id, uri, tileSize); + } else { + return new RasterSource(id, uri); + } + } + + final TileSet tileSet = buildTileset(properties); + if (tileSet != null) { + if (tileSizeObj != null) { + final int tileSize = Convert.toInt(tileSizeObj); + return new RasterSource(id, tileSet, tileSize); + } else { + return new RasterSource(id, tileSet); + } + } else { + return null; + } + } + + static RasterDemSource buildRasterDemSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + try { + final URI uri = new URI(Convert.toString(url)); + return new RasterDemSource(id, uri); + } catch (URISyntaxException e) { + } + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new RasterDemSource(id, tileSet) : null; + } + + static void addSource(String id, Map properties, Style style) { + final Object type = properties.get("type"); + Source source = null; + + if (type != null) { + switch (Convert.toString(type)) { + case "vector": + source = buildVectorSource(id, properties); + break; + case "raster": + source = buildRasterSource(id, properties); + break; + case "raster-dem": + source = buildRasterDemSource(id, properties); + break; + case "image": + source = buildImageSource(id, properties); + break; + case "geojson": + source = buildGeojsonSource(id, properties); + break; + default: + // unsupported source type + } + } + + if (source != null) { + style.addSource(source); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt new file mode 100644 index 00000000..bfcd6603 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt @@ -0,0 +1,31 @@ +@file:JvmName("MapLibreMapUtils") + +package org.maplibre.maplibregl + +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer + +fun MapLibreMap.setMapLanguage(language: String) { + val layers = this.style?.layers ?: emptyList() + + val languageRegex = Regex("(name:[a-z]+)") + + val symbolLayers = layers.filterIsInstance() + + for (layer in symbolLayers) { + // continue when there is no current expression + val expression = layer.textField.expression ?: continue + + // We could skip the current iteration, whenever there is not current language. + if (!expression.toString().contains(languageRegex)) { + continue + } + + val properties = + "[\"coalesce\", [\"get\",\"name:$language\"],[\"get\",\"name:latin\"],[\"get\",\"name\"]]" + + layer.setProperties(PropertyFactory.textField(Expression.raw(properties))) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl.podspec b/third_party/maplibre_gl/ios/maplibre_gl.podspec new file mode 100644 index 00000000..a322e790 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'maplibre_gl' + s.version = '0.25.0' + s.summary = 'MapLibre GL Flutter plugin' + s.description = <<-DESC +MapLibre GL Flutter plugin. + DESC + s.homepage = 'https://maplibre.org' + s.license = { :file => '../LICENSE' } + s.author = { 'MapLibre' => 'info@maplibre.org' } + s.source = { :path => '.' } + s.source_files = 'maplibre_gl/Sources/maplibre_gl/**/*' + s.dependency 'Flutter' + # When updating the dependency version, + # make sure to also update the version in Package.swift. + s.dependency 'MapLibre', '6.19.1' + s.swift_version = '5.0' + s.ios.deployment_target = '13.0' +end + diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Package.swift b/third_party/maplibre_gl/ios/maplibre_gl/Package.swift new file mode 100644 index 00000000..810dbe82 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "maplibre_gl", + platforms: [ + .iOS("13.0"), + ], + products: [ + .library(name: "maplibre-gl", targets: ["maplibre_gl"]) + ], + dependencies: [ + // When updating the dependency version, + // make sure to also update the version in maplibre_gl.podspec. + .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", exact: "6.19.1"), + ], + targets: [ + .target( + name: "maplibre_gl", + dependencies: [ + .product(name: "MapLibre", package: "maplibre-gl-native-distribution") + ], + cSettings: [ + .headerSearchPath("include/maplibre_gl") + ] + ) + ] +) diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift new file mode 100644 index 00000000..1a548ce0 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift @@ -0,0 +1,51 @@ +import MapLibre + +/* + * The mapping is based on the values defined here: + * https://docs.mapbox.com/android/api/map-sdk/8.4.0/constant-values.html + */ + +class Constants { + static let symbolIconAnchorMapping = [ + "center": MLNIconAnchor.center, + "left": MLNIconAnchor.left, + "right": MLNIconAnchor.right, + "top": MLNIconAnchor.top, + "bottom": MLNIconAnchor.bottom, + "top-left": MLNIconAnchor.topLeft, + "top-right": MLNIconAnchor.topRight, + "bottom-left": MLNIconAnchor.bottomLeft, + "bottom-right": MLNIconAnchor.bottomRight, + ] + + static let symbolTextJustificationMapping = [ + "auto": MLNTextJustification.auto, + "center": MLNTextJustification.center, + "left": MLNTextJustification.left, + "right": MLNTextJustification.right, + ] + + static let symbolTextAnchorMapping = [ + "center": MLNTextAnchor.center, + "left": MLNTextAnchor.left, + "right": MLNTextAnchor.right, + "top": MLNTextAnchor.top, + "bottom": MLNTextAnchor.bottom, + "top-left": MLNTextAnchor.topLeft, + "top-right": MLNTextAnchor.topRight, + "bottom-left": MLNTextAnchor.bottomLeft, + "bottom-right": MLNTextAnchor.bottomRight, + ] + + static let symbolTextTransformationMapping = [ + "none": MLNTextTransform.none, + "lowercase": MLNTextTransform.lowercase, + "uppercase": MLNTextTransform.uppercase, + ] + + static let lineJoinMapping = [ + "bevel": MLNLineJoin.bevel, + "miter": MLNLineJoin.miter, + "round": MLNLineJoin.round, + ] +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift new file mode 100644 index 00000000..b7fb0c04 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift @@ -0,0 +1,229 @@ +import MapLibre + +class Convert { + class func interpretMapLibreMapOptions(options: Any?, delegate: MapLibreMapOptionsSink) { + guard let options = options as? [String: Any] else { return } + if let cameraTargetBounds = options["cameraTargetBounds"] as? [Any?] { + // Handle both [[[Double]]] and [nil] (for unbounded) + if let boundsArray = cameraTargetBounds[0] as? [[Double]] { + let bounds = MLNCoordinateBounds.fromArray(boundsArray) + delegate.setCameraTargetBounds(bounds: bounds) + } else { + // Unbounded - clear the bounds restriction + delegate.setCameraTargetBounds(bounds: nil) + } + } + if let compassEnabled = options["compassEnabled"] as? Bool { + delegate.setCompassEnabled(compassEnabled: compassEnabled) + } + if let minMaxZoomPreference = options["minMaxZoomPreference"] as? [Any] { + // Handle both [Double] and [NSNull] (for unbounded zoom) + let minZoom: Double? = (minMaxZoomPreference[0] is NSNull) ? nil : minMaxZoomPreference[0] as? Double + let maxZoom: Double? = (minMaxZoomPreference[1] is NSNull) ? nil : minMaxZoomPreference[1] as? Double + + delegate.setMinMaxZoomPreference( + min: minZoom, + max: maxZoom + ) + } + if let styleString = options["styleString"] as? String { + delegate.setStyleString(styleString: styleString) + } + if let rotateGesturesEnabled = options["rotateGesturesEnabled"] as? Bool { + delegate.setRotateGesturesEnabled(rotateGesturesEnabled: rotateGesturesEnabled) + } + if let scrollGesturesEnabled = options["scrollGesturesEnabled"] as? Bool { + delegate.setScrollGesturesEnabled(scrollGesturesEnabled: scrollGesturesEnabled) + } + if let tiltGesturesEnabled = options["tiltGesturesEnabled"] as? Bool { + delegate.setTiltGesturesEnabled(tiltGesturesEnabled: tiltGesturesEnabled) + } + if let trackCameraPosition = options["trackCameraPosition"] as? Bool { + delegate.setTrackCameraPosition(trackCameraPosition: trackCameraPosition) + } + if let zoomGesturesEnabled = options["zoomGesturesEnabled"] as? Bool { + delegate.setZoomGesturesEnabled(zoomGesturesEnabled: zoomGesturesEnabled) + } + if let myLocationEnabled = options["myLocationEnabled"] as? Bool { + delegate.setMyLocationEnabled(myLocationEnabled: myLocationEnabled) + } + if let myLocationTrackingMode = options["myLocationTrackingMode"] as? UInt, + let trackingMode = MLNUserTrackingMode(rawValue: myLocationTrackingMode) + { + delegate.setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + if let myLocationRenderMode = options["myLocationRenderMode"] as? Int, + let renderMode = MyLocationRenderMode(rawValue: myLocationRenderMode) + { + delegate.setMyLocationRenderMode(myLocationRenderMode: renderMode) + } + if let logoEnabled = options["logoEnabled"] as? Bool { + delegate.setLogoEnabled(logoEnabled: logoEnabled) + } + if let logoViewPosition = options["logoViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: logoViewPosition) + { + delegate.setLogoViewPosition(position: position) + } + if let logoViewMargins = options["logoViewMargins"] as? [Double] { + delegate.setLogoViewMargins(x: logoViewMargins[0], y: logoViewMargins[1]) + } + if let compassViewPosition = options["compassViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: compassViewPosition) + { + delegate.setCompassViewPosition(position: position) + } + if let compassViewMargins = options["compassViewMargins"] as? [Double] { + delegate.setCompassViewMargins(x: compassViewMargins[0], y: compassViewMargins[1]) + } + if let attributionButtonMargins = options["attributionButtonMargins"] as? [Double] { + delegate.setAttributionButtonMargins( + x: attributionButtonMargins[0], + y: attributionButtonMargins[1] + ) + } + if let attributionButtonPosition = options["attributionButtonPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: attributionButtonPosition) + { + delegate.setAttributionButtonPosition(position: position) + } + } + + class func parseLatLngBoundsPadding(_ cameraUpdate: [Any]) -> UIEdgeInsets? { + guard let methodName = cameraUpdate[0] as? String else { return nil } + + if(methodName != "newLatLngBounds") { + return nil + } + + guard let paddingLeft = cameraUpdate[2] as? CGFloat else { return nil } + guard let paddingTop = cameraUpdate[3] as? CGFloat else { return nil } + guard let paddingRight = cameraUpdate[4] as? CGFloat else { return nil } + guard let paddingBottom = cameraUpdate[5] as? CGFloat else { return nil } + + return UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight) + } + + class func parseCameraUpdate(cameraUpdate: [Any], mapView: MLNMapView) -> MLNMapCamera? { + guard let type = cameraUpdate[0] as? String else { return nil } + switch type { + case "newCameraPosition": + guard let cameraPosition = cameraUpdate[1] as? [String: Any] else { return nil } + return MLNMapCamera.fromDict(cameraPosition, mapView: mapView) + case "newLatLng": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + return camera + case "newLatLngBounds": + guard let bounds = cameraUpdate[1] as? [[Double]] else { return nil } + + if let padding = parseLatLngBoundsPadding(cameraUpdate) { + return mapView.cameraThatFitsCoordinateBounds( + MLNCoordinateBounds.fromArray(bounds), + edgePadding: padding + ) + } + + return mapView.cameraThatFitsCoordinateBounds(MLNCoordinateBounds.fromArray(bounds)) + + case "newLatLngZoom": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + guard let zoom = cameraUpdate[2] as? Double else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + let altitude = getAltitude(zoom: zoom, mapView: mapView) + return MLNMapCamera( + lookingAtCenter: camera.centerCoordinate, + altitude: altitude, + pitch: camera.pitch, + heading: camera.heading + ) + case "scrollBy": + guard let x = cameraUpdate[1] as? CGFloat else { return nil } + guard let y = cameraUpdate[2] as? CGFloat else { return nil } + let camera = mapView.camera + let mapPoint = mapView.convert(camera.centerCoordinate, toPointTo: mapView) + let movedPoint = CGPoint(x: mapPoint.x + x, y: mapPoint.y + y) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + case "zoomBy": + guard let zoomBy = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + zoomBy, mapView: mapView) + camera.altitude = altitude + if cameraUpdate.count == 2 { + return camera + } else { + guard let point = cameraUpdate[2] as? [CGFloat], + point.count == 2 else { return nil } + let movedPoint = CGPoint(x: point[0], y: point[1]) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + } + case "zoomIn": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomOut": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom - 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomTo": + guard let zoom = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let altitude = getAltitude(zoom: zoom, mapView: mapView) + camera.altitude = altitude + return camera + case "bearingTo": + guard let bearing = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + camera.heading = bearing + return camera + case "tiltTo": + guard let tilt = cameraUpdate[1] as? CGFloat else { return nil } + let camera = mapView.camera + camera.pitch = tilt + return camera + default: + print("\(type) not implemented!") + } + return nil + } + + class func getZoom(mapView: MLNMapView) -> Double { + return MLNZoomLevelForAltitude( + mapView.camera.altitude, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getAltitude(zoom: Double, mapView: MLNMapView) -> Double { + return MLNAltitudeForZoomLevel( + zoom, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getCoordinates(options: Any?) -> [CLLocationCoordinate2D] { + var coordinates: [CLLocationCoordinate2D] = [] + + if let options = options as? [String: Any], + let geometry = options["geometry"] as? [[Double]], geometry.count > 0 + { + for coordinate in geometry { + coordinates.append(CLLocationCoordinate2DMake(coordinate[0], coordinate[1])) + } + } + return coordinates + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift new file mode 100644 index 00000000..6d621d34 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift @@ -0,0 +1,3 @@ +enum MyLocationRenderMode: Int { + case Normal, Compass, Gps +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift new file mode 100644 index 00000000..722be04a --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift @@ -0,0 +1,148 @@ +import MapLibre + +extension MLNMapCamera { + func toDict(mapView: MLNMapView) -> [String: Any] { + let zoom = MLNZoomLevelForAltitude( + altitude, + pitch, + centerCoordinate.latitude, + mapView.frame.size + ) + return ["bearing": heading, + "target": centerCoordinate.toArray(), + "tilt": pitch, + "zoom": zoom] + } + + static func fromDict(_ dict: [String: Any], mapView: MLNMapView) -> MLNMapCamera? { + guard let target = dict["target"] as? [Double], + let zoom = dict["zoom"] as? Double, + let tilt = dict["tilt"] as? CGFloat, + let bearing = dict["bearing"] as? Double else { return nil } + let location = CLLocationCoordinate2D.fromArray(target) + let altitude = MLNAltitudeForZoomLevel(zoom, tilt, location.latitude, mapView.frame.size) + return MLNMapCamera( + lookingAtCenter: location, + altitude: altitude, + pitch: tilt, + heading: bearing + ) + } +} + +extension CLLocation { + func toDict() -> [String: Any]? { + return ["position": coordinate.toArray(), + "altitude": altitude, + "bearing": course, + "speed": speed, + "horizontalAccuracy": horizontalAccuracy, + "verticalAccuracy": verticalAccuracy, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLHeading { + func toDict() -> [String: Any]? { + return ["magneticHeading": magneticHeading, + "trueHeading": trueHeading, + "headingAccuracy": headingAccuracy, + "x": x, + "y": y, + "z": z, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLLocationCoordinate2D { + func toArray() -> [Double] { + return [latitude, longitude] + } + + static func fromArray(_ array: [Double]) -> CLLocationCoordinate2D { + return CLLocationCoordinate2D(latitude: array[0], longitude: array[1]) + } +} + +extension MLNCoordinateBounds { + func toArray() -> [[Double]] { + return [sw.toArray(), ne.toArray()] + } + + static func fromArray(_ array: [[Double]]) -> MLNCoordinateBounds { + let southwest = CLLocationCoordinate2D.fromArray(array[0]) + let northeast = CLLocationCoordinate2D.fromArray(array[1]) + return MLNCoordinateBounds(sw: southwest, ne: northeast) + } +} + +extension UIImage { + static func loadFromFile(imagePath: String, imageName: String) -> UIImage? { + // Add the trailing slash in path if missing. + let path = imagePath.hasSuffix("/") ? imagePath : "\(imagePath)/" + // Build scale dependant image path. + var scale = UIScreen.main.scale + var absolutePath = "\(path)\(scale)x/\(imageName)" + // Check if the image exists, if not try a an unscaled path. + if Bundle.main.path(forResource: absolutePath, ofType: nil) == nil { + absolutePath = "\(path)\(imageName)" + } else { + // found asset with higher resolution - increase scale even further to compensate + scale *= scale + } + // Load image if it exists. + if let path = Bundle.main.path(forResource: absolutePath, ofType: nil) { + let imageUrl = URL(fileURLWithPath: path) + if let imageData: Data = try? Data(contentsOf: imageUrl), + let image = UIImage(data: imageData, scale: scale) + { + return image + } + } + return nil + } +} + +public extension UIColor { + convenience init?(hexString: String) { + let r, g, b, a: CGFloat + + if hexString.hasPrefix("#") { + let start = hexString.index(hexString.startIndex, offsetBy: 1) + let hexColor = hexString[start...] + + let scanner = Scanner(string: String(hexColor)) + var hexNumber: UInt64 = 0 + + if hexColor.count == 6 { + if scanner.scanHexInt64(&hexNumber) { + r = CGFloat((hexNumber & 0xFF0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x00FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000FF) / 255 + a = 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } else if hexColor.count == 8 { + if scanner.scanHexInt64(&hexNumber) { + a = CGFloat((hexNumber & 0xFF00_0000) >> 24) / 255 + r = CGFloat((hexNumber & 0x00FF_0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x0000_FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000_00FF) / 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } + } + + return nil + } +} + +extension Array { + var tail: Array { + return Array(dropFirst()) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift new file mode 100644 index 00000000..e80a7272 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift @@ -0,0 +1,536 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +import MapLibre + +class LayerPropertyConverter { + class func addSymbolProperties(symbolLayer: MLNSymbolStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "icon-opacity": + symbolLayer.iconOpacity = expression + case "icon-color": + symbolLayer.iconColor = expression + case "icon-halo-color": + symbolLayer.iconHaloColor = expression + case "icon-halo-width": + symbolLayer.iconHaloWidth = expression + case "icon-halo-blur": + symbolLayer.iconHaloBlur = expression + case "icon-translate": + symbolLayer.iconTranslation = expression + case "icon-translate-anchor": + symbolLayer.iconTranslationAnchor = expression + case "text-opacity": + symbolLayer.textOpacity = expression + case "text-color": + symbolLayer.textColor = expression + case "text-halo-color": + symbolLayer.textHaloColor = expression + case "text-halo-width": + symbolLayer.textHaloWidth = expression + case "text-halo-blur": + symbolLayer.textHaloBlur = expression + case "text-translate": + symbolLayer.textTranslation = expression + case "text-translate-anchor": + symbolLayer.textTranslationAnchor = expression + case "symbol-placement": + symbolLayer.symbolPlacement = expression + case "symbol-spacing": + symbolLayer.symbolSpacing = expression + case "symbol-avoid-edges": + symbolLayer.symbolAvoidsEdges = expression + case "symbol-sort-key": + symbolLayer.symbolSortKey = expression + case "symbol-z-order": + symbolLayer.symbolZOrder = expression + case "icon-allow-overlap": + symbolLayer.iconAllowsOverlap = expression + case "icon-ignore-placement": + symbolLayer.iconIgnoresPlacement = expression + case "icon-optional": + symbolLayer.iconOptional = expression + case "icon-rotation-alignment": + symbolLayer.iconRotationAlignment = expression + case "icon-size": + symbolLayer.iconScale = expression + case "icon-text-fit": + symbolLayer.iconTextFit = expression + case "icon-text-fit-padding": + symbolLayer.iconTextFitPadding = expression + case "icon-image": + symbolLayer.iconImageName = expression + case "icon-rotate": + symbolLayer.iconRotation = expression + case "icon-padding": + symbolLayer.iconPadding = expression + case "icon-keep-upright": + symbolLayer.keepsIconUpright = expression + case "icon-offset": + symbolLayer.iconOffset = expression + case "icon-anchor": + symbolLayer.iconAnchor = expression + case "icon-pitch-alignment": + symbolLayer.iconPitchAlignment = expression + case "text-pitch-alignment": + symbolLayer.textPitchAlignment = expression + case "text-rotation-alignment": + symbolLayer.textRotationAlignment = expression + case "text-field": + symbolLayer.text = expression + case "text-font": + symbolLayer.textFontNames = expression + case "text-size": + symbolLayer.textFontSize = expression + case "text-max-width": + symbolLayer.maximumTextWidth = expression + case "text-line-height": + symbolLayer.textLineHeight = expression + case "text-letter-spacing": + symbolLayer.textLetterSpacing = expression + case "text-justify": + symbolLayer.textJustification = expression + case "text-radial-offset": + symbolLayer.textRadialOffset = expression + case "text-variable-anchor": + symbolLayer.textVariableAnchor = expression + case "text-anchor": + symbolLayer.textAnchor = expression + case "text-max-angle": + symbolLayer.maximumTextAngle = expression + case "text-writing-mode": + symbolLayer.textWritingModes = expression + case "text-rotate": + symbolLayer.textRotation = expression + case "text-padding": + symbolLayer.textPadding = expression + case "text-keep-upright": + symbolLayer.keepsTextUpright = expression + case "text-transform": + symbolLayer.textTransform = expression + case "text-offset": + symbolLayer.textOffset = expression + case "text-allow-overlap": + symbolLayer.textAllowsOverlap = expression + case "text-ignore-placement": + symbolLayer.textIgnoresPlacement = expression + case "text-optional": + symbolLayer.textOptional = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + symbolLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addCircleProperties(circleLayer: MLNCircleStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "circle-radius": + circleLayer.circleRadius = expression + case "circle-color": + circleLayer.circleColor = expression + case "circle-blur": + circleLayer.circleBlur = expression + case "circle-opacity": + circleLayer.circleOpacity = expression + case "circle-translate": + circleLayer.circleTranslation = expression + case "circle-translate-anchor": + circleLayer.circleTranslationAnchor = expression + case "circle-pitch-scale": + circleLayer.circleScaleAlignment = expression + case "circle-pitch-alignment": + circleLayer.circlePitchAlignment = expression + case "circle-stroke-width": + circleLayer.circleStrokeWidth = expression + case "circle-stroke-color": + circleLayer.circleStrokeColor = expression + case "circle-stroke-opacity": + circleLayer.circleStrokeOpacity = expression + case "circle-sort-key": + circleLayer.circleSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + circleLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addLineProperties(lineLayer: MLNLineStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "line-opacity": + lineLayer.lineOpacity = expression + case "line-color": + lineLayer.lineColor = expression + case "line-translate": + lineLayer.lineTranslation = expression + case "line-translate-anchor": + lineLayer.lineTranslationAnchor = expression + case "line-width": + lineLayer.lineWidth = expression + case "line-gap-width": + lineLayer.lineGapWidth = expression + case "line-offset": + lineLayer.lineOffset = expression + case "line-blur": + lineLayer.lineBlur = expression + case "line-dasharray": + lineLayer.lineDashPattern = expression + case "line-pattern": + lineLayer.linePattern = expression + case "line-gradient": + lineLayer.lineGradient = expression + case "line-cap": + lineLayer.lineCap = expression + case "line-join": + lineLayer.lineJoin = expression + case "line-miter-limit": + lineLayer.lineMiterLimit = expression + case "line-round-limit": + lineLayer.lineRoundLimit = expression + case "line-sort-key": + lineLayer.lineSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + lineLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addFillProperties(fillLayer: MLNFillStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "fill-antialias": + fillLayer.fillAntialiased = expression + case "fill-opacity": + fillLayer.fillOpacity = expression + case "fill-color": + fillLayer.fillColor = expression + case "fill-outline-color": + fillLayer.fillOutlineColor = expression + case "fill-translate": + fillLayer.fillTranslation = expression + case "fill-translate-anchor": + fillLayer.fillTranslationAnchor = expression + case "fill-pattern": + fillLayer.fillPattern = expression + case "fill-sort-key": + fillLayer.fillSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addFillExtrusionProperties(fillExtrusionLayer: MLNFillExtrusionStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "fill-extrusion-opacity": + fillExtrusionLayer.fillExtrusionOpacity = expression + case "fill-extrusion-color": + fillExtrusionLayer.fillExtrusionColor = expression + case "fill-extrusion-translate": + fillExtrusionLayer.fillExtrusionTranslation = expression + case "fill-extrusion-translate-anchor": + fillExtrusionLayer.fillExtrusionTranslationAnchor = expression + case "fill-extrusion-pattern": + fillExtrusionLayer.fillExtrusionPattern = expression + case "fill-extrusion-height": + fillExtrusionLayer.fillExtrusionHeight = expression + case "fill-extrusion-base": + fillExtrusionLayer.fillExtrusionBase = expression + case "fill-extrusion-vertical-gradient": + fillExtrusionLayer.fillExtrusionHasVerticalGradient = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillExtrusionLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addRasterProperties(rasterLayer: MLNRasterStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "raster-opacity": + rasterLayer.rasterOpacity = expression + case "raster-hue-rotate": + rasterLayer.rasterHueRotation = expression + case "raster-brightness-min": + rasterLayer.minimumRasterBrightness = expression + case "raster-brightness-max": + rasterLayer.maximumRasterBrightness = expression + case "raster-saturation": + rasterLayer.rasterSaturation = expression + case "raster-contrast": + rasterLayer.rasterContrast = expression + case "raster-resampling": + rasterLayer.rasterResamplingMode = expression + case "raster-fade-duration": + rasterLayer.rasterFadeDuration = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + rasterLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addHillshadeProperties(hillshadeLayer: MLNHillshadeStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "hillshade-illumination-direction": + hillshadeLayer.hillshadeIlluminationDirection = expression + case "hillshade-illumination-anchor": + hillshadeLayer.hillshadeIlluminationAnchor = expression + case "hillshade-exaggeration": + hillshadeLayer.hillshadeExaggeration = expression + case "hillshade-shadow-color": + hillshadeLayer.hillshadeShadowColor = expression + case "hillshade-highlight-color": + hillshadeLayer.hillshadeHighlightColor = expression + case "hillshade-accent-color": + hillshadeLayer.hillshadeAccentColor = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + hillshadeLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addHeatmapProperties(heatmapLayer: MLNHeatmapStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "heatmap-radius": + heatmapLayer.heatmapRadius = expression + case "heatmap-weight": + heatmapLayer.heatmapWeight = expression + case "heatmap-intensity": + heatmapLayer.heatmapIntensity = expression + case "heatmap-color": + heatmapLayer.heatmapColor = expression + case "heatmap-opacity": + heatmapLayer.heatmapOpacity = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + heatmapLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + private class func interpretExpression(propertyName: String, expression: String) -> NSExpression? { + let isColor = propertyName.contains("color"); + let isOffset = propertyName.contains("offset"); + let isTranslate = propertyName.contains("translate"); + + do { + let json = try JSONSerialization.jsonObject(with: expression.data(using: .utf8)!, options: .fragmentsAllowed) + + // Check if JSON contains NSNull - this would create an invalid NSExpression + if json is NSNull { + return nil + } + + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of is a hexString + if isColor { + if let color = json as? String { + return NSExpression(forConstantValue: UIColor(hexString: color)) + } + } + + if let offset = json as? [Any]{ + // checks on the value of property that are literal expressions + if offset.count == 2 && offset.first is String && offset.first as? String == "literal" { + if let vector = offset.last as? [Any]{ + if(vector.count == 2) { + if isOffset || isTranslate { + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of a literal is an array destined for a CGVector + if let x = vector.first as? Double, let y = vector.last as? Double { + return NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: x, dy: y))) + } + } + return NSExpression.init(mglJSONObject: json) + } + } + // checks on the value of properties that are arrays + } else if offset.count == 2, let x = offset.first as? Double, let y = offset.last as? Double { + if isOffset || isTranslate { + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of an array is destined for a CGVector + return NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: x, dy: y))) + } + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data is an array of double + return NSExpression(forConstantValue: [NSNumber(value: x), NSNumber(value: y)]) + } else { + // Handle arrays with any number of elements (e.g., dash arrays with 3+ elements) + // Convert to array of NSNumbers for proper expression creation + let numbers = offset.compactMap { $0 as? Double }.map { NSNumber(value: $0) } + if numbers.count == offset.count { + return NSExpression(forConstantValue: numbers) + } + } + } + + return NSExpression.init(mglJSONObject: json) + } catch { + } + return nil + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift new file mode 100644 index 00000000..a19be863 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift @@ -0,0 +1,55 @@ +// +// MLNMapView+setLanguage.swift +// maplibre_gl +// +// Created by Julian Bissekkou on 09.08.23. +// + +import Foundation +import MapLibre + +extension MLNMapView { + func setMapLanguage(_ language: String) { + guard let style = style else { return } + + let layers = style.layers + + for layer in layers { + if let symbolLayer = layer as? MLNSymbolStyleLayer { + if symbolLayer.text == nil { + continue + } + + // We could skip the current iteration, whenever there is not current language. + if !symbolLayer.text.description.containsLanguage() { + continue + } + + let properties = ["text-field": "[\"coalesce\",[\"get\",\"name:\(language)\"],[\"get\",\"name:latin\"],[\"get\",\"name\"]]"] + + LayerPropertyConverter.addSymbolProperties( + symbolLayer: symbolLayer, + properties: properties + ) + } + } + } +} + + +private extension String { + func containsLanguage() -> Bool { + do { + let regex = try NSRegularExpression(pattern: "(name:[a-z]+)") + let range = NSRange(location: 0, length: self.utf16.count) + + if let _ = regex.firstMatch(in: self, options: [], range: range) { + return true + } else { + return false + } + } catch { + return false + } + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift new file mode 100644 index 00000000..78b72905 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift @@ -0,0 +1,39 @@ +import Foundation +import MapLibre + +public class MapLibreCustomHeaders { + private static var customHeaders: [String: String] = [:] + private static var filterPatterns: [String] = [] + + public static func setCustomHeaders(_ headers: [String: String], filter: [String]) { + customHeaders = headers + filterPatterns = filter + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers + MLNNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + } + + public static func getCustomHeaders() -> [String: String] { + return customHeaders + } + + public static func getFilterPatterns() -> [String] { + return filterPatterns + } + + public static func shouldApplyHeaders(to url: String) -> Bool { + if filterPatterns.isEmpty { + return true + } + + for pattern in filterPatterns { + if url.range(of: pattern, options: .regularExpression) != nil { + return true + } + } + + return false + } +} + diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift new file mode 100644 index 00000000..d49a4af4 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift @@ -0,0 +1,2131 @@ +import Flutter +import MapLibre + +class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, MapLibreMapOptionsSink, + UIGestureRecognizerDelegate +{ + private var registrar: FlutterPluginRegistrar + private var channel: FlutterMethodChannel? + + private var mapView: MLNMapView + private var isMapReady = false + private var dragEnabled = true + private var isFirstStyleLoad = true + private var onStyleLoadedCalled = false + private var mapReadyResult: FlutterResult? + private var previousDragCoordinate: CLLocationCoordinate2D? + private var originDragCoordinate: CLLocationCoordinate2D? + private var dragFeature: MLNFeature? + + private var initialTilt: CGFloat? + private var trackCameraPosition = false + private var myLocationEnabled = false + private var scrollingEnabled = true + private var isAdjustingCameraProgrammatically = false + + private var interactiveFeatureLayerIds = Set() + private var addedShapesByLayer = [String: MLNShape]() + + func view() -> UIView { + return mapView + } + + private var styleIsReady: Bool { + return onStyleLoadedCalled && mapView.style != nil + } + + private static func createMapView( + args: Any?, + frame: CGRect, + registrar: FlutterPluginRegistrar + ) -> MLNMapView { + if let args = args as? [String: Any], + let styleString = args["styleString"] as? String + { + if Self.styleStringIsJSON(styleString) { + return MLNMapView(frame: frame, styleJSON: styleString) + } + + if let url = Self.styleStringAsURL( + styleString, + registrar: registrar + ) { + return MLNMapView(frame: frame, styleURL: url) + } + } + + // Fallback to default if neither JSON nor valid URL + NSLog( + """ + Warning: MapLibreMapController - Initializing map view with \ + default style. This capability will be removed in a future release. + """ + ) + // https://github.com/maplibre/maplibre-native/issues/709 + return MLNMapView(frame: frame) + } + + init( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + registrar: FlutterPluginRegistrar + ) { + mapView = Self.createMapView( + args: args, + frame: frame, + registrar: registrar + ) + + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.registrar = registrar + + super.init() + + channel = FlutterMethodChannel( + name: "plugins.flutter.io/maplibre_gl_\(viewId)", + binaryMessenger: registrar.messenger() + ) + channel! + .setMethodCallHandler { [weak self] in self?.onMethodCall(methodCall: $0, result: $1) } + + mapView.delegate = self + + let singleTap = UITapGestureRecognizer( + target: self, + action: #selector(handleMapTap(sender:)) + ) + for recognizer in mapView.gestureRecognizers! + where (recognizer as? UITapGestureRecognizer)?.numberOfTapsRequired == 2 { + singleTap.require(toFail: recognizer) + } + mapView.addGestureRecognizer(singleTap) + + let longPress = UILongPressGestureRecognizer( + target: self, + action: #selector(handleMapLongPress(sender:)) + ) + var longPressRecognizerAdded = false + + if let args = args as? [String: Any] { + + Convert.interpretMapLibreMapOptions(options: args["options"], delegate: self) + if let initialCameraPosition = args["initialCameraPosition"] as? [String: Any], + let camera = MLNMapCamera.fromDict(initialCameraPosition, mapView: mapView), + let zoom = initialCameraPosition["zoom"] as? Double + { + mapView.setCenter( + camera.centerCoordinate, + zoomLevel: zoom, + direction: camera.heading, + animated: false + ) + initialTilt = camera.pitch + } + // if let onAttributionClickOverride = args["onAttributionClickOverride"] as? Bool { + // if onAttributionClickOverride { + // setupAttribution(mapView) + // } + // } + + if let enabled = args["dragEnabled"] as? Bool { + dragEnabled = enabled + } + + if let iosLongClickDurationMilliseconds = args["iosLongClickDurationMilliseconds"] as? Int { + longPress.minimumPressDuration = TimeInterval(iosLongClickDurationMilliseconds) / 1000 + mapView.addGestureRecognizer(longPress) + longPressRecognizerAdded = true + } + } + if dragEnabled { + let pan = UIPanGestureRecognizer( + target: self, + action: #selector(handleMapPan(sender:)) + ) + pan.delegate = self + mapView.addGestureRecognizer(pan) + } + + if(!longPressRecognizerAdded) { + mapView.addGestureRecognizer(longPress) + longPressRecognizerAdded = true + } + } + + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer + ) -> Bool { + return true + } + + func onMethodCall(methodCall: FlutterMethodCall, result: @escaping FlutterResult) { + switch methodCall.method { + case "map#waitForMap": + if isMapReady { + result(nil) + // only call map#onStyleLoaded here if isMapReady has happend and isFirstStyleLoad is true + if isFirstStyleLoad { + isFirstStyleLoad = false + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } else { + mapReadyResult = result + } + case "map#update": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + Convert.interpretMapLibreMapOptions(options: arguments["options"], delegate: self) + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#invalidateAmbientCache": + MLNOfflineStorage.shared.invalidateAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#clearAmbientCache": + MLNOfflineStorage.shared.clearAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#updateMyLocationTrackingMode": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let myLocationTrackingMode = arguments["mode"] as? UInt, + let trackingMode = MLNUserTrackingMode(rawValue: myLocationTrackingMode) + { + setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + result(nil) + case "map#matchMapLanguageWithDeviceDefault": + if let langStr = Locale.current.languageCode { + setMapLanguage(language: langStr) + } + + result(nil) + case "map#updateContentInsets": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + + if let bounds = arguments["bounds"] as? [String: Any], + let top = bounds["top"] as? CGFloat, + let left = bounds["left"] as? CGFloat, + let bottom = bounds["bottom"] as? CGFloat, + let right = bounds["right"] as? CGFloat, + let animated = arguments["animated"] as? Bool + { + mapView.setContentInset( + UIEdgeInsets(top: top, left: left, bottom: bottom, right: right), + animated: animated + ) { + result(nil) + } + } else { + result(nil) + } + case "locationComponent#getLastLocation": + var reply = [String: NSObject]() + if let loc = mapView.userLocation?.location?.coordinate { + reply["latitude"] = loc.latitude as NSObject + reply["longitude"] = loc.longitude as NSObject + result(reply) + } else { + result(nil) + } + case "map#setMapLanguage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let localIdentifier = arguments["language"] as? String { + setMapLanguage(language: localIdentifier) + } + result(nil) + case "map#queryRenderedFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var styleLayerIdentifiers: Set? + if let layerIds = arguments["layerIds"] as? [String], !layerIds.isEmpty { + styleLayerIdentifiers = Set(layerIds) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(mglJSONObject: filter) + } + var reply = [String: NSObject]() + var features: [MLNFeature] = [] + if let x = arguments["x"] as? Double, let y = arguments["y"] as? Double { + features = mapView.visibleFeatures( + at: CGPoint(x: x, y: y), + styleLayerIdentifiers: styleLayerIdentifiers, + predicate: filterExpression + ) + } + if let top = arguments["top"] as? Double, + let bottom = arguments["bottom"] as? Double, + let left = arguments["left"] as? Double, + let right = arguments["right"] as? Double + { + var width = right - left + var height = bottom - top + features = mapView.visibleFeatures(in: CGRect(x: left, y: top, width: width, height: height), styleLayerIdentifiers: styleLayerIdentifiers, predicate: filterExpression) + } + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .utf8) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + case "map#setTelemetryEnabled": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + let telemetryEnabled = arguments["enabled"] as? Bool + UserDefaults.standard.set(telemetryEnabled, forKey: "MLNMapboxMetricsEnabled") + result(nil) + case "map#getTelemetryEnabled": + let telemetryEnabled = UserDefaults.standard.bool(forKey: "MLNMapboxMetricsEnabled") + result(telemetryEnabled) + case "map#setMaximumFps": + result(nil) + case "map#forceOnlineMode": + // Force online mode by ensuring network requests are enabled + // In MapLibre GL iOS, this is typically handled by the style and data sources + result(nil) + case "camera#ease": + guard let arguments = methodCall.arguments as? [String: Any] else { + result(false) + return + } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { + result(false) + return + } + guard let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) else { + result(false) + return + } + // MESHMAPPER GUARD: refuse camera ops while the map view has no usable + // viewport. MapLibre's transform unprojects against the viewport and + // throws an uncaught C++ std::domain_error (SIGABRT, uncatchable from + // Dart) when it is degenerate/zero-sized. Complete the Future so the + // Dart-side await still returns. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(false) + return + } + + let completion = { + result(true) + } + + if let duration = arguments["duration"] as? Double, duration > 0 { + let interval: TimeInterval = duration / 1000.0 + mapView.setCamera(camera, withDuration: interval, animationTimingFunction: CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut), completionHandler: completion) + } else { + mapView.setCamera(camera, animated: true) + completion() + } + case "map#queryCameraPosition": + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#editGeoJsonSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let srcId = arguments["id"] as? String else { return } + guard let srcData = arguments["data"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + if let data = srcData.data(using: String.Encoding.utf8) { + let src = style.source(withIdentifier: srcId) + if src != nil && src is MLNShapeSource { + let geojsonSrc = src as! MLNShapeSource + let geojsonData = try? MLNShape(data: data, encoding: String.Encoding.utf8.rawValue) + if geojsonData != nil { + geojsonSrc.shape = geojsonData + ret = true + } + } + } + + reply["result"] = ret + result(reply) + case "map#editGeoJsonUrl": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let srcId = arguments["id"] as? String else { return } + guard let srcUrl = arguments["url"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + let src = style.source(withIdentifier: srcId) + if src != nil && src is MLNShapeSource { + let geojsonSrc = src as! MLNShapeSource + let geojsonUrl = URL(string: srcUrl) + if geojsonUrl != nil { + geojsonSrc.url = geojsonUrl + ret = true + } + } + + reply["result"] = ret + result(reply) + case "map#setLayerFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["id"] as? String else { return } + guard let layerFilter = arguments["filter"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + let layer = style.layer(withIdentifier: layerId) + if layer != nil { + do { + if let data = layerFilter.data(using: .utf8) { + let jsonFilter = try JSONSerialization.jsonObject(with: data, options: []) + let predicate = NSPredicate(mglJSONObject: jsonFilter) + if let layer = layer as? MLNVectorStyleLayer { + layer.predicate = predicate + ret = true + } + } + } catch { + print("Error parsing filter: \(error.localizedDescription)") + } + } + + reply["result"] = ret + result(reply) + case "map#getStyle": + var reply: [String: Bool] = [:] + reply["result"] = false + result(reply) + case "map#setCustomHeaders": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let headers = arguments["headers"] as? [String:String] else { return } + guard let filter = arguments["filter"] as? [String] else { return } + MapLibreCustomHeaders.setCustomHeaders(headers, filter: filter) + result(nil) + case "map#getCustomHeaders": + result(MapLibreCustomHeaders.getCustomHeaders()) + case "map#getVisibleRegion": + var reply = [String: NSObject]() + let visibleRegion = mapView.visibleCoordinateBounds + reply["sw"] = [visibleRegion.sw.latitude, visibleRegion.sw.longitude] as NSObject + reply["ne"] = [visibleRegion.ne.latitude, visibleRegion.ne.longitude] as NSObject + result(reply) + case "map#toScreenLocation": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let latitude = arguments["latitude"] as? Double else { return } + guard let longitude = arguments["longitude"] as? Double else { return } + let latlng = CLLocationCoordinate2DMake(latitude, longitude) + let returnVal = mapView.convert(latlng, toPointTo: mapView) + var reply = [String: NSObject]() + reply["x"] = returnVal.x as NSObject + reply["y"] = returnVal.y as NSObject + result(reply) + case "map#toScreenLocationBatch": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let data = arguments["coordinates"] as? FlutterStandardTypedData else { return } + let latLngs = data.data.withUnsafeBytes { + Array( + UnsafeBufferPointer( + start: $0.baseAddress!.assumingMemoryBound(to: Double.self), + count: Int(data.elementCount) + ) + ) + } + var reply: [Double] = Array(repeating: 0.0, count: latLngs.count) + for i in stride(from: 0, to: latLngs.count, by: 2) { + let coordinate = CLLocationCoordinate2DMake(latLngs[i], latLngs[i + 1]) + let returnVal = mapView.convert(coordinate, toPointTo: mapView) + reply[i] = Double(returnVal.x) + reply[i + 1] = Double(returnVal.y) + } + result(FlutterStandardTypedData( + float64: Data(bytes: &reply, count: reply.count * 8) + )) + case "map#getMetersPerPixelAtLatitude": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var reply = [String: NSObject]() + guard let latitude = arguments["latitude"] as? Double else { return } + let returnVal = mapView.metersPerPoint(atLatitude: latitude) + reply["metersperpixel"] = returnVal as NSObject + result(reply) + case "map#toLatLng": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let x = arguments["x"] as? Double else { return } + guard let y = arguments["y"] as? Double else { return } + let screenPoint = CGPoint(x: x, y: y) + let coordinates: CLLocationCoordinate2D = mapView.convert( + screenPoint, + toCoordinateFrom: mapView + ) + var reply = [String: NSObject]() + reply["latitude"] = coordinates.latitude as NSObject + reply["longitude"] = coordinates.longitude as NSObject + result(reply) + case "camera#move": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#ease) — prevents the uncatchable SIGABRT. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(nil) + return + } + + if let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) { + mapView.setCamera(camera, animated: false) + } + result(nil) + case "camera#animate": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + guard let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) else { return } + + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#ease) — prevents the uncatchable SIGABRT. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(nil) + return + } + + let completion = { + result(nil) + } + + if let duration = arguments["duration"] as? TimeInterval { + if let padding = Convert.parseLatLngBoundsPadding(cameraUpdate) { + mapView.fly(to: camera, edgePadding: padding, withDuration: duration / 1000, completionHandler: completion) + } else { + mapView.fly(to: camera, withDuration: duration / 1000, completionHandler: completion) + } + } else { + mapView.setCamera(camera, animated: true) + completion() + } + case "symbolLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addSymbolLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "lineLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addLineLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "layer#setProperties": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(FlutterError( + code: "LAYER_NOT_FOUND_ERROR", + message: "Layer " + layerId + "not found", + details: "" + )) + return + } + + //switch depending on the runtime type of layer + switch layer { + case let lineLayer as MLNLineStyleLayer: + LayerPropertyConverter.addLineProperties(lineLayer: lineLayer, properties: properties) + case let fillLayer as MLNFillStyleLayer: + LayerPropertyConverter.addFillProperties(fillLayer: fillLayer, properties: properties) + case let circleLayer as MLNCircleStyleLayer: + LayerPropertyConverter.addCircleProperties(circleLayer: circleLayer, properties: properties) + case let symbolLayer as MLNSymbolStyleLayer: + LayerPropertyConverter.addSymbolProperties(symbolLayer: symbolLayer, properties: properties) + case let rasterLayer as MLNRasterStyleLayer: + LayerPropertyConverter.addRasterProperties(rasterLayer: rasterLayer, properties: properties) + case let hillshadeLayer as MLNHillshadeStyleLayer: + LayerPropertyConverter.addHillshadeProperties(hillshadeLayer: hillshadeLayer, properties: properties) + default: + result(FlutterError( + code: "UNSUPPORTED_LAYER_TYPE", + message: "Layer type not supported", + details: "" + )) + return + } + + result(nil) + + case "fillLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addFillLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "fillExtrusionLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addFillExtrusionLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "circleLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addCircleLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "hillshadeLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + let addResult = addHillshadeLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "heatmapLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let addResult = addHeatmapLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "rasterLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let addResult = addRasterLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#addImage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let name = arguments["name"] as? String else { return } + // guard let length = arguments["length"] as? NSNumber else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let sdf = arguments["sdf"] as? Bool else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data, scale: UIScreen.main.scale) else { return } + if sdf { + mapView.style?.setImage(image.withRenderingMode(.alwaysTemplate), forName: name) + } else { + mapView.style?.setImage(image, forName: name) + } + result(nil) + + case "style#addImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + + guard let coordinates = arguments["coordinates"] as? [[Double]] else { return } + let quad = MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][0], + longitude: coordinates[0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][0], + longitude: coordinates[3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][0], + longitude: coordinates[2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][0], + longitude: coordinates[1][1] + ) + ) + + // Check for duplicateSource error + if mapView.style?.source(withIdentifier: imageSourceId) != nil { + result(FlutterError( + code: "duplicateSource", + message: "Source with imageSourceId \(imageSourceId) already exists", + details: "Can't add duplicate source with imageSourceId: \(imageSourceId)" + )) + return + } + + let source = MLNImageSource( + identifier: imageSourceId, + coordinateQuad: quad, + image: image + ) + mapView.style?.addSource(source) + + result(nil) + case "style#updateImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let imageSource = mapView.style? + .source(withIdentifier: imageSourceId) as? MLNImageSource else { return } + let bytes = arguments["bytes"] as? FlutterStandardTypedData + if bytes != nil { + guard let data = bytes!.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + imageSource.image = image + } + let coordinates = arguments["coordinates"] as? [[Double]] + if coordinates != nil { + let quad = MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates![0][0], + longitude: coordinates![0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates![3][0], + longitude: coordinates![3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates![2][0], + longitude: coordinates![2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates![1][0], + longitude: coordinates![1][1] + ) + ) + imageSource.coordinates = quad + } + result(nil) + case "style#removeSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let source = mapView.style?.source(withIdentifier: sourceId) else { + result(nil) + return + } + mapView.style?.removeSource(source) + result(nil) + case "style#addLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + + let layer = MLNRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + + mapView.style?.addLayer(layer) + case "style#addLayerBelow": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let belowLayerId = arguments["belowLayerId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + // Check for noSuchLayer error + guard let belowLayer = mapView.style?.layer(withIdentifier: belowLayerId) else { + result(FlutterError( + code: "noSuchLayer", + message: "No layer found with layerId \(belowLayerId)", + details: "Can't insert layer below layer with id \(belowLayerId), as no such layer exists." + )) + return + } + + let layer = MLNRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + mapView.style?.insertLayer(layer, below: belowLayer) + result(nil) + + case "style#removeLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + interactiveFeatureLayerIds.remove(layerId) + mapView.style?.removeLayer(layer) + result(nil) + + case "map#setCameraBounds": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let west = arguments["west"] as? Double else { return } + guard let north = arguments["north"] as? Double else { return } + guard let south = arguments["south"] as? Double else { return } + guard let east = arguments["east"] as? Double else { return } + guard let padding = arguments["padding"] as? CGFloat else { return } + + let southwest = CLLocationCoordinate2D(latitude: south, longitude: west) + let northeast = CLLocationCoordinate2D(latitude: north, longitude: east) + let bounds = MLNCoordinateBounds(sw: southwest, ne: northeast) + mapView.setVisibleCoordinateBounds(bounds, edgePadding: UIEdgeInsets(top: padding, + left: padding, bottom: padding, right: padding) , animated: true) + result(nil) + + case "style#setFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let filter = arguments["filter"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + switch setFilter(layer, filter) { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#addGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + let addResult = addSourceGeojson(sourceId: sourceId, geojson: geojson) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#addSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: Any] else { return } + let addResult = addSource(sourceId: sourceId, properties: properties) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#setGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + let setResult = setSource(sourceId: sourceId, geojson: geojson) + + switch setResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#setFeature": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojsonFeature"] as? String else { return } + let setResult = setFeature(sourceId: sourceId, geojsonFeature: geojson) + + switch setResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "layer#setVisibility": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let visible = arguments["visible"] as? Bool else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + layer.isVisible = visible + result(nil) + + case "map#querySourceFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + + var sourceLayerId = Set() + if let layerId = arguments["sourceLayerId"] as? String { + sourceLayerId.insert(layerId) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(mglJSONObject: filter) + } + + var reply = [String: NSObject]() + var features: [MLNFeature] = [] + + guard let style = mapView.style else { return } + if let source = style.source(withIdentifier: sourceId) { + if let vectorSource = source as? MLNVectorTileSource { + features = vectorSource.features(sourceLayerIdentifiers: sourceLayerId, predicate: filterExpression) + } else if let shapeSource = source as? MLNShapeSource { + features = shapeSource.features(matching: filterExpression) + } + } + + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .utf8) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + + case "style#getLayerIds": + var layerIds = [String]() + + guard let style = mapView.style else { return } + + style.layers.forEach { layer in layerIds.append(layer.identifier) } + + var reply = [String: NSObject]() + reply["layers"] = layerIds as NSObject + result(reply) + + case "style#getSourceIds": + var sourceIds = [String]() + + guard let style = mapView.style else { return } + + style.sources.forEach { source in sourceIds.append(source.identifier) } + + var reply = [String: NSObject]() + reply["sources"] = sourceIds as NSObject + result(reply) + + case "style#getFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + + guard let style = mapView.style else { return } + guard let layer = style.layer(withIdentifier: layerId) else { return } + + var currentLayerFilter : String = "" + if let vectorLayer = layer as? MLNVectorStyleLayer { + if let layerFilter = vectorLayer.predicate { + + let jsonExpression = layerFilter.mgl_jsonExpressionObject + if let data = try? JSONSerialization.data(withJSONObject: jsonExpression, options: []) { + currentLayerFilter = String(data: data, encoding: String.Encoding.utf8) ?? "" + } + } + } else { + result(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + ).flutterError) + return; + } + + var reply = [String: NSObject]() + reply["filter"] = currentLayerFilter as NSObject + result(reply) + + case "style#setStyle": + if let arguments = methodCall.arguments as? [String: Any] { + if let style = arguments["style"] as? String { + setStyleString(styleString: style) + result(nil) + } else { + // Error for missing style key in argument + result( + FlutterError( + code: "invalidStyleString", + message: "Missing style key in arguments", + details: nil + ) + ) + } + } else { + // Error for invalid arguments type + result( + FlutterError( + code: "invalidArgumentsType", + message: "Arguments not of type [String: Any]", + details: nil + ) + ) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func loadIconImage(name: String) -> UIImage? { + // Build up the full path of the asset. + // First find the last '/' ans split the image name in the asset directory and the image file name. + if let range = name.range(of: "/", options: [.backwards]) { + let directory = String(name[.. MLNMapCamera? { + return trackCameraPosition ? mapView.camera : nil + } + + private func setMapLanguage(language: String) { + self.mapView.setMapLanguage(language) + } + + /* + * Scan layers from top to bottom and return the first matching feature + */ + private func firstFeatureOnLayers(at: CGPoint) -> (feature: MLNFeature?, layerId: String?) { + guard let style = mapView.style else { + NSLog("MapLibreMapController - Map style is nil") + return (nil, nil) + } + + guard styleIsReady else { + NSLog("MapLibreMapController - Map style is not ready yet") + return (nil, nil) + } + + // get layers in order (interactiveFeatureLayerIds is unordered) + let clickableLayers = style.layers.filter { layer in + interactiveFeatureLayerIds.contains(layer.identifier) + } + + for layer in clickableLayers.reversed() { + let features = mapView.visibleFeatures( + at: at, + styleLayerIdentifiers: [layer.identifier] + ) + if let feature = features.first { + return (feature, layer.identifier) + } + } + return (nil, nil) + } + + /* + * UITapGestureRecognizer + * On tap invoke the map#onMapClick callback. + */ + @IBAction func handleMapTap(sender: UITapGestureRecognizer) { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + let result = firstFeatureOnLayers(at: point) + if let feature = result.feature { + channel?.invokeMethod("feature#onTap", arguments: [ + "id": feature.identifier, + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + "layerId": result.layerId, + ]) + } else { + channel?.invokeMethod("map#onMapClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + fileprivate func invokeFeatureDrag( + _ point: CGPoint, + _ coordinate: CLLocationCoordinate2D, + _ eventType: String + ) { + if let feature = dragFeature, + let id = feature.identifier, + let previous = previousDragCoordinate, + let origin = originDragCoordinate + { + channel?.invokeMethod("feature#onDrag", arguments: [ + "id": id, + "x": point.x, + "y": point.y, + "originLng": origin.longitude, + "originLat": origin.latitude, + "currentLng": coordinate.longitude, + "currentLat": coordinate.latitude, + "eventType": eventType, + "deltaLng": coordinate.longitude - previous.longitude, + "deltaLat": coordinate.latitude - previous.latitude, + ]) + } + } + + @IBAction func handleMapPan(sender: UIPanGestureRecognizer) { + let began = sender.state == UIGestureRecognizer.State.began + let end = sender.state == UIGestureRecognizer.State.ended + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + if dragFeature == nil, began, sender.numberOfTouches == 1 { + let result = firstFeatureOnLayers(at: point) + if let feature = result.feature, + let draggable = feature.attribute(forKey: "draggable") as? Bool, + draggable { + sender.state = UIGestureRecognizer.State.began + dragFeature = feature + originDragCoordinate = coordinate + previousDragCoordinate = coordinate + mapView.allowsScrolling = false + let eventType = "start" + invokeFeatureDrag(point, coordinate, eventType) + for gestureRecognizer in mapView.gestureRecognizers! { + if let _ = gestureRecognizer as? UIPanGestureRecognizer { + gestureRecognizer.addTarget(self, action: #selector(handleMapPan)) + break + } + } + } + } + if end, dragFeature != nil { + mapView.allowsScrolling = true + let eventType = "end" + invokeFeatureDrag(point, coordinate, eventType) + dragFeature = nil + originDragCoordinate = nil + previousDragCoordinate = nil + } + + if !began, !end, dragFeature != nil { + let eventType = "drag" + invokeFeatureDrag(point, coordinate, eventType) + previousDragCoordinate = coordinate + } + } + + /* + * UILongPressGestureRecognizer + * After a long press invoke the map#onMapLongClick callback. + */ + @IBAction func handleMapLongPress(sender: UILongPressGestureRecognizer) { + // Fire when the long press starts + if sender.state == .began { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + channel?.invokeMethod("map#onMapLongClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + /* /* + * Override the attribution button's click target to handle the event locally. + * Called if the application supplies an onAttributionClick handler. + */ + func setupAttribution(_ mapView: MLNMapView) { + mapView.attributionButton.removeTarget( + mapView, + action: #selector(mapView.showAttribution), + for: .touchUpInside + ) + mapView.attributionButton.addTarget( + self, + action: #selector(showAttribution), + for: UIControl.Event.touchUpInside + ) + } + + /* + * Custom click handler for the attribution button. This callback is bound when + * the application specifies an onAttributionClick handler. + */ + @objc func showAttribution() { + channel?.invokeMethod("map#onAttributionClick", arguments: []) + } */ + + /* + * MLNMapViewDelegate + */ + func mapView(_ mapView: MLNMapView, didFinishLoading _: MLNStyle) { + isMapReady = true + updateMyLocationEnabled() + + if let initialTilt = initialTilt { + let camera = mapView.camera + camera.pitch = initialTilt + mapView.setCamera(camera, animated: false) + } + + addedShapesByLayer.removeAll() + interactiveFeatureLayerIds.removeAll() + + mapReadyResult?(nil) + + // On first launch we only call map#onStyleLoaded if map#waitForMap has already been called + if !isFirstStyleLoad || mapReadyResult != nil { + isFirstStyleLoad = false + + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } + + // handle missing images + func mapView(_: MLNMapView, didFailToLoadImage name: String) -> UIImage? { + return loadIconImage(name: name) + } + + func mapView(_: MLNMapView, didUpdate userLocation: MLNUserLocation?) { + if let channel = channel, let userLocation = userLocation, + let location = userLocation.location + { + channel.invokeMethod("map#onUserLocationUpdated", arguments: [ + "userLocation": location.toDict(), + "heading": userLocation.heading?.toDict(), + ]) + } + } + + func mapView(_: MLNMapView, didChange mode: MLNUserTrackingMode, animated _: Bool) { + if let channel = channel { + channel.invokeMethod("map#onCameraTrackingChanged", arguments: ["mode": mode.rawValue]) + if mode == .none { + channel.invokeMethod("map#onCameraTrackingDismissed", arguments: []) + } + } + } + + private func validateBeforeLayerAdd( + sourceId: String, + layerId: String + ) -> Result<(MLNStyle, MLNSource), MethodCallError> { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard let source = style.source(withIdentifier: sourceId) else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + guard style.layer(withIdentifier: layerId) == nil else { + return .failure(.layerAlreadyExists(layerId: layerId)) + } + return .success((style, source)) + } + + + func addSymbolLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNSymbolStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addSymbolProperties( + symbolLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addLineLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNLineStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addLineProperties(lineLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addFillLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNFillStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillProperties(fillLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addFillExtrusionLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNFillExtrusionStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillExtrusionProperties( + fillExtrusionLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + + + func addCircleLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNCircleStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addCircleProperties( + circleLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func setFilter(_ layer: MLNStyleLayer, _ filter: String) -> Result { + do { + let filter = try JSONSerialization.jsonObject( + with: filter.data(using: .utf8)!, + options: .fragmentsAllowed + ) + if filter is NSNull { + return .success(()) + } + let predicate = NSPredicate(mglJSONObject: filter) + if let layer = layer as? MLNVectorStyleLayer { + layer.predicate = predicate + } else { + return .failure(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + )) + } + return .success(()) + } catch { + return .failure(MethodCallError.invalidExpression) + } + } + + func addHillshadeLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNHillshadeStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHillshadeProperties( + hillshadeLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addHeatmapLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNHeatmapStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHeatmapProperties( + heatmapLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addRasterLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNRasterStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addRasterProperties( + rasterLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addSource(sourceId: String, properties: [String: Any]) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard style.source(withIdentifier: sourceId) == nil else { + return .failure(.sourceAlreadyExists(sourceId: sourceId)) + } + guard let type = properties["type"] as? String else { + return .failure(.invalidSourceType( + details: "Source '\(sourceId)' does not have a type." + )) + } + + var source: MLNSource? + switch type { + case "vector": + source = SourcePropertyConverter.buildVectorTileSource( + identifier: sourceId, + properties: properties + ) + case "raster": + source = SourcePropertyConverter.buildRasterTileSource( + identifier: sourceId, + properties: properties + ) + case "raster-dem": + source = SourcePropertyConverter.buildRasterDemSource( + identifier: sourceId, + properties: properties + ) + case "image": + source = SourcePropertyConverter.buildImageSource( + identifier: sourceId, + properties: properties + ) + case "geojson": + source = SourcePropertyConverter.buildShapeSource( + identifier: sourceId, + properties: properties + ) + default: + // unsupported source type + source = nil + } + if let source = source { + style.addSource(source) + return .success(()) + } + return .failure(.invalidSourceType( + details: "Source '\(sourceId)' does not support type '\(type)'." + )) + + } + + func mapViewDidBecomeIdle(_: MLNMapView) { + if let channel = channel { + channel.invokeMethod("map#onIdle", arguments: []) + } + } + + func mapView(_: MLNMapView, regionWillChangeAnimated _: Bool) { + if let channel = channel { + channel.invokeMethod("camera#onMoveStarted", arguments: []) + } + } + + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + if !trackCameraPosition { return } + if let channel = channel { + channel.invokeMethod("camera#onMove", arguments: [ + "position": getCamera()?.toDict(mapView: mapView), + ]) + } + } + + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { + let arguments = trackCameraPosition ? [ + "position": getCamera()?.toDict(mapView: mapView) + ] : [:] + if let channel = channel { + channel.invokeMethod("camera#onIdle", arguments: arguments) + } + } + + func addSourceGeojson(sourceId: String, geojson: String) -> Result { + do{ + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard style.source(withIdentifier: sourceId) == nil else { + return .failure(.sourceAlreadyExists(sourceId: sourceId)) + } + + let parsed = try MLNShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + let source = MLNShapeSource(identifier: sourceId, shape: parsed, options: [:]) + addedShapesByLayer[sourceId] = parsed + style.addSource(source) + return .success(()) + } catch { + return .failure(.geojsonParseError(sourceId: sourceId)) + } + } + + func setSource(sourceId: String, geojson: String) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + + do{ + let parsed = try MLNShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + guard let source = style.source(withIdentifier: sourceId) as? MLNShapeSource else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + addedShapesByLayer[sourceId] = parsed + source.shape = parsed + return .success(()) + }catch{ + return .failure(.geojsonParseError(sourceId: sourceId)) + } + + } + + + func setFeature(sourceId: String, geojsonFeature: String) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + do { + let newShape = try MLNShape( + data: geojsonFeature.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + guard let source = style.source(withIdentifier: sourceId) as? MLNShapeSource else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + if let shape = addedShapesByLayer[sourceId] as? MLNShapeCollectionFeature, + let feature = newShape as? MLNShape & MLNFeature + { + if let index = shape.shapes + .firstIndex(where: { + if let id = $0.identifier as? String, + let featureId = feature.identifier as? String + { return id == featureId } + + if let id = $0.identifier as? NSNumber, + let featureId = feature.identifier as? NSNumber + { return id == featureId } + return false + }) + { + var shapes = shape.shapes + shapes[index] = feature + + source.shape = MLNShapeCollectionFeature(shapes: shapes) + } + + addedShapesByLayer[sourceId] = source.shape + return .success(()) + } + return .failure(.genericError(details: "Failed to set feature for sourceId \(sourceId)")) + + } catch { + return .failure(.geojsonParseError(sourceId: sourceId)) + } + } + + /* + * MapLibreMapOptionsSink + */ + func setCameraTargetBounds(bounds: MLNCoordinateBounds?) { + let bounds = bounds ?? MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: -90, longitude: -180), + ne: CLLocationCoordinate2D(latitude: 90, longitude: 180) + ) + mapView.maximumScreenBounds = bounds; + } + + func setCompassEnabled(compassEnabled: Bool) { + mapView.compassView.isHidden = compassEnabled + mapView.compassView.isHidden = !compassEnabled + } + + func setMinMaxZoomPreference(min: Double?, max: Double?) { + // Use MapLibre defaults (0 for min, 22 for max) when unbounded (nil) + let minZoom = min ?? 0.0 + let maxZoom = max ?? 22.0 + + mapView.minimumZoomLevel = minZoom + mapView.maximumZoomLevel = maxZoom + } + + private static func styleStringIsJSON(_ styleString: String) -> Bool { + return styleString.hasPrefix("{") || styleString.hasPrefix("[") + } + + private static func styleStringAsURL( + _ styleString: String, + registrar: FlutterPluginRegistrar + ) -> URL? { + if styleString.isEmpty { + NSLog("styleStringAsURL - style string is empty, ignoring") + return nil + } else if styleStringIsJSON(styleString) { + return nil + } else if styleString.hasPrefix("/") { + // Absolute path + return URL(fileURLWithPath: styleString, isDirectory: false) + } else if !styleString.hasPrefix("http://"), + !styleString.hasPrefix("https://"), + !styleString.hasPrefix("mapbox://") + { + // We are assuming that the style will be loaded from an asset here. + let assetPath = registrar.lookupKey(forAsset: styleString) + return URL(string: assetPath, relativeTo: Bundle.main.resourceURL) + } else if (styleString.hasPrefix("file://")) { + if let path = Bundle.main.path( + forResource: styleString.deletingPrefix("file://"), + ofType: "json" + ) { + return URL(fileURLWithPath: path) + } else { + NSLog( + "styleStringAsURL - path not found: \(styleString), ignoring" + ) + return nil + } + } else { + return URL(string: styleString) + } + } + + func setStyleString(styleString: String) { + interactiveFeatureLayerIds.removeAll() + addedShapesByLayer.removeAll() + + if Self.styleStringIsJSON(styleString) { + mapView.styleJSON = styleString + } else if let url = Self.styleStringAsURL( + styleString, + registrar: registrar + ) { + mapView.styleURL = url; + } + } + + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) { + mapView.allowsRotating = rotateGesturesEnabled + } + + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) { + mapView.allowsScrolling = scrollGesturesEnabled + scrollingEnabled = scrollGesturesEnabled + } + + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) { + mapView.allowsTilting = tiltGesturesEnabled + } + + func setTrackCameraPosition(trackCameraPosition: Bool) { + self.trackCameraPosition = trackCameraPosition + } + + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) { + mapView.allowsZooming = zoomGesturesEnabled + } + + func setMyLocationEnabled(myLocationEnabled: Bool) { + if self.myLocationEnabled == myLocationEnabled { + return + } + self.myLocationEnabled = myLocationEnabled + updateMyLocationEnabled() + } + + func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) { + mapView.userTrackingMode = myLocationTrackingMode + } + + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) { + switch myLocationRenderMode { + case .Normal: + mapView.showsUserHeadingIndicator = false + case .Compass: + mapView.showsUserHeadingIndicator = true + case .Gps: + NSLog("RenderMode.GPS currently not supported") + } + } + + func setLogoEnabled(logoEnabled: Bool) { + mapView.logoView.isHidden = !logoEnabled + } + + func setLogoViewPosition(position: MLNOrnamentPosition) { + mapView.logoViewPosition = position + } + + func setLogoViewMargins(x: Double, y: Double) { + mapView.logoViewMargins = CGPoint(x: x, y: y) + } + + func setCompassViewPosition(position: MLNOrnamentPosition) { + mapView.compassViewPosition = position + } + + func setCompassViewMargins(x: Double, y: Double) { + mapView.compassViewMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonMargins(x: Double, y: Double) { + mapView.attributionButtonMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonPosition(position: MLNOrnamentPosition) { + mapView.attributionButtonPosition = position + } +} + +extension String { + func deletingPrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift new file mode 100644 index 00000000..a292c4f2 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift @@ -0,0 +1,25 @@ +import Flutter + +class MapLibreMapFactory: NSObject, FlutterPlatformViewFactory { + var registrar: FlutterPluginRegistrar + + init(withRegistrar registrar: FlutterPluginRegistrar) { + self.registrar = registrar + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, + arguments args: Any?) -> FlutterPlatformView + { + return MapLibreMapController( + withFrame: frame, + viewIdentifier: viewId, + arguments: args, + registrar: registrar + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift new file mode 100644 index 00000000..4625b127 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift @@ -0,0 +1,23 @@ +import MapLibre + +protocol MapLibreMapOptionsSink { + func setCameraTargetBounds(bounds: MLNCoordinateBounds?) + func setCompassEnabled(compassEnabled: Bool) + func setStyleString(styleString: String) + func setMinMaxZoomPreference(min: Double?, max: Double?) + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) + func setTrackCameraPosition(trackCameraPosition: Bool) + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) + func setMyLocationEnabled(myLocationEnabled: Bool) + func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) + func setLogoEnabled(logoEnabled: Bool) + func setLogoViewPosition(position: MLNOrnamentPosition) + func setLogoViewMargins(x: Double, y: Double) + func setCompassViewPosition(position: MLNOrnamentPosition) + func setCompassViewMargins(x: Double, y: Double) + func setAttributionButtonMargins(x: Double, y: Double) + func setAttributionButtonPosition(position: MLNOrnamentPosition) +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift new file mode 100644 index 00000000..802e4790 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift @@ -0,0 +1,170 @@ +import Flutter +import Foundation +import MapLibre +import UIKit + +public class MapLibreMapsPlugin: NSObject, FlutterPlugin { + static var downloadOfflineRegionChannelHandler: OfflineChannelHandler? = nil + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = MapLibreMapFactory(withRegistrar: registrar) + registrar.register(instance, withId: "plugins.flutter.io/maplibre_gl") + + let channel = FlutterMethodChannel( + name: "plugins.flutter.io/maplibre_gl", + binaryMessenger: registrar.messenger() + ) + + channel.setMethodCallHandler { methodCall, result in + switch methodCall.method { + case "setHttpHeaders": + guard let arguments = methodCall.arguments as? [String: Any], + let headers = arguments["headers"] as? [String: String] + else { + result(FlutterError( + code: "setHttpHeadersError", + message: "could not decode arguments", + details: nil + )) + result(nil) + return + } + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers // your headers here + MLNNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + result(nil) + case "installOfflineMapTiles": + guard let arguments = methodCall.arguments as? [String: String] else { return } + let tilesdb = arguments["tilesdb"] + installOfflineMapTiles(registrar: registrar, tilesdb: tilesdb!) + result(nil) + case "downloadOfflineRegion#setup": + guard let args = methodCall.arguments as? [String: Any], + let channelName = args["channelName"] as? String + else { + print( + "downloadOfflineRegion#setup unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + + downloadOfflineRegionChannelHandler = OfflineChannelHandler( + messenger: registrar.messenger(), + channelName: channelName + ) + + result(nil) + case "downloadOfflineRegion": + // Get download region arguments from caller + guard let args = methodCall.arguments as? [String: Any], + let definitionDictionary = args["definition"] as? [String: Any], + let metadata = args["metadata"] as? [String: Any], + let defintion = OfflineRegionDefinition.fromDictionary(definitionDictionary) + else { + print( + "downloadOfflineRegion unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + + if (downloadOfflineRegionChannelHandler == nil) { + result(FlutterError( + code: "downloadOfflineRegion#setup NOT CALLED", + message: "The setup has not been called, please call downloadOfflineRegion#setup before", + details: nil + )) + return + } + + OfflineManagerUtils.downloadRegion( + definition: defintion, + metadata: metadata, + result: result, + registrar: registrar, + channelHandler: downloadOfflineRegionChannelHandler! + ) + downloadOfflineRegionChannelHandler = nil; + case "setOfflineTileCountLimit": + guard let arguments = methodCall.arguments as? [String: Any], + let limit = arguments["limit"] as? UInt64 + else { + result(FlutterError( + code: "SetOfflineTileCountLimitError", + message: "could not decode arguments", + details: nil + )) + return + } + OfflineManagerUtils.setOfflineTileCountLimit(result: result, maximumCount: limit) + case "getListOfRegions": + // Note: this does not download anything from internet, it only fetches data drom database + OfflineManagerUtils.regionsList(result: result) + case "deleteOfflineRegion": + guard let args = methodCall.arguments as? [String: Any], + let id = args["id"] as? Int + else { + result(nil) + return + } + OfflineManagerUtils.deleteRegion(result: result, id: id) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func getTilesUrl() -> URL { + guard var cachesUrl = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first, + let bundleId = Bundle.main + .object(forInfoDictionaryKey: kCFBundleIdentifierKey as String) as? String + else { + fatalError("Could not get map tiles directory") + } + cachesUrl.appendPathComponent(bundleId) + cachesUrl.appendPathComponent(".mapbox") + cachesUrl.appendPathComponent("cache.db") + return cachesUrl + } + + // Copies the "offline" tiles to where MapLibre expects them + private static func installOfflineMapTiles(registrar: FlutterPluginRegistrar, tilesdb: String) { + var tilesUrl = getTilesUrl() + let bundlePath = getTilesDbPath(registrar: registrar, tilesdb: tilesdb) + NSLog( + "Cached tiles not found, copying from bundle... \(String(describing: bundlePath)) ==> \(tilesUrl)" + ) + do { + let parentDir = tilesUrl.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parentDir, + withIntermediateDirectories: true, + attributes: nil + ) + if FileManager.default.fileExists(atPath: tilesUrl.path) { + try FileManager.default.removeItem(atPath: tilesUrl.path) + } + try FileManager.default.copyItem(atPath: bundlePath!, toPath: tilesUrl.path) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try tilesUrl.setResourceValues(resourceValues) + } catch { + NSLog("Error copying bundled tiles: \(error)") + } + } + + private static func getTilesDbPath(registrar: FlutterPluginRegistrar, + tilesdb: String) -> String? + { + if tilesdb.starts(with: "/") { + return tilesdb + } else { + let key = registrar.lookupKey(forAsset: tilesdb) + return Bundle.main.path(forResource: key, ofType: nil) + } + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift new file mode 100644 index 00000000..d97ad1d8 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift @@ -0,0 +1,98 @@ +import Flutter + +enum MethodCallError: Error { + case genericError(details: String) + case invalidLayerType(details: String) + case invalidSourceType(details: String) + case invalidExpression + case sourceNotFound(sourceId: String) + case layerNotFound(layerId: String) + case styleNotFound + case sourceAlreadyExists(sourceId: String) + case layerAlreadyExists(layerId: String) + case geojsonParseError(sourceId: String) + + var code: String { + switch self { + case .genericError: + return "genericError" + case .invalidLayerType: + return "invalidLayerType" + case .invalidSourceType: + return "invalidSourceType" + case .invalidExpression: + return "invalidExpression" + case .sourceNotFound: + return "sourceNotFound" + case .layerNotFound: + return "layerNotFound" + case .styleNotFound: + return "styleNotFound" + case .sourceAlreadyExists: + return "sourceAlreadyExists" + case .layerAlreadyExists: + return "layerAlreadyExists" + case .geojsonParseError: + return "parseError" + + } + } + + var message: String { + switch self { + case .genericError: + return "Generic error" + case .invalidLayerType: + return "Invalid layer type" + case .invalidSourceType: + return "Invalid source type" + case .invalidExpression: + return "Invalid expression" + case .sourceNotFound: + return "Source not found" + case .layerNotFound: + return "Layer not found" + case .styleNotFound: + return "Style not found" + case .sourceAlreadyExists: + return "Source already exists" + case .layerAlreadyExists: + return "Layer already exists" + case .geojsonParseError: + return "Geojson parse error" + } + } + + var details: String { + switch self { + case let .genericError(details): + return details + case let .invalidLayerType(details): + return details + case let .invalidSourceType(details): + return details + case .invalidExpression: + return "Could not parse expression." + case let .sourceNotFound(sourceId): + return "Source with id \(sourceId) not found." + case let .layerNotFound(layerId): + return "Layer with id \(layerId) not found." + case .styleNotFound: + return "Style not found." + case let .sourceAlreadyExists(sourceId): + return "Source with id \(sourceId) already exists." + case let .layerAlreadyExists(layerId): + return "Layer with id \(layerId) already exists." + case let .geojsonParseError(sourceId): + return "Geojson parse error for source with id \(sourceId)." + } + } + + var flutterError: FlutterError { + return FlutterError( + code: code, + message: message, + details: details + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift new file mode 100644 index 00000000..b5f47466 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift @@ -0,0 +1,69 @@ +// +// OfflineChannelHandler.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation + +class OfflineChannelHandler: NSObject, FlutterStreamHandler { + private var sink: FlutterEventSink? + + init(messenger: FlutterBinaryMessenger, channelName: String) { + super.init() + let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger) + eventChannel.setStreamHandler(self) + } + + // MARK: FlutterStreamHandler protocol compliance + + func onListen(withArguments _: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments _: Any?) -> FlutterError? { + sink = nil + return nil + } + + // MARK: Util methods + + func onError(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink?(FlutterError(code: errorCode, message: errorMessage, details: errorDetails)) + } + + func onSuccess() { + let body = ["status": "success"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onStart() { + let body = ["status": "start"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onProgress(progress: Double) { + let body: [String: Any] = ["status": "progress", "progress": progress] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift new file mode 100644 index 00000000..4a725d74 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift @@ -0,0 +1,101 @@ +// +// OfflineManagerUtils.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Flutter +import Foundation +import MapLibre + +class OfflineManagerUtils { + static var activeDownloaders: [Int: OfflinePackDownloader] = [:] + + static func downloadRegion( + definition: OfflineRegionDefinition, + metadata: [String: Any], + result: @escaping FlutterResult, + registrar _: FlutterPluginRegistrar, + channelHandler: OfflineChannelHandler + ) { + // Prepare downloader + let downloader = OfflinePackDownloader( + result: result, + channelHandler: channelHandler, + regionDefintion: definition, + metadata: metadata + ) + + // Download region + let id = downloader.download() + // retain downloader by its generated id + activeDownloaders[id] = downloader + } + + static func regionsList(result: @escaping FlutterResult) { + let offlineStorage = MLNOfflineStorage.shared + guard let packs = offlineStorage.packs else { + result("[]") + return + } + let regionsArgs = packs.compactMap { pack in + OfflineRegion.fromOfflinePack(pack)?.toDictionary() + } + guard let regionsArgsJsonData = try? JSONSerialization.data(withJSONObject: regionsArgs), + let regionsArgsJsonString = String(data: regionsArgsJsonData, encoding: .utf8) + else { + result(FlutterError(code: "RegionListError", message: nil, details: nil)) + return + } + result(regionsArgsJsonString) + } + + static func setOfflineTileCountLimit(result: @escaping FlutterResult, maximumCount: UInt64) { + let offlineStorage = MLNOfflineStorage.shared + offlineStorage.setMaximumAllowedMapboxTiles(maximumCount) + result(nil) + } + + static func deleteRegion(result: @escaping FlutterResult, id: Int) { + let offlineStorage = MLNOfflineStorage.shared + guard let pacs = offlineStorage.packs else { return } + let packToRemove = pacs.first(where: { pack -> Bool in + let contextJsonObject = try? JSONSerialization.jsonObject(with: pack.context) + let contextJsonDict = contextJsonObject as? [String: Any] + if let regionId = contextJsonDict?["id"] as? Int { + return regionId == id + } else { + return false + } + }) + if let packToRemoveUnwrapped = packToRemove { + // deletion is only safe if the download is suspended + packToRemoveUnwrapped.suspend() + OfflineManagerUtils.releaseDownloader(id: id) + + offlineStorage.removePack(packToRemoveUnwrapped) { error in + if let error = error { + result(FlutterError( + code: "DeleteRegionError", + message: error.localizedDescription, + details: nil + )) + } else { + result(nil) + } + } + } else { + result(FlutterError( + code: "DeleteRegionError", + message: "There is no region with given id to delete", + details: nil + )) + } + } + + /// Removes downloader from cache so it's memory can be deallocated + static func releaseDownloader(id: Int) { + activeDownloaders.removeValue(forKey: id) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift new file mode 100644 index 00000000..350dd3fb --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift @@ -0,0 +1,223 @@ +// +// OfflinePackDownloadManager.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation +import MapLibre + +class OfflinePackDownloader { + // MARK: Properties + + private let result: FlutterResult + private let channelHandler: OfflineChannelHandler + private let regionDefinition: OfflineRegionDefinition + private let metadata: [String: Any] + + /// Currently managed pack + private var pack: MLNOfflinePack? + + /// This variable is set to true when this downloader has finished downloading and called the result method. It is used to prevent + /// the result method being called multiple times + private var isCompleted = false + + // MARK: Initializers + + init( + result: @escaping FlutterResult, + channelHandler: OfflineChannelHandler, + regionDefintion: OfflineRegionDefinition, + metadata: [String: Any] + ) { + self.result = result + self.channelHandler = channelHandler + regionDefinition = regionDefintion + self.metadata = metadata + + setupNotifications() + } + + deinit { + print("Removing offline pack notification observers") + NotificationCenter.default.removeObserver(self) + } + + // MARK: Public methods + + func download() -> Int { + let storage = MLNOfflineStorage.shared + // While the Android SDK generates a region ID in createOfflineRegion, the iOS + // SDK does not have this feature. Therefore, we generate a region ID here. + let id = UUID().hashValue + let regionData = OfflineRegion(id: id, metadata: metadata, definition: regionDefinition) + let tilePyramidRegion = regionDefinition.toMLNTilePyramidOfflineRegion() + storage + .addPack(for: tilePyramidRegion, + withContext: regionData.prepareContext()) { [weak self] pack, error in + if let pack = pack { + self?.onPackCreated(pack: pack) + } else { + self?.onPackCreationError(error: error) + } + } + return id + } + + // MARK: Pack management + + private func onPackCreated(pack: MLNOfflinePack) { + if let region = OfflineRegion.fromOfflinePack(pack), + let regionData = try? JSONSerialization.data(withJSONObject: region.toDictionary()) + { + // Start downloading + self.pack = pack + pack.resume() + // Provide region with generated + result(String(data: regionData, encoding: .utf8)) + channelHandler.onStart() + } else { + onPackCreationError(error: OfflinePackError.InvalidPackData) + } + } + + private func onPackCreationError(error: Error?) { + // Reset downloading state + channelHandler.onError( + errorCode: "mapboxInvalidRegionDefinition", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "mapboxInvalidRegionDefinition", + message: error?.localizedDescription, + details: nil + )) + } + + // MARK: Progress obseration + + @objc private func onPackDownloadProgress(notification: NSNotification) { + // Verify if correct pack is checked + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + // Calculate progress of downloading + let packProgress = pack.progress + let downloadProgress = calculateDownloadingProgress( + requiredResourceCount: packProgress.countOfResourcesExpected, + completedResourceCount: packProgress.countOfResourcesCompleted + ) + // Check if downloading is complete + if pack.state == .complete { + print("Region downloaded successfully") + // set download state to inactive + // This can be called multiple times but result can only be called once. We use this + // check to ensure that + guard !isCompleted else { return } + isCompleted = true + channelHandler.onSuccess() + result(nil) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.releaseDownloader(id: region.id) + } + } else { + print("Region download progress \(downloadProgress)") + channelHandler.onProgress(progress: downloadProgress) + } + } + + @objc private func onPackDownloadError(notification: NSNotification) { + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError + print("Pack download error: \(String(describing: error?.localizedDescription))") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "Downloading error", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "Downloading error", + message: error?.localizedDescription, + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + @objc private func onMaximumAllowedMapboxTiles(notification: NSNotification) { + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + let maximumCount = (notification.userInfo?[MLNOfflinePackUserInfoKey.maximumCount] + as AnyObject).uint64Value ?? 0 + print("MapLibre tile count limit exceeded: \(maximumCount)") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "mapboxTileCountLimitExceeded", + errorMessage: "MapLibre tile count limit exceeded: \(maximumCount)", + errorDetails: nil + ) + result(FlutterError( + code: "mapboxTileCountLimitExceeded", + message: "MapLibre tile count limit exceeded: \(maximumCount)", + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + // MARK: Util methods + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadProgress(notification:)), + name: NSNotification.Name.MLNOfflinePackProgressChanged, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadError(notification:)), + name: NSNotification.Name.MLNOfflinePackError, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onMaximumAllowedMapboxTiles(notification:)), + name: NSNotification.Name.MLNOfflinePackMaximumMapboxTilesReached, + object: nil + ) + } + + /// Since NotificationCenter will send notifications about all packs downloads we need to make sure we only handle packs + /// managed by this downloader. So this method checks if the pack we got from a notification is the same as the pack being + /// managed by this downloader and if it is it returns true. Otherwise it returns false + private func verifyPack(pack: MLNOfflinePack) -> Bool { + guard let currentlyManagedPack = self.pack else { + // No pack is being managed yet + return false + } + // We can tell whether 2 packs are the same by comparing metadata we assigned earlier + return pack.state != .invalid && pack.context == currentlyManagedPack.context + } + + private func calculateDownloadingProgress( + requiredResourceCount: UInt64, + completedResourceCount: UInt64 + ) -> Double { + return requiredResourceCount > 0 + ? 100.0 * Double(completedResourceCount) / Double(requiredResourceCount) + : 0.0 + } +} + +enum OfflinePackError: Error { + case InvalidPackData +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift new file mode 100644 index 00000000..1181366e --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift @@ -0,0 +1,57 @@ +// +// OfflineRegionData.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Foundation +import MapLibre + +class OfflineRegion { + let id: Int + let metadata: [String: Any] + let definition: OfflineRegionDefinition + + enum CodingKeys: CodingKey { + case id, metadata, definition + } + + init(id: Int, metadata: [String: Any], definition: OfflineRegionDefinition) { + self.id = id + self.metadata = metadata + self.definition = definition + } + + func prepareContext() -> Data { + let context = ["metadata": metadata, "id": id] as [String: Any] + let jsonData = try? JSONSerialization.data(withJSONObject: context, options: []) + return jsonData ?? Data() + } + + func toDictionary() -> [String: Any] { + return [ + "id": id, + "metadata": metadata, + "definition": definition.toDictionary(), + ] + } + + static func fromOfflinePack(_ pack: MLNOfflinePack) -> OfflineRegion? { + guard let region = pack.region as? MLNTilePyramidOfflineRegion, + let dataObject = try? JSONSerialization.jsonObject(with: pack.context, options: []), + let dict = dataObject as? [String: Any], + let id = dict["id"] as? Int, + let metadata = dict["metadata"] as? [String: Any] else { return nil } + return OfflineRegion( + id: id, + metadata: metadata, + definition: OfflineRegionDefinition( + bounds: [region.bounds.sw, region.bounds.ne].map { [$0.latitude, $0.longitude] }, + mapStyleUrl: region.styleURL, + minZoom: region.minimumZoomLevel, + maxZoom: region.maximumZoomLevel + ) + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift new file mode 100644 index 00000000..9e57cde8 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift @@ -0,0 +1,56 @@ +import Foundation +import MapLibre + +class OfflineRegionDefinition { + let bounds: [[Double]] + let mapStyleUrl: URL + let minZoom: Double + let maxZoom: Double + + init(bounds: [[Double]], mapStyleUrl: URL, minZoom: Double, maxZoom: Double) { + self.bounds = bounds + self.mapStyleUrl = mapStyleUrl + self.minZoom = minZoom + self.maxZoom = maxZoom + } + + func getBounds() -> MLNCoordinateBounds { + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: bounds[0][0], longitude: bounds[0][1]), + ne: CLLocationCoordinate2D(latitude: bounds[1][0], longitude: bounds[1][1]) + ) + } + + static func fromDictionary(_ jsonDict: [String: Any]) -> OfflineRegionDefinition? { + guard let bounds = jsonDict["bounds"] as? [[Double]], + let mapStyleUrlString = jsonDict["mapStyleUrl"] as? String, + let mapStyleUrl = URL(string: mapStyleUrlString), + let minZoom = jsonDict["minZoom"] as? Double, + let maxZoom = jsonDict["maxZoom"] as? Double + else { return nil } + return OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: mapStyleUrl, + minZoom: minZoom, + maxZoom: maxZoom + ) + } + + func toDictionary() -> [String: Any] { + return [ + "bounds": bounds, + "mapStyleUrl": mapStyleUrl.absoluteString, + "minZoom": minZoom, + "maxZoom": maxZoom, + ] + } + + func toMLNTilePyramidOfflineRegion() -> MLNTilePyramidOfflineRegion { + return MLNTilePyramidOfflineRegion( + styleURL: mapStyleUrl, + bounds: getBounds(), + fromZoomLevel: minZoom, + toZoomLevel: maxZoom + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift new file mode 100644 index 00000000..a4c5ec9f --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift @@ -0,0 +1,241 @@ +import Foundation +import MapLibre + +class SourcePropertyConverter { + class func interpretTileOptions(properties: [String: Any]) -> [MLNTileSourceOption: Any] { + var options = [MLNTileSourceOption: Any]() + + if let bounds = properties["bounds"] as? [Double] { + options[.coordinateBounds] = + NSValue(mlnCoordinateBounds: boundsFromArray(coordinates: bounds)) + } + if let minzoom = properties["minzoom"] as? Double { + options[.minimumZoomLevel] = minzoom + } + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + if let tileSize = properties["tileSize"] as? Double { + options[.tileSize] = Int(tileSize) + } + if let scheme = properties["scheme"] as? String { + let system: MLNTileCoordinateSystem = (scheme == "tms" ? .TMS : .XYZ) + options[.tileCoordinateSystem] = system.rawValue + } + if let attribution = properties["attribution"] as? String { + options[.attributionInfos] = parseAttributionHTML(attribution) + } + return options + } + + class func parseAttributionHTML(_ html: String) -> [MLNAttributionInfo] { + // Parse HTML attribution string and create a single attributed string with clickable links + let pattern = #"]*>([^<]+)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + // Regex failed, use plain text + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + let nsString = html as NSString + let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) + + if matches.isEmpty { + // No links found, create simple attribution + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + // Build a single attributed string with all text and apply link attributes + let attributedString = NSMutableAttributedString() + var lastLocation = 0 + var primaryURL: URL? + + for match in matches { + // Add text before the link + if match.range.location > lastLocation { + let plainRange = NSRange(location: lastLocation, length: match.range.location - lastLocation) + let plainText = nsString.substring(with: plainRange) + attributedString.append(NSAttributedString(string: plainText)) + } + + // Add the link text with link attribute + if match.numberOfRanges >= 3 { + let urlRange = match.range(at: 1) + let textRange = match.range(at: 2) + + let urlString = nsString.substring(with: urlRange) + let linkText = nsString.substring(with: textRange) + + if let url = URL(string: urlString) { + if primaryURL == nil { + primaryURL = url + } + let linkAttributedString = NSMutableAttributedString(string: linkText) + linkAttributedString.addAttribute(.link, value: url, range: NSRange(location: 0, length: linkText.count)) + attributedString.append(linkAttributedString) + } + } + + lastLocation = match.range.location + match.range.length + } + + // Add any remaining text after the last link + if lastLocation < nsString.length { + let remainingText = nsString.substring(from: lastLocation) + attributedString.append(NSAttributedString(string: remainingText)) + } + + return [MLNAttributionInfo(title: attributedString, url: primaryURL)] + } + + class func buildRasterTileSource(identifier: String, + properties: [String: Any]) -> MLNRasterTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNRasterTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + let options = interpretTileOptions(properties: properties) + return MLNRasterTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: options + ) + } + return nil + } + + class func buildVectorTileSource(identifier: String, + properties: [String: Any]) -> MLNVectorTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNVectorTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return MLNVectorTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func buildRasterDemSource(identifier: String, + properties: [String: Any]) -> MLNRasterDEMSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNRasterDEMSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return MLNRasterDEMSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func interpretShapeOptions(properties: [String: Any]) -> [MLNShapeSourceOption: Any] { + var options = [MLNShapeSourceOption: Any]() + + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + + if let buffer = properties["buffer"] as? Double { + options[.buffer] = buffer + } + if let tolerance = properties["tolerance"] as? Double { + options[.simplificationTolerance] = tolerance + } + + if let cluster = properties["cluster"] as? Bool { + options[.clustered] = cluster + } + if let clusterRadius = properties["clusterRadius"] as? Double { + options[.clusterRadius] = clusterRadius + } + if let clusterMaxZoom = properties["clusterMaxZoom"] as? Double { + options[.maximumZoomLevelForClustering] = clusterMaxZoom + } + + // TODO: clusterProperties not implemneted for IOS + + if let lineMetrics = properties["lineMetrics"] as? Bool { + options[.lineDistanceMetrics] = lineMetrics + } + return options + } + + class func buildShapeSource(identifier: String, properties: [String: Any]) -> MLNShapeSource? { + let options = interpretShapeOptions(properties: properties) + if let data = properties["data"] as? String, let url = URL(string: data) { + return MLNShapeSource(identifier: identifier, url: url, options: options) + } + if let data = properties["data"] { + do { + let geoJsonData = try JSONSerialization.data(withJSONObject: data) + let shape = try MLNShape(data: geoJsonData, encoding: String.Encoding.utf8.rawValue) + return MLNShapeSource(identifier: identifier, shape: shape, options: options) + } catch {} + } + return nil + } + + class func buildImageSource(identifier: String, properties: [String: Any]) -> MLNImageSource? { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl), + let coordinates = properties["coordinates"] as? [[Double]] + { + return MLNImageSource( + identifier: identifier, + coordinateQuad: quadFromArray(coordinates: coordinates), + url: url + ) + } + return nil + } + + class func addShapeProperties(properties: [String: Any], source: MLNShapeSource) { + do { + if let data = properties["data"] as? String { + let parsed = try MLNShape( + data: data.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + source.shape = parsed + } + } catch {} + } + + class func quadFromArray(coordinates: [[Double]]) -> MLNCoordinateQuad { + return MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][1], + longitude: coordinates[0][0] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][1], + longitude: coordinates[3][0] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][1], + longitude: coordinates[2][0] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][1], + longitude: coordinates[1][0] + ) + ) + } + + class func boundsFromArray(coordinates: [Double]) -> MLNCoordinateBounds { + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: coordinates[1], longitude: coordinates[0]), + ne: CLLocationCoordinate2D(latitude: coordinates[3], longitude: coordinates[2]) + ) + } +} diff --git a/third_party/maplibre_gl/lib/maplibre_gl.dart b/third_party/maplibre_gl/lib/maplibre_gl.dart new file mode 100644 index 00000000..f7e93044 --- /dev/null +++ b/third_party/maplibre_gl/lib/maplibre_gl.dart @@ -0,0 +1,109 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This library contains the MapLibre GL plugin for Flutter. +/// +/// To display a map, add a [MapLibreMap] widget to the widget tree. +/// +/// In this plugin, the map is configured through the parameters passed to the [MapLibreMap] constructor and through the [MapLibreMapController]. +/// The [MapLibreMapController] is provided at runtime by the [MapLibreMap.onMapCreated] callback. +/// The controller also allows adding annotations (icons, lines etc.) to the map at runtime and provides some callbacks to get notified when the user clicks those. +/// +/// The visual appearance of the map is configured through a MapLibre style passed to the +/// [styleString] parameter of the [MapLibreMap] constructor. +/// The MapLibre style is a JSON document following the specification at https://maplibre.org/maplibre-style-spec/. +/// The following is supposed to serve as a short introduction to the MapLibre style specification: +/// The style document contains (among other things) sources and layers. +/// Sources determine which data is displayed on the map, layers determine how the data is displayed. +/// +/// Typical types of sources are raster and vector tiles, as well as GeoJson data. +/// For raster and vector tiles, the entire world is divided into a set of tiles in different zoom levels. +/// Depending on the map's zoom level and viewport, the MapLibre client libraries decide, which tiles are needed to fill the viewport and request them from the source. +/// +/// The difference between raster and vector tiles is that raster tiles are images that are pre-rendered on a server, whereas vector tiles contain raw geometric information that is rendered on the client. +/// Vector tiles are in the Mapbox Vector Tile (MVT) format, the de-facto standard for vector tiles that is implemented by multiple libraries. +/// +/// Vector tiles have a number of advantages over raster tiles, including (often) smaller size, +/// the possibility to style them dynamically at runtime (e.g. change the color or visibility of certain features), +/// and the possibility to rotate them and keep text labels horizontal. +/// Raster and vector tiles can be generated from a variety of sources, including OpenStreetMap data and are also available from a number of providers. +/// +/// Raster sources are displayed by adding a "raster" layer to the MapLibre GL style. +/// Vector and GeoJson sources are displayed by adding a "line", "fill", "symbol" or "circle" layer to the MapLibre GL style and specifying +/// which source to use (by setting the "source" property of the layer to the id of the source) as well as how to style the data by setting other properties of the layer such as "line-color" or "fill-outline-color". +/// For example, a vector source layer (or a GeoJson source layer) with the outlines of countries could be displayed both by a fill layer to fill the countries with a color and by a line layer to draw the outlines of the countries. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart'; + +export 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart' + show + Annotation, + ArgumentCallbacks, + AttributionButtonPosition, + CameraPosition, + CameraTargetBounds, + CameraUpdate, + Circle, + CircleOptions, + CompassViewPosition, + Fill, + FillOptions, + GeojsonSourceProperties, + ImageSourceProperties, + LatLng, + LatLngBounds, + LatLngQuad, + Line, + LineOptions, + LocationEngineAndroidProperties, + LocationEnginePlatforms, + LocationPriority, + LogoViewPosition, + MapLibreMethodChannel, + MapLibrePlatform, + MinMaxZoomPreference, + MyLocationRenderMode, + MyLocationTrackingMode, + OnPlatformViewCreatedCallback, + RasterDemSourceProperties, + RasterSourceProperties, + SourceProperties, + Symbol, + SymbolOptions, + UserHeading, + UserLocation, + VectorSourceProperties, + VideoSourceProperties; + +part 'src/controller.dart'; + +part 'src/maplibre_map.dart'; + +part 'src/global.dart'; + +part 'src/offline_region.dart'; + +part 'src/download_region_status.dart'; + +part 'src/layer_expressions.dart'; + +part 'src/layer_properties.dart'; + +part 'src/color_tools.dart'; + +part 'src/annotation_manager.dart'; + +part 'src/util.dart'; + +part 'src/maplibre_styles.dart'; diff --git a/third_party/maplibre_gl/lib/src/annotation_manager.dart b/third_party/maplibre_gl/lib/src/annotation_manager.dart new file mode 100644 index 00000000..8ab46870 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/annotation_manager.dart @@ -0,0 +1,376 @@ +part of '../maplibre_gl.dart'; + +/// Manages a homogeneous set of [Annotation]s (e.g. symbols, lines, fills) by +/// owning their backing style source(s)/layer(s) and performing efficient +/// batched updates. +/// +/// The [initialize] method must be called before [AnnotationManager] instance +/// can be used. Once [AnnotationManager] is initialized, the [isInitialized] +/// getter will return true. +/// +/// An [AnnotationManager] keeps an internal mapping from annotation id to its +/// model object and mirrors the collection into one or more GeoJSON sources; +/// each source is bound to a style layer whose visual properties come from +/// [allLayerProperties]. +/// +/// When [enableInteraction] is true, drag events are listened to and the +/// underlying annotation is translated & re-set. +abstract class AnnotationManager { + final MapLibreMapController controller; + + bool _isInitializing = false; + bool _isInitialized = false; + final _idToAnnotation = {}; + final _idToLayerIndex = {}; + + /// Base identifier of the manager. Use [layerIds] for concrete layer ids. + final String id; + + /// Tracks whether the manager and its layers were initialized. + bool get isInitialized => _isInitialized; + + List get layerIds => + [for (int i = 0; i < allLayerProperties.length; i++) _makeLayerId(i)]; + + /// If false, the manager disables user interaction (e.g. dragging) for + /// its annotations. + final bool enableInteraction; + + /// Layer property definitions (one per backing style layer). Override in + /// subclasses to specify visual styling for data-driven attributes. + List get allLayerProperties; + + /// Optional function used to select which layer/source a given annotation + /// should live in (e.g. pattern vs non-pattern lines). If null, a single + /// layer/source is used. + final int Function(T)? selectLayer; + + /// Returns the annotation with the given [id], or null if not found. + T? byId(String id) => _idToAnnotation[id]; + + /// Current set of managed annotations. + Set get annotations => _idToAnnotation.values.toSet(); + + AnnotationManager( + this.controller, { + this.selectLayer, + required this.enableInteraction, + }) : id = getRandomString(); + + @mustCallSuper + Future initialize() async { + if (_isInitializing || _isInitialized) { + return; + } + + // Mark initialization process start, so that it cannot be entered again + _isInitializing = true; + + try { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + + await controller.addGeoJsonSource( + layerId, + buildFeatureCollection([]), + promoteId: "id", + ); + await controller.addLayer( + layerId, + layerId, + allLayerProperties[i], + enableInteraction: enableInteraction, + ); + } + + controller.onFeatureDrag.add(_onDrag); + + // Mark as initialized + _isInitialized = true; + } finally { + // Mark initialization process end + _isInitializing = false; + } + } + + /// Rebuilds all backing style layers (e.g. after overlap settings changed). + Future _rebuildLayers() async { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + await controller.removeLayer(layerId); + await controller.addLayer(layerId, layerId, allLayerProperties[i], + enableInteraction: enableInteraction); + } + } + + String _makeLayerId(int layerIndex) => "${id}_$layerIndex"; + + Future _setAll() async { + if (selectLayer != null) { + final featureBuckets = [for (final _ in allLayerProperties) []]; + + for (final annotation in _idToAnnotation.values) { + final layerIndex = selectLayer!(annotation); + _idToLayerIndex[annotation.id] = layerIndex; + featureBuckets[layerIndex].add(annotation); + } + + for (var i = 0; i < featureBuckets.length; i++) { + await controller.setGeoJsonSource( + _makeLayerId(i), + buildFeatureCollection( + [for (final l in featureBuckets[i]) l.toGeoJson()])); + } + } else { + await controller.setGeoJsonSource( + _makeLayerId(0), + buildFeatureCollection( + [for (final l in _idToAnnotation.values) l.toGeoJson()])); + } + } + + /// Adds multiple annotations (faster than adding one-by-one). + Future addAll(Iterable annotations) async { + for (final a in annotations) { + _idToAnnotation[a.id] = a; + } + await _setAll(); + } + + /// Adds a single annotation. + Future add(T annotation) async { + _idToAnnotation[annotation.id] = annotation; + await _setAll(); + } + + /// Removes multiple annotations. + Future removeAll(Iterable annotations) async { + for (final a in annotations) { + _idToAnnotation.remove(a.id); + } + await _setAll(); + } + + /// Removes a single annotation. + Future remove(T annotation) async { + _idToAnnotation.remove(annotation.id); + await _setAll(); + } + + /// Removes all annotations. + Future clear() async { + _idToAnnotation.clear(); + await _setAll(); + } + + /// Fully dispose resources (layers & sources). Manager is unusable after. + Future dispose() async { + _idToAnnotation.clear(); + await _setAll(); + for (var i = 0; i < allLayerProperties.length; i++) { + await controller.removeLayer(_makeLayerId(i)); + await controller.removeSource(_makeLayerId(i)); + } + } + + Future _onDrag( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, + ) async { + if (annotation is T) { + annotation.translate(delta); + await set(annotation); + } + } + + /// Updates (re-sets) an existing annotation quickly by only replacing its + /// underlying GeoJSON feature if it remains on the same logical layer. + Future set(T annotation) async { + assert(_idToAnnotation.containsKey(annotation.id), + "you can only set existing annotations"); + _idToAnnotation[annotation.id] = annotation; + final oldLayerIndex = _idToLayerIndex[annotation.id]; + final layerIndex = selectLayer != null ? selectLayer!(annotation) : 0; + if (oldLayerIndex != layerIndex) { + // Layer changed; must rewrite all sources. + await _setAll(); + } else { + await controller.setGeoJsonFeature( + _makeLayerId(layerIndex), annotation.toGeoJson()); + } + } +} + +class LineManager extends AnnotationManager { + LineManager( + super.controller, { + super.enableInteraction = true, + }) : super( + selectLayer: (Line line) => line.options.linePattern == null ? 0 : 1, + ); + + static const _baseProperties = LineLayerProperties( + lineJoin: [Expressions.get, 'lineJoin'], + lineOpacity: [Expressions.get, 'lineOpacity'], + lineColor: [Expressions.get, 'lineColor'], + lineWidth: [Expressions.get, 'lineWidth'], + lineGapWidth: [Expressions.get, 'lineGapWidth'], + lineOffset: [Expressions.get, 'lineOffset'], + lineBlur: [Expressions.get, 'lineBlur'], + ); + + @override + List get allLayerProperties => [ + _baseProperties, + _baseProperties.copyWith(const LineLayerProperties( + linePattern: [Expressions.get, 'linePattern'])), + ]; +} + +class FillManager extends AnnotationManager { + FillManager( + super.controller, { + super.enableInteraction = true, + }) : super( + selectLayer: (Fill fill) => fill.options.fillPattern == null ? 0 : 1, + ); + + @override + List get allLayerProperties => const [ + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + ), + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + fillPattern: [Expressions.get, 'fillPattern'], + ) + ]; +} + +class CircleManager extends AnnotationManager { + CircleManager( + super.controller, { + super.enableInteraction = true, + }); + + @override + List get allLayerProperties => const [ + CircleLayerProperties( + circleRadius: [Expressions.get, 'circleRadius'], + circleColor: [Expressions.get, 'circleColor'], + circleBlur: [Expressions.get, 'circleBlur'], + circleOpacity: [Expressions.get, 'circleOpacity'], + circleStrokeWidth: [Expressions.get, 'circleStrokeWidth'], + circleStrokeColor: [Expressions.get, 'circleStrokeColor'], + circleStrokeOpacity: [Expressions.get, 'circleStrokeOpacity'], + ) + ]; +} + +class SymbolManager extends AnnotationManager { + SymbolManager( + super.controller, { + bool iconAllowOverlap = false, + bool textAllowOverlap = false, + bool iconIgnorePlacement = false, + bool textIgnorePlacement = false, + super.enableInteraction = true, + }) : _iconAllowOverlap = iconAllowOverlap, + _textAllowOverlap = textAllowOverlap, + _iconIgnorePlacement = iconIgnorePlacement, + _textIgnorePlacement = textIgnorePlacement; + + bool _iconAllowOverlap; + bool _textAllowOverlap; + bool _iconIgnorePlacement; + bool _textIgnorePlacement; + + /// If true, the icon will be visible even if it collides with other previously drawn symbols. + Future setIconAllowOverlap(bool value) async { + if (value == _iconAllowOverlap) return; + + _iconAllowOverlap = value; + await _rebuildLayers(); + } + + /// If true, other symbols can be visible even if they collide with the icon. + Future setTextAllowOverlap(bool value) async { + if (value == _textAllowOverlap) return; + + _textAllowOverlap = value; + await _rebuildLayers(); + } + + /// If true, the text will be visible even if it collides with other previously drawn symbols. + Future setIconIgnorePlacement(bool value) async { + if (value == _iconIgnorePlacement) return; + + _iconIgnorePlacement = value; + await _rebuildLayers(); + } + + /// If true, other symbols can be visible even if they collide with the text. + Future setTextIgnorePlacement(bool value) async { + if (value == _textIgnorePlacement) return; + + _textIgnorePlacement = value; + await _rebuildLayers(); + } + + @override + List get allLayerProperties => [ + SymbolLayerProperties( + iconSize: [Expressions.get, 'iconSize'], + iconImage: [Expressions.get, 'iconImage'], + iconRotate: [Expressions.get, 'iconRotate'], + iconOffset: [Expressions.get, 'iconOffset'], + iconAnchor: [Expressions.get, 'iconAnchor'], + iconOpacity: [Expressions.get, 'iconOpacity'], + iconColor: [Expressions.get, 'iconColor'], + iconHaloColor: [Expressions.get, 'iconHaloColor'], + iconHaloWidth: [Expressions.get, 'iconHaloWidth'], + iconHaloBlur: [Expressions.get, 'iconHaloBlur'], + // note that web does not support setting this in a fully data driven + // way this is a upstream issue + textFont: kIsWeb + ? null + : [ + Expressions.caseExpression, + [Expressions.has, 'fontNames'], + [Expressions.get, 'fontNames'], + [ + Expressions.literal, + ["Open Sans Regular", "Arial Unicode MS Regular"] + ], + ], + textField: [Expressions.get, 'textField'], + textSize: [Expressions.get, 'textSize'], + textMaxWidth: [Expressions.get, 'textMaxWidth'], + textLetterSpacing: [Expressions.get, 'textLetterSpacing'], + textJustify: [Expressions.get, 'textJustify'], + textAnchor: [Expressions.get, 'textAnchor'], + textRotate: [Expressions.get, 'textRotate'], + textTransform: [Expressions.get, 'textTransform'], + textOffset: [Expressions.get, 'textOffset'], + textOpacity: [Expressions.get, 'textOpacity'], + textColor: [Expressions.get, 'textColor'], + textHaloColor: [Expressions.get, 'textHaloColor'], + textHaloWidth: [Expressions.get, 'textHaloWidth'], + textHaloBlur: [Expressions.get, 'textHaloBlur'], + symbolSortKey: [Expressions.get, 'zIndex'], + iconAllowOverlap: _iconAllowOverlap, + iconIgnorePlacement: _iconIgnorePlacement, + textAllowOverlap: _textAllowOverlap, + textIgnorePlacement: _textIgnorePlacement, + ) + ]; +} diff --git a/third_party/maplibre_gl/lib/src/color_tools.dart b/third_party/maplibre_gl/lib/src/color_tools.dart new file mode 100644 index 00000000..026302cb --- /dev/null +++ b/third_party/maplibre_gl/lib/src/color_tools.dart @@ -0,0 +1,12 @@ +// ignore_for_file: deprecated_member_use --- IGNORE --- + +part of '../maplibre_gl.dart'; + +extension MapLibreColorConversion on Color { + String toHexStringRGB() { + final r = red.toRadixString(16).padLeft(2, '0'); + final g = green.toRadixString(16).padLeft(2, '0'); + final b = blue.toRadixString(16).padLeft(2, '0'); + return '#$r$g$b'; + } +} diff --git a/third_party/maplibre_gl/lib/src/controller.dart b/third_party/maplibre_gl/lib/src/controller.dart new file mode 100644 index 00000000..f511b3cd --- /dev/null +++ b/third_party/maplibre_gl/lib/src/controller.dart @@ -0,0 +1,1721 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +typedef OnMapClickCallback = void Function( + Point point, LatLng coordinates); + +// New generalized feature interaction callback that always provides a raw feature id. +// If the feature also corresponds to a managed annotation, the annotation parameter +// is non-null; otherwise it is null (e.g. raw style layer feature not managed by an AnnotationManager). +typedef OnFeatureInteractionCallback = void Function( + Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, +); + +typedef OnFeatureDragCallback = void Function( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, +); + +typedef OnFeatureHoverCallback = void Function( + Point point, + LatLng coordinates, + String id, + Annotation? annotation, + HoverEventType eventType, +); + +typedef OnMapLongClickCallback = void Function( + Point point, LatLng coordinates); + +typedef OnStyleLoadedCallback = void Function(); + +typedef OnUserLocationUpdated = void Function(UserLocation location); + +typedef OnCameraTrackingDismissedCallback = void Function(); +typedef OnCameraTrackingChangedCallback = void Function( + MyLocationTrackingMode mode); + +typedef OnCameraMoveCallback = void Function(CameraPosition cameraPosition); + +typedef OnCameraIdleCallback = void Function(); + +typedef OnMapIdleCallback = void Function(); + +@Deprecated('MaplibreMapController was renamed to MapLibreMapController.') +typedef MaplibreMapController = MapLibreMapController; + +/// Controller for a single [MapLibreMap] instance running on the host platform. +/// +/// Some of its methods can only be called after the [onStyleLoaded] callback has been invoked. +/// +/// To add annotations ([Circle]s, [Line]s, [Symbol]s and [Fill]s) on the map, there are two ways: +/// +/// 1. *Simple way to add annotations*: Use the corresponding add* methods ([addCircle], [addLine], [addSymbol] and [addFill]) on the MapLibreMapController to add one annotation at a time to the map. +/// There are also corresponding [addCircles], [addLines] etc. methods which work the same but add multiple annotations at a time. +/// +/// (If you are interested how this works: under the hood, this uses AnnotationManagers to manage the annotations. +/// An annotation manager performs the steps from the advanced way, but hides the complexity from the developer. +/// E.g. the [addCircle] method uses the [CircleManager], which in turn adds a GeoJson source to the map's style with the circle's locations as features. +/// The CircleManager also adds a circle style layer to the map's style that references that GeoJson source, therefore rendering all circles added with [addCircle] on the map.) +/// +/// There are also corresponding clear* methods like [clearCircles] to remove all circles from the map, which had been added with [addCircle] or [addCircles]. +/// +/// There are also properties like [circles] to get the current set of circles on the map, which had been added with [addCircle] or [addCircles]. +/// +/// Click events on annotations that are added this way (with the [addCircle], [addLine] etc. methods) can be received by adding callbacks to [onCircleTapped], [onLineTapped] etc. +/// +/// Note: [circles], [clearCircles] and [onCircleTapped] only work for circles added with [addCircle] or [addCircles], +/// not for circles that are already contained in the map's style when the map is loaded or are added to that map's style with the methods from the advanced way (see below). +/// The same of course applies for fills, lines and symbols. +/// +/// 2. *Advanced way to add annotations*: Modify the underlying MapLibre Style of the map to add a new data source (e.g. with the [addSource] method or the more specific methods like [addGeoJsonSource]) +/// and add a new layer to display the data of that source on the map (either with the [addLayer] method or with the more specific methods like [addCircleLayer], [addLineLayer] etc.). +/// For more information about MapLibre Styles, see the documentation of [maplibre_gl] as well as the specification at [https://maplibre.org/maplibre-style-spec/]. +/// +/// A MapLibreMapController is also a [ChangeNotifier]. Subscribers (change listeners) are notified upon changes to any of +/// +/// * the configuration options of the [MapLibreMap] widget +/// * the [symbols], [lines], [circles] or [fills] properties +/// (i.e. the collection of [Symbol]s, [Line]s, [Circle]s and [Fill]s added to this map via the "simple way" (see above)) +/// * the [isCameraMoving] property +/// * the [cameraPosition] property +/// +/// Listeners are notified after changes have been applied on the platform side. +class MapLibreMapController extends ChangeNotifier { + MapLibreMapController({ + required MapLibrePlatform maplibrePlatform, + required CameraPosition initialCameraPosition, + required Iterable annotationOrder, + required Iterable annotationConsumeTapEvents, + this.onStyleLoadedCallback, + this.onMapClick, + this.onMapLongClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onMapIdle, + this.onUserLocationUpdated, + this.onCameraIdle, + this.onCameraMove, + }) : _maplibrePlatform = maplibrePlatform { + _cameraPosition = initialCameraPosition; + + _maplibrePlatform.onFeatureTappedPlatform.add((payload) { + final id = payload["id"].toString(); + final layerId = payload["layerId"]; + final point = payload["point"]; + final latLng = payload["latLng"]; + final annotation = getAnnotationById(id); + + // Call all generic feature tapped callbacks + for (final fun in List.of(onFeatureTapped)) { + // New signature supplies id and (possibly null) annotation + fun(point, latLng, id, layerId, annotation); + } + + // If we have a managed annotation, call specific annotation callbacks (onSymbolTapped, onLineTapped...) + if (annotation != null) { + ArgumentCallbacks? annotationTappedCallbacks; + if (annotation is Line) { + annotationTappedCallbacks = onLineTapped; + } else if (annotation is Symbol) { + annotationTappedCallbacks = onSymbolTapped; + } else if (annotation is Fill) { + annotationTappedCallbacks = onFillTapped; + } else if (annotation is Circle) { + annotationTappedCallbacks = onCircleTapped; + } + annotationTappedCallbacks?.call(annotation); + } + }); + + _maplibrePlatform.onFeatureDraggedPlatform.add((payload) { + final id = payload["id"]; + final annotation = getAnnotationById(id); + final enmDragEventType = DragEventType.values + .firstWhere((element) => element.name == payload["eventType"]); + for (final fun in List.of(onFeatureDrag)) { + fun( + payload["point"], + payload["origin"], + payload["current"], + payload["delta"], + id, + annotation, + enmDragEventType, + ); + } + }); + + _maplibrePlatform.onFeatureHoverPlatform.add((payload) { + final id = payload["id"]; + final annotation = getAnnotationById(id); + final hoverEventType = HoverEventType.values + .firstWhere((e) => e.name == payload["eventType"]); + for (final fun in List.of(onFeatureHover)) { + fun( + payload["point"], + payload["latLng"], + id, + annotation, + hoverEventType, + ); + } + }); + + _maplibrePlatform.onCameraMoveStartedPlatform.add((_) { + _isCameraMoving = true; + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onCameraMovePlatform.add((cameraPosition) { + _cameraPosition = cameraPosition; + onCameraMove?.call(cameraPosition); + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onCameraIdlePlatform.add((cameraPosition) { + _isCameraMoving = false; + if (cameraPosition != null) { + _cameraPosition = cameraPosition; + } + onCameraIdle?.call(); + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onMapStyleLoadedPlatform.add((_) async { + final interactionEnabled = annotationConsumeTapEvents.toSet(); + for (final type in annotationOrder.toSet()) { + final enableInteraction = interactionEnabled.contains(type); + switch (type) { + case AnnotationType.fill: + fillManager = FillManager( + this, + enableInteraction: enableInteraction, + ); + await fillManager!.initialize(); + case AnnotationType.line: + lineManager = LineManager( + this, + enableInteraction: enableInteraction, + ); + await lineManager!.initialize(); + case AnnotationType.circle: + circleManager = CircleManager( + this, + enableInteraction: enableInteraction, + ); + await circleManager!.initialize(); + case AnnotationType.symbol: + symbolManager = SymbolManager( + this, + enableInteraction: enableInteraction, + ); + await symbolManager!.initialize(); + } + } + onStyleLoadedCallback?.call(); + }); + + _maplibrePlatform.onMapClickPlatform.add((dict) { + onMapClick?.call(dict['point'], dict['latLng']); + }); + + _maplibrePlatform.onMapLongClickPlatform.add((dict) { + onMapLongClick?.call(dict['point'], dict['latLng']); + }); + + _maplibrePlatform.onCameraTrackingChangedPlatform.add((mode) { + onCameraTrackingChanged?.call(mode); + }); + + _maplibrePlatform.onCameraTrackingDismissedPlatform.add((_) { + onCameraTrackingDismissed?.call(); + }); + + _maplibrePlatform.onMapIdlePlatform.add((_) { + onMapIdle?.call(); + }); + _maplibrePlatform.onUserLocationUpdatedPlatform.add((location) { + onUserLocationUpdated?.call(location); + }); + } + + Annotation? getAnnotationById(dynamic id) { + if (id == null) return null; + + final formattedId = id.toString(); + return fillManager?.byId(formattedId) ?? + lineManager?.byId(formattedId) ?? + symbolManager?.byId(formattedId) ?? + circleManager?.byId(formattedId); + } + + FillManager? fillManager; + LineManager? lineManager; + CircleManager? circleManager; + SymbolManager? symbolManager; + + final OnStyleLoadedCallback? onStyleLoadedCallback; + final OnMapClickCallback? onMapClick; + final OnMapLongClickCallback? onMapLongClick; + + final OnUserLocationUpdated? onUserLocationUpdated; + + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + final OnCameraMoveCallback? onCameraMove; + final OnCameraIdleCallback? onCameraIdle; + + final OnMapIdleCallback? onMapIdle; + + /// Callbacks to receive tap events for symbols placed on this map. + final ArgumentCallbacks onSymbolTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for circles placed on this map. + final ArgumentCallbacks onCircleTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for fills placed on this map. + final ArgumentCallbacks onFillTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for lines placed on this map. + final ArgumentCallbacks onLineTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for features (geojson layer) placed on this map. + final onFeatureTapped = []; + + /// Callbacks to receive drag events for features (geojson layer) placed on this map. + final onFeatureDrag = []; + + /// Callbacks to receive mouse events(enter,move,leave) on web for features (geojson layer) placed on this map. + final onFeatureHover = []; + + /// Callbacks to receive tap events for info windows on symbols + @Deprecated("InfoWindow tapped is no longer supported") + final ArgumentCallbacks onInfoWindowTapped = + ArgumentCallbacks(); + + /// The current set of symbols on this map added with the [addSymbol] or [addSymbols] methods. + /// + /// The returned set will be a detached snapshot of the symbols collection. + Set get symbols => symbolManager?.annotations ?? {}; + + /// The current set of lines on this map added with the [addLine] or [addLines] methods. + /// + /// The returned set will be a detached snapshot of the lines collection. + Set get lines => lineManager?.annotations ?? {}; + + /// The current set of circles on this map added with the [addCircle] or [addCircles] methods. + /// + /// The returned set will be a detached snapshot of the circles collection. + Set get circles => circleManager?.annotations ?? {}; + + /// The current set of fills on this map added with the [addFill] or [addFills] methods. + /// + /// The returned set will be a detached snapshot of the fills collection. + Set get fills => fillManager?.annotations ?? {}; + + /// True if the map camera is currently moving. + bool get isCameraMoving => _isCameraMoving; + bool _isCameraMoving = false; + + /// Returns the most recent camera position reported by the platform side. + /// Will be null, if [MapLibreMap.trackCameraPosition] is false. + CameraPosition? get cameraPosition => _cameraPosition; + CameraPosition? _cameraPosition; + + final MapLibrePlatform _maplibrePlatform; + + /// Tracks whether the controller has already been disposed + bool _isDisposed = false; + + /// Return whether the controller has already been disposed. + bool get isDisposed => _isDisposed; + + /// Updates configuration options of the map user interface. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future _updateMapOptions(Map optionsUpdate) async { + _cameraPosition = await _maplibrePlatform.updateMapOptions(optionsUpdate); + if (!isDisposed) notifyListeners(); + } + + /// Triggers a resize event for the map on web (ignored on Android or iOS). + /// + /// Checks first if a resize is required or if it looks like it is already correctly resized. + /// If it looks good, the resize call will be skipped. + /// + /// To force resize map (without any checks) have a look at forceResizeWebMap() + void resizeWebMap() { + _maplibrePlatform.resizeWebMap(); + } + + /// Triggers a hard map resize event on web and does not check if it is required or not. + void forceResizeWebMap() { + _maplibrePlatform.forceResizeWebMap(); + } + + /// Starts an animated change of the map camera position. + /// + /// [duration] is the amount of time, that the transition animation should take. + /// + /// The returned [Future] completes after the change has been started on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future animateCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + return _maplibrePlatform.animateCamera(cameraUpdate, duration: duration); + } + + /// Instantaneously re-position the camera. + /// Note: moveCamera() quickly moves the camera, which can be visually jarring for a user. Strongly consider using the animateCamera() methods instead because it's less abrupt. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future moveCamera(CameraUpdate cameraUpdate) async { + return _maplibrePlatform.moveCamera(cameraUpdate); + } + + /// Adds a new geojson source + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// [promoteId] can be used on web to promote an id from properties to be the + /// id of the feature. This is useful because by default maplibre-gl-js does not + /// support string ids + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future addGeoJsonSource(String sourceId, Map geojson, + {String? promoteId}) async { + await _maplibrePlatform.addGeoJsonSource(sourceId, geojson, + promoteId: promoteId); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonSource( + String sourceId, Map geojson) async { + await _maplibrePlatform.setGeoJsonSource(sourceId, geojson); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonFeature( + String sourceId, Map geojsonFeature) async { + await _maplibrePlatform.setFeatureForGeoJsonSource( + sourceId, geojsonFeature); + } + + /// Add a symbol layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addSymbolLayer( + String sourceId, String layerId, SymbolLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addSymbolLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a line layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addLineLayer( + String sourceId, String layerId, LineLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addLineLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Set one or multiple properties of a layer. + /// You can only use properties that are supported for the layer's type. + /// So you can e.g. only use LineLayerProperties on a line layer. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// NOTE: The [properties] will not skip null values, so setting a property to null will potentially reset it to default. + Future setLayerProperties( + String layerId, + LayerProperties properties, + ) async { + await _maplibrePlatform.setLayerProperties( + layerId, + properties.toJson(skipNulls: false), + ); + } + + /// Add a fill layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addFillLayer( + String sourceId, String layerId, FillLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addFillLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a fill extrusion layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addFillExtrusionLayer( + String sourceId, String layerId, FillExtrusionLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addFillExtrusionLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a circle layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addCircleLayer( + String sourceId, String layerId, CircleLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addCircleLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a raster layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addRasterLayer( + String sourceId, String layerId, RasterLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addRasterLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a hillshade layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHillshadeLayer( + String sourceId, String layerId, HillshadeLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addHillshadeLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a heatmap layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHeatmapLayer( + String sourceId, String layerId, HeatmapLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addHeatmapLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Updates user location tracking mode. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateMyLocationTrackingMode( + MyLocationTrackingMode myLocationTrackingMode) async { + return _maplibrePlatform + .updateMyLocationTrackingMode(myLocationTrackingMode); + } + + /// Updates the language of the map labels to match the device's language. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future matchMapLanguageWithDeviceDefault() async { + return _maplibrePlatform.matchMapLanguageWithDeviceDefault(); + } + + /// Updates the distance from the edges of the map view’s frame to the edges + /// of the map view’s logical viewport, optionally animating the change. + /// + /// When the value of this property is equal to `EdgeInsets.zero`, viewport + /// properties such as centerCoordinate assume a viewport that matches the map + /// view’s frame. Otherwise, those properties are inset, excluding part of the + /// frame from the viewport. For instance, if the only the top edge is inset, + /// the map center is effectively shifted downward. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateContentInsets(EdgeInsets insets, + [bool animated = false]) async { + return _maplibrePlatform.updateContentInsets(insets, animated); + } + + /// Updates the language of the map labels to match the specified language. + /// This will use labels with "name:$language" if available, otherwise "name:latin" or "name". + /// This naming schema is used by OpenStreetMap (see [https://wiki.openstreetmap.org/wiki/Multilingual_names]), + /// and is also used by some other vector tile generation software and vector tile providers. + /// Commonly, (and according to the OSM wiki) [language] should be + /// "a lowercase language's ISO 639-1 alpha2 code (second column), a lowercase ISO 639-2 code if an ISO 639-1 code doesn't exist, or a ISO 639-3 code if neither of those exist". + /// + /// If your vector tiles do not follow this schema of having labels with "name:$language" for different language, this method will not work for you. + /// In that case, you need to adapt your MapLibre style accordingly yourself to use labels in your preferred language. + /// + /// Attention: This may only be called after onStyleLoaded() has been invoked. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMapLanguage(String language) async { + return _maplibrePlatform.setMapLanguage(language); + } + + /// Enables or disables the collection of anonymized telemetry data. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setTelemetryEnabled(bool enabled) async { + return _maplibrePlatform.setTelemetryEnabled(enabled); + } + + /// Retrieves whether collection of anonymized telemetry data is enabled. + /// + /// The returned [Future] completes after the query has been made on the + /// platform side. + Future getTelemetryEnabled() async { + return _maplibrePlatform.getTelemetryEnabled(); + } + + /// Sets the maximum frames per second for the map rendering. + /// + /// This can help optimize performance on lower-end devices by limiting + /// the rendering frequency. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMaximumFps(int fps) async { + return _maplibrePlatform.setMaximumFps(fps); + } + + /// Forces the map to use online mode, disabling any offline functionality. + /// + /// This is useful for testing or when you want to ensure the map always + /// uses the latest data from the network. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future forceOnlineMode() async { + return _maplibrePlatform.forceOnlineMode(); + } + + /// Eases the camera to a new position with an optional duration. + /// + /// The [cameraUpdate] specifies the target camera position, and [duration] + /// specifies the animation duration in milliseconds (optional). + /// + /// The returned [Future] completes with true if the animation finished successfully, + /// or false if it was cancelled. + Future easeCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + return _maplibrePlatform.easeCamera(cameraUpdate, duration: duration); + } + + /// Queries the current camera position. + /// + /// Returns the current camera position including center, zoom, bearing, and tilt. + /// Returns null if the camera position cannot be determined. + /// + /// The returned [Future] completes with the current camera position. + Future queryCameraPosition() async { + return _maplibrePlatform.queryCameraPosition(); + } + + /// Edits a GeoJSON source with new data. + /// + /// The [id] specifies the source identifier, and [data] contains the new + /// GeoJSON data as a string. + /// + /// The returned [Future] completes with true if the source was successfully + /// updated, false otherwise. + Future editGeoJsonSource(String id, String data) async { + return _maplibrePlatform.editGeoJsonSource(id, data); + } + + /// Edits a GeoJSON source with a new URL. + /// + /// The [id] specifies the source identifier, and [url] contains the new + /// URL for the GeoJSON data. + /// + /// The returned [Future] completes with true if the source was successfully + /// updated, false otherwise. + Future editGeoJsonUrl(String id, String url) async { + return _maplibrePlatform.editGeoJsonUrl(id, url); + } + + /// Sets a filter for a layer. + /// + /// The [layerId] specifies the layer identifier, and [filter] contains the + /// filter expression as a JSON string. + /// + /// The returned [Future] completes with true if the filter was successfully + /// applied, false otherwise. + Future setLayerFilter(String layerId, String filter) async { + return _maplibrePlatform.setLayerFilter(layerId, filter); + } + + /// Gets the current map style as JSON string. + /// + /// The returned [Future] completes with the style JSON string if successful, + /// null otherwise. + Future getStyle() async { + return _maplibrePlatform.getStyle(); + } + + /// Sets custom HTTP headers for map requests. + /// + /// The [headers] map contains the header key-value pairs to set, and [filter] + /// contains URL patterns to determine which requests should include these headers. + /// + /// The returned [Future] completes when the headers are successfully set. + Future setCustomHeaders( + Map headers, List filter) async { + return _maplibrePlatform.setCustomHeaders(headers, filter); + } + + /// Gets the current custom HTTP headers. + /// + /// The returned [Future] completes with a map of the current custom headers. + Future> getCustomHeaders() async { + return _maplibrePlatform.getCustomHeaders(); + } + + /// Adds a symbol to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future addSymbol(SymbolOptions options, [Map? data]) async { + _ensureManagerInitialized(symbolManager); + + final effectiveOptions = SymbolOptions.defaultOptions.copyWith(options); + final symbol = Symbol(getRandomString(), effectiveOptions, data); + await symbolManager?.add(symbol); + if (!isDisposed) notifyListeners(); + return symbol; + } + + /// Adds multiple symbols to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future> addSymbols( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(symbolManager); + + final symbols = [ + for (var i = 0; i < options.length; i++) + Symbol( + getRandomString(), + SymbolOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await symbolManager?.addAll(symbols); + if (!isDisposed) notifyListeners(); + return symbols; + } + + /// Updates the specified [symbol] with the given [changes]. The symbol must + /// be a current member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future updateSymbol(Symbol symbol, SymbolOptions changes) async { + await symbolManager?.set( + symbol..options = symbol.options.copyWith(changes), + ); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the symbol. + /// This may be different from the value of `symbol.options.geometry` if the symbol is draggable. + /// In that case this method provides the symbol's actual position, and `symbol.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Symbol has no geometry set. + LatLng getSymbolLatLng(Symbol symbol) { + if (symbol.options.geometry == null) { + throw ArgumentError( + "Symbol geometry is null. Cannot determine position.", + ); + } + + return symbol.options.geometry!; + } + + /// Removes the specified [symbol] from the map. The symbol must be a current + /// member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbol(Symbol symbol) async { + await symbolManager?.remove(symbol); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [symbols] from the map. The symbols must be current + /// members of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbols(Iterable symbols) async { + await symbolManager?.removeAll(symbols); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [symbols] from the map added with the [addSymbol] or [addSymbols] methods. + /// + /// Change listeners are notified once all symbols have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearSymbols() async { + await symbolManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a line to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the line has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified.\ + /// An [Exception] is thrown if the LineManager is not initialized (style not loaded yet). + Future addLine(LineOptions options, [Map? data]) async { + _ensureManagerInitialized(lineManager); + + final effectiveOptions = LineOptions.defaultOptions.copyWith(options); + final line = Line(getRandomString(), effectiveOptions, data); + await lineManager?.add(line); + if (!isDisposed) notifyListeners(); + return line; + } + + /// Adds multiple lines to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the lines have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified.\ + /// An [Exception] is thrown if the LineManager is not initialized (style not loaded yet). + Future> addLines( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(lineManager); + + final lines = [ + for (var i = 0; i < options.length; i++) + Line( + getRandomString(), + LineOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await lineManager?.addAll(lines); + if (!isDisposed) notifyListeners(); + return lines; + } + + /// Updates the specified [line] with the given [changes]. The line must + /// be a current member of the [lines] set.‚ + /// + /// Change listeners are notified once the line has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateLine(Line line, LineOptions changes) async { + line.options = line.options.copyWith(changes); + await lineManager?.set(line); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the line. + /// This may be different from the value of `line.options.geometry` if the line is draggable. + /// In that case this method provides the line's actual position, and `line.options.geometry` the last programmatically set position. + /// An [Exception] is thrown if the Line has no geometry set. + List getLineLatLngs(Line line) { + if (line.options.geometry == null) { + throw ArgumentError( + "Line geometry is null. Cannot determine position.", + ); + } + + return line.options.geometry!; + } + + /// Removes the specified [line] from the map. The line must be a current + /// member of the [lines] set. + /// + /// Change listeners are notified once the line has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLine(Line line) async { + await lineManager?.remove(line); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [lines] from the map. The lines must be current + /// members of the [lines] set. + /// + /// Change listeners are notified once the lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLines(Iterable lines) async { + await lineManager?.removeAll(lines); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [lines] from the map added with the [addLine] or [addLines] methods. + /// + /// Change listeners are notified once all lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearLines() async { + await lineManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a circle to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the circle has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified.\ + /// An [Exception] is thrown if the CircleManager is not initialized (style not loaded yet). + Future addCircle(CircleOptions options, [Map? data]) async { + _ensureManagerInitialized(circleManager); + + final effectiveOptions = CircleOptions.defaultOptions.copyWith(options); + final circle = Circle(getRandomString(), effectiveOptions, data); + await circleManager?.add(circle); + if (!isDisposed) notifyListeners(); + return circle; + } + + /// Adds multiple circles to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the circles have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified.\ + /// An [Exception] is thrown if the CircleManager is not initialized (style not loaded yet). + Future> addCircles( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(circleManager); + + final circles = [ + for (var i = 0; i < options.length; i++) + Circle( + getRandomString(), + CircleOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await circleManager?.addAll(circles); + if (!isDisposed) notifyListeners(); + return circles; + } + + /// Updates the specified [circle] with the given [changes]. The circle must + /// be a current member of the [circles] set. + /// + /// Change listeners are notified once the circle has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateCircle(Circle circle, CircleOptions changes) async { + circle.options = circle.options.copyWith(changes); + await circleManager?.set(circle); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the circle. + /// This may be different from the value of `circle.options.geometry` if the circle is draggable. + /// In that case this method provides the circle's actual position, and `circle.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Circle has no geometry set. + LatLng getCircleLatLng(Circle circle) { + if (circle.options.geometry == null) { + throw ArgumentError( + "Circle geometry is null. Cannot determine position.", + ); + } + + return circle.options.geometry!; + } + + /// Removes the specified [circle] from the map. The circle must be a current + /// member of the [circles] set. + /// + /// Change listeners are notified once the circle has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircle(Circle circle) async { + await circleManager?.remove(circle); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [circles] from the map. The circles must be current + /// members of the [circles] set. + /// + /// Change listeners are notified once the circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircles(Iterable circles) async { + await circleManager?.removeAll(circles); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [circles] from the map added with the [addCircle] or [addCircles] methods. + /// + /// Change listeners are notified once all circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearCircles() async { + await circleManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a fill to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the fill has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fill once listeners have + /// been notified.\ + /// An [Exception] is thrown if the FillManager is not initialized (style not loaded yet). + Future addFill(FillOptions options, [Map? data]) async { + _ensureManagerInitialized(fillManager); + + final effectiveOptions = FillOptions.defaultOptions.copyWith(options); + final fill = Fill(getRandomString(), effectiveOptions, data); + await fillManager?.add(fill); + if (!isDisposed) notifyListeners(); + return fill; + } + + /// Adds multiple fills to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the fills has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fills once listeners have + /// been notified.\ + /// An [Exception] is thrown if the FillManager is not initialized (style not loaded yet). + Future> addFills( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(fillManager); + + final fills = [ + for (var i = 0; i < options.length; i++) + Fill( + getRandomString(), + FillOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await fillManager?.addAll(fills); + if (!isDisposed) notifyListeners(); + return fills; + } + + /// Updates the specified [fill] with the given [changes]. The fill must + /// be a current member of the [fills] set. + /// + /// Change listeners are notified once the fill has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateFill(Fill fill, FillOptions changes) async { + fill.options = fill.options.copyWith(changes); + await fillManager?.set(fill); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [fills] from the map added with the [addFill] or [addFills] methods. + /// + /// Change listeners are notified once all fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearFills() async { + await fillManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [fill] from the map. The fill must be a current + /// member of the [fills] set. + /// + /// Change listeners are notified once the fill has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFill(Fill fill) async { + await fillManager?.remove(fill); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [fills] from the map. The fills must be current + /// members of the [fills] set. + /// + /// Change listeners are notified once the fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFills(Iterable fills) async { + await fillManager?.removeAll(fills); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the fill. + /// This may be different from the value of `fill.options.geometry` if the fill is + /// draggable. In that case this method provides the fill's actual position, + /// and `fill.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Fill has no geometry set. + List> getFillLatLngs(Fill fill) { + if (fill.options.geometry == null) { + throw ArgumentError( + "Fill geometry is null. Cannot determine position.", + ); + } + + return fill.options.geometry!; + } + + /// Query rendered (i.e. visible) features at a point in screen coordinates + Future queryRenderedFeatures( + Point point, List layerIds, List? filter) async { + return _maplibrePlatform.queryRenderedFeatures(point, layerIds, filter); + } + + /// Query rendered (i.e. visible) features in a Rect in screen coordinates + Future queryRenderedFeaturesInRect( + Rect rect, List layerIds, String? filter) async { + return _maplibrePlatform.queryRenderedFeaturesInRect( + rect, layerIds, filter); + } + + /// Query features contained in the source with the specified [sourceId]. + /// + /// In contrast to [queryRenderedFeatures], this returns all features in the source, + /// regardless of whether they are currently rendered by the current style. + /// + /// Note: On web, this will probably only work for GeoJson source, not for vector tiles + Future querySourceFeatures( + String sourceId, String? sourceLayerId, List? filter) async { + return _maplibrePlatform.querySourceFeatures( + sourceId, sourceLayerId, filter); + } + + Future invalidateAmbientCache() async { + return _maplibrePlatform.invalidateAmbientCache(); + } + + Future clearAmbientCache() async { + return _maplibrePlatform.clearAmbientCache(); + } + + /// Get last my location + /// + /// Return last latlng, nullable + Future requestMyLocationLatLng() async { + return _maplibrePlatform.requestMyLocationLatLng(); + } + + /// This method returns the boundaries of the region currently displayed in the map. + Future getVisibleRegion() async { + return _maplibrePlatform.getVisibleRegion(); + } + + /// Adds an image to the style currently displayed in the map, so that it can later be referred to by the provided name. + /// + /// This allows you to add an image to the currently displayed style once, and from there on refer to it e.g. in the [Symbol.iconImage] anytime you add a [Symbol] later on. + /// Set [sdf] to true if the image you add is an SDF image. + /// Returns after the image has successfully been added to the style. + /// Note: This can only be called after OnStyleLoadedCallback has been invoked and any added images will have to be re-added if a new style is loaded. + /// + /// Example: Adding an asset image and using it in a new symbol: + /// ```dart + /// Future addImageFromAsset() async{ + /// final ByteData bytes = await rootBundle.load("assets/someAssetImage.jpg"); + /// final Uint8List list = bytes.buffer.asUint8List(); + /// await controller.addImage("assetImage", list); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "assetImage", + /// ), + /// ); + /// } + /// ``` + /// + /// Example: Adding a network image (with the http package) and using it in a new symbol: + /// ```dart + /// Future addImageFromUrl() async{ + /// var response = await get("https://example.com/image.png"); + /// await controller.addImage("testImage", response.bodyBytes); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "testImage", + /// ), + /// ); + /// } + /// ``` + Future addImage(String name, Uint8List bytes, [bool sdf = false]) { + return _maplibrePlatform.addImage(name, bytes, sdf); + } + + /// If true, the icon will be visible even if it collides with other previously drawn symbols. + Future setSymbolIconAllowOverlap(bool enable) async { + await symbolManager?.setIconAllowOverlap(enable); + } + + /// If true, other symbols can be visible even if they collide with the icon. + Future setSymbolIconIgnorePlacement(bool enable) async { + await symbolManager?.setIconIgnorePlacement(enable); + } + + /// If true, the text will be visible even if it collides with other previously drawn symbols. + Future setSymbolTextAllowOverlap(bool enable) async { + await symbolManager?.setTextAllowOverlap(enable); + } + + /// If true, other symbols can be visible even if they collide with the text. + Future setSymbolTextIgnorePlacement(bool enable) async { + await symbolManager?.setTextIgnorePlacement(enable); + } + + /// Adds an image source to the style currently displayed in the map, so that it can later be referred to by the provided id. + /// Not implemented on web. + Future addImageSource( + String imageSourceId, Uint8List bytes, LatLngQuad coordinates) { + return _maplibrePlatform.addImageSource(imageSourceId, bytes, coordinates); + } + + /// Update the image and/or coordinates of an image source. + /// Not implemented on web. + Future updateImageSource( + String imageSourceId, Uint8List? bytes, LatLngQuad? coordinates) { + return _maplibrePlatform.updateImageSource( + imageSourceId, bytes, coordinates); + } + + /// Removes previously added image source by id + @Deprecated("This method was renamed to removeSource") + Future removeImageSource(String imageSourceId) { + return _maplibrePlatform.removeSource(imageSourceId); + } + + /// Removes previously added source by id + Future removeSource(String sourceId) { + return _maplibrePlatform.removeSource(sourceId); + } + + /// Adds an image layer to the map's style at render time. + Future addImageLayer(String layerId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayer(layerId, imageSourceId, minzoom, maxzoom); + } + + /// Adds an image layer below the layer provided with belowLayerId to the map's style at render time. + Future addImageLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Adds an image layer below the layer provided with belowLayerId to the map's style at render time. Only works for image sources! + @Deprecated("This method was renamed to addImageLayerBelow for clarity.") + Future addLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Removes a MapLibre style layer + Future removeLayer(String layerId) { + return _maplibrePlatform.removeLayer(layerId); + } + + Future setFilter(String layerId, dynamic filter) { + return _maplibrePlatform.setFilter(layerId, filter); + } + + Future getFilter(String layerId) { + return _maplibrePlatform.getFilter(layerId); + } + + /// Returns the point on the screen that corresponds to a geographical coordinate ([latLng]). The screen location is in screen pixels (not display pixels) relative to the top left of the map (not of the whole screen) + /// + /// Note: The resulting x and y coordinates are rounded to [int] on web, on other platforms they may differ very slightly (in the range of about 10^-10) from the actual nearest screen coordinate. + /// You therefore might want to round them appropriately, depending on your use case. + /// + /// Returns null if [latLng] is not currently visible on the map. + Future toScreenLocation(LatLng latLng) async { + return _maplibrePlatform.toScreenLocation(latLng); + } + + Future> toScreenLocationBatch(Iterable latLngs) async { + return _maplibrePlatform.toScreenLocationBatch(latLngs); + } + + /// Returns the geographic location (as [LatLng]) that corresponds to a point on the screen. The screen location is specified in screen pixels (not display pixels) relative to the top left of the map (not the top left of the whole screen). + Future toLatLng(Point screenLocation) async { + return _maplibrePlatform.toLatLng(screenLocation); + } + + /// Returns the distance spanned by one pixel at the specified [latitude] and current zoom level. + /// The distance between pixels decreases as the latitude approaches the poles. This relationship parallels the relationship between longitudinal coordinates at different latitudes. + Future getMetersPerPixelAtLatitude(double latitude) async { + return _maplibrePlatform.getMetersPerPixelAtLatitude(latitude); + } + + /// Add a new source to the map + Future addSource(String sourceid, SourceProperties properties) async { + return _maplibrePlatform.addSource(sourceid, properties); + } + + /// Pans and zooms the map to contain its visible area within the specified geographical bounds. + /// + /// Also consider using [animateCamera] or [moveCamera], which allow you to set camera bounds (with different padding values per side) + /// as well as other camera properties. + Future setCameraBounds({ + required double west, + required double north, + required double south, + required double east, + required int padding, + }) async { + return _maplibrePlatform.setCameraBounds( + west: west, + north: north, + south: south, + east: east, + padding: padding, + ); + } + + /// Add a layer to the map with the given properties + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events this has no effect for [RasterLayerProperties] and + /// [HillshadeLayerProperties]. + /// [sourceLayer] is used to selected a specific source layer from Vector + /// source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// [filter] is not supported by RasterLayer and HillshadeLayer. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addLayer( + String sourceId, String layerId, LayerProperties properties, + {String? belowLayerId, + bool enableInteraction = true, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter}) async { + if (properties is FillLayerProperties) { + await addFillLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is FillExtrusionLayerProperties) { + await addFillExtrusionLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is LineLayerProperties) { + await addLineLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is SymbolLayerProperties) { + await addSymbolLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is CircleLayerProperties) { + await addCircleLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is RasterLayerProperties) { + if (filter != null) { + throw UnimplementedError("RasterLayer does not support filter"); + } + await addRasterLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HillshadeLayerProperties) { + if (filter != null) { + throw UnimplementedError("HillShadeLayer does not support filter"); + } + await addHillshadeLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HeatmapLayerProperties) { + await addHeatmapLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else { + throw UnimplementedError("Unknown layer type $properties"); + } + } + + Future setLayerVisibility(String layerId, bool visible) async { + return _maplibrePlatform.setLayerVisibility(layerId, visible); + } + + Future getLayerIds() { + return _maplibrePlatform.getLayerIds(); + } + + /// Retrieve every source ids of the map as a [String] list, including the ones added internally + /// + /// This method is not currently implemented on the web + Future> getSourceIds() async { + return (await _maplibrePlatform.getSourceIds()) + .whereType() + .toList(); + } + + /// Method to set style string + /// A MapLibre GL style document defining the map's appearance. + /// The style document specification is at [https://maplibre.org/maplibre-style-spec]. + /// A short introduction can be found in the documentation of the [maplibre_gl] library. + /// The [styleString] supports following formats: + /// + /// 1. Passing the URL of the map style. This should be a custom map style served remotely using a URL that start with 'http(s)://' + /// 2. Passing the style as a local asset. Create a JSON file in the `assets` and add a reference in `pubspec.yml`. Set the style string to the relative path for this asset in order to load it into the map. + /// 3. Passing the style as a local file. create an JSON file in app directory (e.g. ApplicationDocumentsDirectory). Set the style string to the absolute path of this JSON file. + /// 4. Passing the raw JSON of the map style. This is only supported on Android. + Future setStyle(String styleString) async { + return _maplibrePlatform.setStyle(styleString); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + _maplibrePlatform.dispose(); + } + + /// Ensures that the given manager is initialized. + /// If not, throws an [Exception]. + void _ensureManagerInitialized(AnnotationManager? manager) { + if (manager == null || !manager.isInitialized) { + throw Exception( + "This Annotation Manager has not been initialized. Make sure that the map style has been loaded.", + ); + } + } +} diff --git a/third_party/maplibre_gl/lib/src/download_region_status.dart b/third_party/maplibre_gl/lib/src/download_region_status.dart new file mode 100644 index 00000000..2f9aed2d --- /dev/null +++ b/third_party/maplibre_gl/lib/src/download_region_status.dart @@ -0,0 +1,25 @@ +part of '../maplibre_gl.dart'; + +abstract class DownloadRegionStatus {} + +class Success extends DownloadRegionStatus {} + +class InProgress extends DownloadRegionStatus { + final double progress; + + InProgress(this.progress); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.InProgress', progress = $progress"; +} + +class Error extends DownloadRegionStatus { + final PlatformException cause; + + Error(this.cause); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.Error', cause = $cause"; +} diff --git a/third_party/maplibre_gl/lib/src/global.dart b/third_party/maplibre_gl/lib/src/global.dart new file mode 100644 index 00000000..d9bfe46a --- /dev/null +++ b/third_party/maplibre_gl/lib/src/global.dart @@ -0,0 +1,136 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +const _globalChannel = MethodChannel('plugins.flutter.io/maplibre_gl'); + +/// Copy tiles db file passed in to the tiles cache directory (sideloaded) to +/// make tiles available offline. +Future installOfflineMapTiles(String tilesDb) async { + await _globalChannel.invokeMethod( + 'installOfflineMapTiles', + { + 'tilesdb': tilesDb, + }, + ); +} + +enum DragEventType { start, drag, end } + +enum HoverEventType { enter, move, leave } + +Future setOffline(bool offline) => _globalChannel.invokeMethod( + 'setOffline', + { + 'offline': offline, + }, + ); + +Future setHttpHeaders(Map headers) { + return _globalChannel.invokeMethod( + 'setHttpHeaders', + { + 'headers': headers, + }, + ); +} + +Future> mergeOfflineRegions(String path) async { + final String regionsJson = await _globalChannel.invokeMethod( + 'mergeOfflineRegions', + { + 'path': path, + }, + ); + final regions = List>.from(json.decode(regionsJson)); + return regions.map(OfflineRegion.fromMap).toList(); +} + +Future> getListOfRegions() async { + final String regionsJson = await _globalChannel.invokeMethod( + 'getListOfRegions', + {}, + ); + final regions = List>.from(json.decode(regionsJson)); + return regions.map(OfflineRegion.fromMap).toList(); +} + +Future updateOfflineRegionMetadata( + int id, Map metadata) async { + final regionJson = await _globalChannel.invokeMethod( + 'updateOfflineRegionMetadata', + { + 'id': id, + 'metadata': metadata, + }, + ); + + return OfflineRegion.fromMap(json.decode(regionJson)); +} + +Future setOfflineTileCountLimit(int limit) => + _globalChannel.invokeMethod( + 'setOfflineTileCountLimit', + { + 'limit': limit, + }, + ); + +Future deleteOfflineRegion(int id) => _globalChannel.invokeMethod( + 'deleteOfflineRegion', + { + 'id': id, + }, + ); + +Future downloadOfflineRegion( + OfflineRegionDefinition definition, { + Map metadata = const {}, + Function(DownloadRegionStatus event)? onEvent, +}) async { + final channelName = + 'downloadOfflineRegion_${DateTime.now().microsecondsSinceEpoch}'; + + await _globalChannel + .invokeMethod('downloadOfflineRegion#setup', { + 'channelName': channelName, + }); + + if (onEvent != null) { + EventChannel(channelName).receiveBroadcastStream().handleError((error) { + if (error is PlatformException) { + onEvent(Error(error)); + return Error(error); + } + final unknownError = Error( + PlatformException( + code: 'UnknowException', + message: + 'This error is unhandled by plugin. Please contact us if needed.', + details: error, + ), + ); + onEvent(unknownError); + return unknownError; + }).listen((data) { + final Map jsonData = json.decode(data); + final status = switch (jsonData['status']) { + 'start' => InProgress(0.0), + 'progress' => InProgress((jsonData['progress']! as num).toDouble()), + 'success' => Success(), + _ => throw Exception('Invalid event status ${jsonData['status']}'), + }; + onEvent(status); + }); + } + + final result = await _globalChannel + .invokeMethod('downloadOfflineRegion', { + 'definition': definition.toMap(), + 'metadata': metadata, + }); + + return OfflineRegion.fromMap(json.decode(result)); +} diff --git a/third_party/maplibre_gl/lib/src/layer_expressions.dart b/third_party/maplibre_gl/lib/src/layer_expressions.dart new file mode 100644 index 00000000..258df0d8 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/layer_expressions.dart @@ -0,0 +1,657 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +part of '../maplibre_gl.dart'; + +class Expressions { + /// Binds expressions to named variables, which can then be referenced in + /// the result expression using ["var", "variable_name"]. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const let = "let"; + + /// References variable bound using "let". + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const varExpression = "var"; + + /// Provides a literal array or object value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const literal = "literal"; + + /// Asserts that the input is an array (optionally with a specific item + /// type and length). If, when the input expression is evaluated, it is + /// not of the asserted type, then this assertion will cause the whole + /// expression to be aborted. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const array = "array"; + + /// Retrieves an item from an array. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const at = "at"; + + /// Determines whether an item exists in an array or a substring exists in + /// a string. + /// + /// Sdk Support: + /// basic functionality with js + static const inExpression = "in"; + + /// Selects the first output whose corresponding test condition evaluates + /// to true, or the fallback value otherwise. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const caseExpression = "case"; + + /// Selects the output whose label value matches the input value, or the + /// fallback value if no match is found. The input can be any expression + /// (e.g. `["get", "building_type"]`). Each label must be either: + /// * a single literal value; or + /// * an array of literal values, whose values must be all strings or all + /// numbers (e.g. `[100, 101]` or `["c", "b"]`). The input matches if any + /// of the values in the array matches, similar to the `"in"` + /// operator.Each label must be unique. If the input type does not match + /// the type of the labels, the result will be the fallback value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const match = "match"; + + /// Evaluates each expression in turn until the first non-null value is + /// obtained, and returns that value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const coalesce = "coalesce"; + + /// Produces discrete, stepped results by evaluating a piecewise-constant + /// function defined by pairs of input and output values ("stops"). The + /// `input` may be any numeric expression (e.g., `["get", "population"]`). + /// Stop inputs must be numeric literals in strictly ascending order. + /// Returns the output value of the stop just less than the input, or the + /// first output if the input is less than the first stop. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const step = "step"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). The `input` may be any numeric + /// expression (e.g., `["get", "population"]`). Stop inputs must be + /// numeric literals in strictly ascending order. The output type must be + /// `number`, `array`, or `color`.Interpolation types:- + /// `["linear"]`: interpolates linearly between the pair of stops just + /// less than and just greater than the input.- `["exponential", base]`: + /// interpolates exponentially between the stops just less than and just + /// greater than the input. `base` controls the rate at which the output + /// increases: higher values make the output increase more towards the + /// high end of the range. With values close to 1 the output increases + /// linearly.- `["cubic-bezier", x1, y1, x2, y2]`: interpolates using the + /// cubic bezier curve defined by the given control points. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const interpolate = "interpolate"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// Hue-Chroma-Luminance color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateHcl = "interpolate-hcl"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// CIELAB color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateLab = "interpolate-lab"; + + /// Returns mathematical constant ln(2). + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln2 = "ln2"; + + /// Returns the mathematical constant pi. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const pi = "pi"; + + /// Returns the mathematical constant e. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const e = "e"; + + /// Returns a string describing the type of the given value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const typeof = "typeof"; + + /// Asserts that the input value is a string. If multiple values are + /// provided, each one is evaluated in order until a string is obtained. + /// If none of the inputs are strings, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const string = "string"; + + /// Asserts that the input value is a number. If multiple values are + /// provided, each one is evaluated in order until a number is obtained. + /// If none of the inputs are numbers, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const number = "number"; + + /// Asserts that the input value is a boolean. If multiple values are + /// provided, each one is evaluated in order until a boolean is obtained. + /// If none of the inputs are booleans, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const boolean = "boolean"; + + /// Asserts that the input value is an object. If multiple values are + /// provided, each one is evaluated in order until an object is obtained. + /// If none of the inputs are objects, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const object = "object"; + + /// Returns a `collator` for use in locale-dependent comparison + /// operations. The `case-sensitive` and `diacritic-sensitive` options + /// default to `false`. The `locale` argument specifies the IETF language + /// tag of the locale to use. If none is provided, the default locale is + /// used. If the requested locale is not available, the `collator` will + /// use a system-defined fallback locale. Use `resolved-locale` to test + /// the results of locale fallback behavior. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const collator = "collator"; + + /// Returns `formatted` text containing annotations for use in + /// mixed-format `text-field` entries. For a `text-field` entries of a + /// string type, following option object's properties are supported: If + /// set, the `text-font` value overrides the font specified by the root + /// layout properties. If set, the `font-scale` value specifies a scaling + /// factor relative to the `text-size` specified in the root layout + /// properties. If set, the `text-color` value overrides the color + /// specified by the root paint properties for this layer. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const format = "format"; + + /// Returns an `image` type for use in `icon-image`, `*-pattern` entries + /// and as a section in the `format` expression. If set, the `image` + /// argument will check that the requested image exists in the style and + /// will return either the resolved image name or `null`, depending on + /// whether or not the image is currently in the style. This validation + /// process is synchronous and requires the image to have been added to + /// the style before requesting it in the `image` argument. + /// + /// Sdk Support: + /// basic functionality with js, android, ios + static const image = "image"; + + /// Converts the input number into a string representation using the + /// providing formatting rules. If set, the `locale` argument specifies + /// the locale to use, as a BCP 47 language tag. If set, the `currency` + /// argument specifies an ISO 4217 code to use for currency-style + /// formatting. If set, the `min-fraction-digits` and + /// `max-fraction-digits` arguments specify the minimum and maximum number + /// of fractional digits to include. + /// + /// Sdk Support: + /// basic functionality with js + static const numberFormat = "number-format"; + + /// Converts the input value to a string. If the input is `null`, the + /// result is `""`. If the input is a boolean, the result is `"true"` or + /// `"false"`. If the input is a number, it is converted to a string as + /// specified by the ["NumberToString" + /// algorithm](https://tc39.github.io/ecma262/#sec-tostring-applied-to-the-number-type) + /// of the ECMAScript Language Specification. If the input is a color, it + /// is converted to a string of the form `"rgba(r,g,b,a)"`, where `r`, + /// `g`, and `b` are numerals ranging from 0 to 255, and `a` ranges from 0 + /// to 1. Otherwise, the input is converted to a string in the format + /// specified by the + /// [`JSON.stringify`](https://tc39.github.io/ecma262/#sec-json.stringify) + /// function of the ECMAScript Language Specification. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toStringExpression = "to-string"; + + /// Converts the input value to a number, if possible. If the input is + /// `null` or `false`, the result is 0. If the input is `true`, the result + /// is 1. If the input is a string, it is converted to a number as + /// specified by the ["ToNumber Applied to the String Type" + /// algorithm](https://tc39.github.io/ecma262/#sec-tonumber-applied-to-the-string-type) + /// of the ECMAScript Language Specification. If multiple values are + /// provided, each one is evaluated in order until the first successful + /// conversion is obtained. If none of the inputs can be converted, the + /// expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toNumber = "to-number"; + + /// Converts the input value to a boolean. The result is `false` when then + /// input is an empty string, 0, `false`, `null`, or `NaN`; otherwise it + /// is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toBoolean = "to-boolean"; + + /// Returns a four-element array containing the input color's red, green, + /// blue, and alpha components, in that order. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toRgba = "to-rgba"; + + /// Converts the input value to a color. If multiple values are provided, + /// each one is evaluated in order until the first successful conversion + /// is obtained. If none of the inputs can be converted, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toColor = "to-color"; + + /// Creates a color value from red, green, and blue components, which must + /// range between 0 and 255, and an alpha component of 1. If any component + /// is out of range, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgb = "rgb"; + + /// Creates a color value from red, green, blue components, which must + /// range between 0 and 255, and an alpha component which must range + /// between 0 and 1. If any component is out of range, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgba = "rgba"; + + /// Retrieves a property value from the current feature's properties, or + /// from another object if a second argument is provided. Returns null if + /// the requested property is missing. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const get = "get"; + + /// Tests for the presence of an property value in the current feature's + /// properties, or from another object if a second argument is provided. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const has = "has"; + + /// Gets the length of an array or string. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const length = "length"; + + /// Gets the feature properties object. Note that in some cases, it may + /// be more efficient to use ["get", "property_name"] directly. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const properties = "properties"; + + /// Retrieves a property value from the current feature's state. Returns + /// null if the requested property is not present on the feature's state. + /// A feature's state is not part of the GeoJSON or vector tile data, and + /// must be set programmatically on each feature. Features are identified + /// by their `id` attribute, which must be an integer or a string that can + /// be cast to an integer. Note that ["feature-state"] can only be used + /// with paint properties that support data-driven styling. + /// + /// Sdk Support: + /// basic functionality with js + static const featureState = "feature-state"; + + /// Gets the feature's geometry type: Point, MultiPoint, LineString, + /// MultiLineString, Polygon, MultiPolygon. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const geometryType = "geometry-type"; + + /// Gets the feature's id, if it has one. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const id = "id"; + + /// Gets the current zoom level. Note that in style layout and paint + /// properties, ["zoom"] may only appear as the input to a top-level + /// "step" or "interpolate" expression. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const zoom = "zoom"; + + /// Gets the kernel density estimation of a pixel in a heatmap layer, + /// which is a relative measure of how many data points are crowded around + /// a particular pixel. Can only be used in the `heatmap-color` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const heatmapDensity = "heatmap-density"; + + /// Gets the progress along a gradient line. Can only be used in the + /// `line-gradient` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const lineProgress = "line-progress"; + + /// Gets the value of a cluster property accumulated so far. Can only be + /// used in the `clusterProperties` option of a clustered GeoJSON source. + /// + /// Sdk Support: + /// basic functionality with js + static const accumulated = "accumulated"; + + /// Returns the sum of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const plus = "+"; + + /// Returns the product of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const multiply = "*"; + + /// For two inputs, returns the result of subtracting the second input + /// from the first. For a single input, returns the result of subtracting + /// it from 0. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const minus = "-"; + + /// Returns the result of floating point division of the first input by + /// the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const divide = "/"; + + /// Returns the remainder after integer division of the first input by the + /// second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const precent = "%"; + + /// Returns the result of raising the first input to the power specified + /// by the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const xor = "^"; + + /// Returns the square root of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sqrt = "sqrt"; + + /// Returns the base-ten logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log10 = "log10"; + + /// Returns the natural logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln = "ln"; + + /// Returns the base-two logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log2 = "log2"; + + /// Returns the sine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sin = "sin"; + + /// Returns the cosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const cos = "cos"; + + /// Returns the tangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const tan = "tan"; + + /// Returns the arcsine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const asin = "asin"; + + /// Returns the arccosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const acos = "acos"; + + /// Returns the arctangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const atan = "atan"; + + /// Returns the minimum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const min = "min"; + + /// Returns the maximum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const max = "max"; + + /// Rounds the input to the nearest integer. Halfway values are rounded + /// away from zero. For example, `["round", -1.5]` evaluates to -2. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const round = "round"; + + /// Returns the absolute value of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const abs = "abs"; + + /// Returns the smallest integer that is greater than or equal to the + /// input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ceil = "ceil"; + + /// Returns the largest integer that is less than or equal to the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const floor = "floor"; + + /// Returns `true` if the input values are equal, `false` otherwise. The + /// comparison is strictly typed: values of different runtime types are + /// always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const equal = "=="; + + /// Returns `true` if the input values are not equal, `false` otherwise. + /// The comparison is strictly typed: values of different runtime types + /// are always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const notEqual = "!="; + + /// Returns `true` if the first input is strictly greater than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const larger = ">"; + + /// Returns `true` if the first input is strictly less than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smaller = "<"; + + /// Returns `true` if the first input is greater than or equal to the + /// second, `false` otherwise. The arguments are required to be either + /// both strings or both numbers; if during evaluation they are not, + /// expression evaluation produces an error. Cases where this constraint + /// is known not to hold at parse time are considered in valid and will + /// produce a parse error. Accepts an optional `collator` argument to + /// control locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const largerOrEqual = ">="; + + /// Returns `true` if the first input is less than or equal to the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smallerOrEqual = "<="; + + /// Returns `true` if all the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `false`, the result is `false` + /// and no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const all = "all"; + + /// Returns `true` if any of the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `true`, the result is `true` and + /// no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const any = "any"; + + /// Logical negation. Returns `true` if the input is `false`, and `false` + /// if the input is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const not = "!"; + + /// Returns `true` if the input string is expected to render legibly. + /// Returns `false` if the input string contains sections that cannot be + /// rendered without potential loss of meaning (e.g. Indic scripts that + /// require complex text shaping, or right-to-left scripts if the the + /// `mapbox-gl-rtl-text` plugin is not in use in MapLibre GL JS). + /// + /// Sdk Support: + /// basic functionality with js, android + static const isSupportedScript = "is-supported-script"; + + /// Returns the input string converted to uppercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const upcase = "upcase"; + + /// Returns the input string converted to lowercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const downcase = "downcase"; + + /// Returns a `string` consisting of the concatenation of the inputs. Each + /// input is converted to a string as if by `to-string`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const concat = "concat"; + + /// Returns the IETF language tag of the locale being used by the provided + /// `collator`. This can be used to determine the default system locale, + /// or to determine if a requested locale was successfully loaded. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const resolvedLocale = "resolved-locale"; +} diff --git a/third_party/maplibre_gl/lib/src/layer_properties.dart b/third_party/maplibre_gl/lib/src/layer_properties.dart new file mode 100644 index 00000000..7e0e38d2 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/layer_properties.dart @@ -0,0 +1,2380 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +part of '../maplibre_gl.dart'; + +abstract class LayerProperties { + Map toJson({bool skipNulls = true}); +} + +class SymbolLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the icon will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOpacity; + + /// The color of the icon. This can only be used with sdf icons. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconColor; + + /// The color of the icon's halo. Icon halos can only be used with SDF + /// icons. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloColor; + + /// Distance of halo to the icon outline. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloWidth; + + /// Fade out the halo towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloBlur; + + /// Distance that the icon's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslate; + + /// Controls the frame of reference for `icon-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Icons are translated relative to the map. + /// "viewport" + /// Icons are translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslateAnchor; + + /// The opacity at which the text will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOpacity; + + /// The color with which the text will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textColor; + + /// The color of the text's halo, which helps it stand out from + /// backgrounds. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloColor; + + /// Distance of halo to the font outline. Max text halo width is 1/4 of + /// the font-size. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloWidth; + + /// The halo's fadeout distance towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloBlur; + + /// Distance that the text's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslate; + + /// Controls the frame of reference for `text-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The text is translated relative to the map. + /// "viewport" + /// The text is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslateAnchor; + + // Layout Properties + /// Label placement relative to its geometry. + /// + /// Type: enum + /// default: point + /// Options: + /// "point" + /// The label is placed at the point where the geometry is located. + /// "line" + /// The label is placed along the line of the geometry. Can only be + /// used on `LineString` and `Polygon` geometries. + /// "line-center" + /// The label is placed at the center of the line of the geometry. + /// Can only be used on `LineString` and `Polygon` geometries. Note + /// that a single feature in a vector tile may contain multiple line + /// geometries. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolPlacement; + + /// Distance between two symbol anchors. + /// + /// Type: number + /// default: 250 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolSpacing; + + /// If true, the symbols will not cross tile edges to avoid mutual + /// collisions. Recommended in layers that don't have enough padding in + /// the vector tile to prevent collisions, or if it is a point symbol + /// layer placed after a line symbol layer. When using a client that + /// supports global collision detection, like MapLibre GL JS, enabling + /// this property is not needed to prevent clipped labels at tile + /// boundaries. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolAvoidEdges; + + /// Sorts features in ascending order based on this value. Features with + /// lower sort keys are drawn and placed first. When `icon-allow-overlap` + /// or `text-allow-overlap` is `false`, features with a lower sort key + /// will have priority during placement. When `icon-allow-overlap` or + /// `text-allow-overlap` is set to `true`, features with a higher sort key + /// will overlap over features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic symbolSortKey; + + /// Controls the order in which overlapping symbols in the same layer are + /// rendered + /// + /// Type: enum + /// default: auto + /// Options: + /// "auto" + /// If `symbol-sort-key` is set, sort based on that. Otherwise sort + /// symbols by their y-position relative to the viewport. + /// "viewport-y" + /// Symbols will be sorted by their y-position relative to the + /// viewport. + /// "source" + /// Symbols will be rendered in the same order as the source data + /// with no sorting applied. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolZOrder; + + /// If true, the icon will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// icon. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconIgnorePlacement; + + /// If true, text will display without their corresponding icons when the + /// icon collides with other symbols and the text does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconOptional; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of icons. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns icons + /// east-west. When `symbol-placement` is set to `line` or + /// `line-center`, aligns icon x-axes with the line. + /// "viewport" + /// Produces icons whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconRotationAlignment; + + /// Scales the original size of the icon by the provided factor. The new + /// pixel size of the image will be the original pixel size multiplied by + /// `icon-size`. 1 is the original size; 3 triples the size of the image. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconSize; + + /// Scales the icon to fit around the associated text. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The icon is displayed at its intrinsic aspect ratio. + /// "width" + /// The icon is scaled in the x-dimension to fit the width of the + /// text. + /// "height" + /// The icon is scaled in the y-dimension to fit the height of the + /// text. + /// "both" + /// The icon is scaled in both x- and y-dimensions. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFit; + + /// Size of the additional area added to dimensions determined by + /// `icon-text-fit`, in clockwise order: top, right, bottom, left. + /// + /// Type: array + /// default: [0, 0, 0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFitPadding; + + /// Name of image in sprite to use for drawing an image background. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconImage; + + /// Rotates the icon clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconRotate; + + /// Size of the additional area around the icon bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPadding; + + /// If true, the icon may be flipped to prevent it from being rendered + /// upside-down. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconKeepUpright; + + /// Offset distance of icon from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. Each + /// component is multiplied by the value of `icon-size` to obtain the + /// final offset in pixels. When combined with `icon-rotate` the offset + /// will be as if the rotated direction was up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOffset; + + /// Part of the icon placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the icon is placed closest to the anchor. + /// "left" + /// The left side of the icon is placed closest to the anchor. + /// "right" + /// The right side of the icon is placed closest to the anchor. + /// "top" + /// The top of the icon is placed closest to the anchor. + /// "bottom" + /// The bottom of the icon is placed closest to the anchor. + /// "top-left" + /// The top left corner of the icon is placed closest to the anchor. + /// "top-right" + /// The top right corner of the icon is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the icon is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the icon is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconAnchor; + + /// Orientation of icon when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The icon is aligned to the plane of the map. + /// "viewport" + /// The icon is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `icon-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPitchAlignment; + + /// Orientation of text when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The text is aligned to the plane of the map. + /// "viewport" + /// The text is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `text-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPitchAlignment; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of the individual glyphs forming the text. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns text east-west. + /// When `symbol-placement` is set to `line` or `line-center`, aligns + /// text x-axes with the line. + /// "viewport" + /// Produces glyphs whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textRotationAlignment; + + /// Value to use for a text label. If a plain `string` is provided, it + /// will be treated as a `formatted` with default/inherited formatting + /// options. + /// + /// Type: formatted + /// default: + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textField; + + /// Font stack to use for displaying text. + /// + /// Type: array + /// default: [Open Sans Regular, Arial Unicode MS Regular] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textFont; + + /// Font size. + /// + /// Type: number + /// default: 16 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textSize; + + /// The maximum line width for text wrapping. + /// + /// Type: number + /// default: 10 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textMaxWidth; + + /// Text leading value for multi-line text. + /// + /// Type: number + /// default: 1.2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textLineHeight; + + /// Text tracking amount. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textLetterSpacing; + + /// Text justification options. + /// + /// Type: enum + /// default: center + /// Options: + /// "auto" + /// The text is aligned towards the anchor position. + /// "left" + /// The text is aligned to the left. + /// "center" + /// The text is centered. + /// "right" + /// The text is aligned to the right. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textJustify; + + /// Radial offset of text, in the direction of the symbol's anchor. Useful + /// in combination with `text-variable-anchor`, which defaults to using + /// the two-dimensional `text-offset` if present. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRadialOffset; + + /// To increase the chance of placing high-priority labels on the map, you + /// can provide an array of `text-anchor` locations: the renderer will + /// attempt to place the label at each location, in order, before moving + /// onto the next label. Use `text-justify: auto` to choose justification + /// based on anchor position. To apply an offset, use the + /// `text-radial-offset` or the two-dimensional `text-offset`. + /// + /// Type: array + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textVariableAnchor; + + /// Part of the text placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textAnchor; + + /// Maximum angle change between adjacent characters. + /// + /// Type: number + /// default: 45 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textMaxAngle; + + /// The property allows control over a symbol's orientation. Note that the + /// property values act as a hint, so that a symbol whose language doesn’t + /// support the provided orientation will be laid out in its natural + /// orientation. Example: English point symbol will be rendered + /// horizontally even if array value contains single 'vertical' enum + /// value. The order of elements in an array define priority order for the + /// placement of an orientation variant. + /// + /// Type: array + /// Options: + /// "horizontal" + /// If a text's language supports horizontal writing mode, symbols + /// with point placement would be laid out horizontally. + /// "vertical" + /// If a text's language supports vertical writing mode, symbols with + /// point placement would be laid out vertically. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textWritingMode; + + /// Rotates the text clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRotate; + + /// Size of the additional area around the text bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPadding; + + /// If true, the text may be flipped vertically to prevent it from being + /// rendered upside-down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textKeepUpright; + + /// Specifies how to capitalize text, similar to the CSS `text-transform` + /// property. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The text is not altered. + /// "uppercase" + /// Forces all letters to be displayed in uppercase. + /// "lowercase" + /// Forces all letters to be displayed in lowercase. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textTransform; + + /// Offset distance of text from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. If used + /// with text-variable-anchor, input values will be taken as absolute + /// values. Offsets along the x- and y-axis will be applied automatically + /// based on the anchor position. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOffset; + + /// If true, the text will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// text. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textIgnorePlacement; + + /// If true, icons will display without their corresponding text when the + /// text collides with other symbols and the icon does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textOptional; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const SymbolLayerProperties({ + this.iconOpacity, + this.iconColor, + this.iconHaloColor, + this.iconHaloWidth, + this.iconHaloBlur, + this.iconTranslate, + this.iconTranslateAnchor, + this.textOpacity, + this.textColor, + this.textHaloColor, + this.textHaloWidth, + this.textHaloBlur, + this.textTranslate, + this.textTranslateAnchor, + this.symbolPlacement, + this.symbolSpacing, + this.symbolAvoidEdges, + this.symbolSortKey, + this.symbolZOrder, + this.iconAllowOverlap, + this.iconIgnorePlacement, + this.iconOptional, + this.iconRotationAlignment, + this.iconSize, + this.iconTextFit, + this.iconTextFitPadding, + this.iconImage, + this.iconRotate, + this.iconPadding, + this.iconKeepUpright, + this.iconOffset, + this.iconAnchor, + this.iconPitchAlignment, + this.textPitchAlignment, + this.textRotationAlignment, + this.textField, + this.textFont, + this.textSize, + this.textMaxWidth, + this.textLineHeight, + this.textLetterSpacing, + this.textJustify, + this.textRadialOffset, + this.textVariableAnchor, + this.textAnchor, + this.textMaxAngle, + this.textWritingMode, + this.textRotate, + this.textPadding, + this.textKeepUpright, + this.textTransform, + this.textOffset, + this.textAllowOverlap, + this.textIgnorePlacement, + this.textOptional, + this.visibility, + }); + + SymbolLayerProperties copyWith(SymbolLayerProperties changes) { + return SymbolLayerProperties( + iconOpacity: changes.iconOpacity ?? iconOpacity, + iconColor: changes.iconColor ?? iconColor, + iconHaloColor: changes.iconHaloColor ?? iconHaloColor, + iconHaloWidth: changes.iconHaloWidth ?? iconHaloWidth, + iconHaloBlur: changes.iconHaloBlur ?? iconHaloBlur, + iconTranslate: changes.iconTranslate ?? iconTranslate, + iconTranslateAnchor: changes.iconTranslateAnchor ?? iconTranslateAnchor, + textOpacity: changes.textOpacity ?? textOpacity, + textColor: changes.textColor ?? textColor, + textHaloColor: changes.textHaloColor ?? textHaloColor, + textHaloWidth: changes.textHaloWidth ?? textHaloWidth, + textHaloBlur: changes.textHaloBlur ?? textHaloBlur, + textTranslate: changes.textTranslate ?? textTranslate, + textTranslateAnchor: changes.textTranslateAnchor ?? textTranslateAnchor, + symbolPlacement: changes.symbolPlacement ?? symbolPlacement, + symbolSpacing: changes.symbolSpacing ?? symbolSpacing, + symbolAvoidEdges: changes.symbolAvoidEdges ?? symbolAvoidEdges, + symbolSortKey: changes.symbolSortKey ?? symbolSortKey, + symbolZOrder: changes.symbolZOrder ?? symbolZOrder, + iconAllowOverlap: changes.iconAllowOverlap ?? iconAllowOverlap, + iconIgnorePlacement: changes.iconIgnorePlacement ?? iconIgnorePlacement, + iconOptional: changes.iconOptional ?? iconOptional, + iconRotationAlignment: + changes.iconRotationAlignment ?? iconRotationAlignment, + iconSize: changes.iconSize ?? iconSize, + iconTextFit: changes.iconTextFit ?? iconTextFit, + iconTextFitPadding: changes.iconTextFitPadding ?? iconTextFitPadding, + iconImage: changes.iconImage ?? iconImage, + iconRotate: changes.iconRotate ?? iconRotate, + iconPadding: changes.iconPadding ?? iconPadding, + iconKeepUpright: changes.iconKeepUpright ?? iconKeepUpright, + iconOffset: changes.iconOffset ?? iconOffset, + iconAnchor: changes.iconAnchor ?? iconAnchor, + iconPitchAlignment: changes.iconPitchAlignment ?? iconPitchAlignment, + textPitchAlignment: changes.textPitchAlignment ?? textPitchAlignment, + textRotationAlignment: + changes.textRotationAlignment ?? textRotationAlignment, + textField: changes.textField ?? textField, + textFont: changes.textFont ?? textFont, + textSize: changes.textSize ?? textSize, + textMaxWidth: changes.textMaxWidth ?? textMaxWidth, + textLineHeight: changes.textLineHeight ?? textLineHeight, + textLetterSpacing: changes.textLetterSpacing ?? textLetterSpacing, + textJustify: changes.textJustify ?? textJustify, + textRadialOffset: changes.textRadialOffset ?? textRadialOffset, + textVariableAnchor: changes.textVariableAnchor ?? textVariableAnchor, + textAnchor: changes.textAnchor ?? textAnchor, + textMaxAngle: changes.textMaxAngle ?? textMaxAngle, + textWritingMode: changes.textWritingMode ?? textWritingMode, + textRotate: changes.textRotate ?? textRotate, + textPadding: changes.textPadding ?? textPadding, + textKeepUpright: changes.textKeepUpright ?? textKeepUpright, + textTransform: changes.textTransform ?? textTransform, + textOffset: changes.textOffset ?? textOffset, + textAllowOverlap: changes.textAllowOverlap ?? textAllowOverlap, + textIgnorePlacement: changes.textIgnorePlacement ?? textIgnorePlacement, + textOptional: changes.textOptional ?? textOptional, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('icon-opacity', iconOpacity); + addIfPresent('icon-color', iconColor); + addIfPresent('icon-halo-color', iconHaloColor); + addIfPresent('icon-halo-width', iconHaloWidth); + addIfPresent('icon-halo-blur', iconHaloBlur); + addIfPresent('icon-translate', iconTranslate); + addIfPresent('icon-translate-anchor', iconTranslateAnchor); + addIfPresent('text-opacity', textOpacity); + addIfPresent('text-color', textColor); + addIfPresent('text-halo-color', textHaloColor); + addIfPresent('text-halo-width', textHaloWidth); + addIfPresent('text-halo-blur', textHaloBlur); + addIfPresent('text-translate', textTranslate); + addIfPresent('text-translate-anchor', textTranslateAnchor); + addIfPresent('symbol-placement', symbolPlacement); + addIfPresent('symbol-spacing', symbolSpacing); + addIfPresent('symbol-avoid-edges', symbolAvoidEdges); + addIfPresent('symbol-sort-key', symbolSortKey); + addIfPresent('symbol-z-order', symbolZOrder); + addIfPresent('icon-allow-overlap', iconAllowOverlap); + addIfPresent('icon-ignore-placement', iconIgnorePlacement); + addIfPresent('icon-optional', iconOptional); + addIfPresent('icon-rotation-alignment', iconRotationAlignment); + addIfPresent('icon-size', iconSize); + addIfPresent('icon-text-fit', iconTextFit); + addIfPresent('icon-text-fit-padding', iconTextFitPadding); + addIfPresent('icon-image', iconImage); + addIfPresent('icon-rotate', iconRotate); + addIfPresent('icon-padding', iconPadding); + addIfPresent('icon-keep-upright', iconKeepUpright); + addIfPresent('icon-offset', iconOffset); + addIfPresent('icon-anchor', iconAnchor); + addIfPresent('icon-pitch-alignment', iconPitchAlignment); + addIfPresent('text-pitch-alignment', textPitchAlignment); + addIfPresent('text-rotation-alignment', textRotationAlignment); + addIfPresent('text-field', textField); + addIfPresent('text-font', textFont); + addIfPresent('text-size', textSize); + addIfPresent('text-max-width', textMaxWidth); + addIfPresent('text-line-height', textLineHeight); + addIfPresent('text-letter-spacing', textLetterSpacing); + addIfPresent('text-justify', textJustify); + addIfPresent('text-radial-offset', textRadialOffset); + addIfPresent('text-variable-anchor', textVariableAnchor); + addIfPresent('text-anchor', textAnchor); + addIfPresent('text-max-angle', textMaxAngle); + addIfPresent('text-writing-mode', textWritingMode); + addIfPresent('text-rotate', textRotate); + addIfPresent('text-padding', textPadding); + addIfPresent('text-keep-upright', textKeepUpright); + addIfPresent('text-transform', textTransform); + addIfPresent('text-offset', textOffset); + addIfPresent('text-allow-overlap', textAllowOverlap); + addIfPresent('text-ignore-placement', textIgnorePlacement); + addIfPresent('text-optional', textOptional); + addIfPresent('visibility', visibility); + return json; + } + + factory SymbolLayerProperties.fromJson(Map json) { + return SymbolLayerProperties( + iconOpacity: json['icon-opacity'], + iconColor: json['icon-color'], + iconHaloColor: json['icon-halo-color'], + iconHaloWidth: json['icon-halo-width'], + iconHaloBlur: json['icon-halo-blur'], + iconTranslate: json['icon-translate'], + iconTranslateAnchor: json['icon-translate-anchor'], + textOpacity: json['text-opacity'], + textColor: json['text-color'], + textHaloColor: json['text-halo-color'], + textHaloWidth: json['text-halo-width'], + textHaloBlur: json['text-halo-blur'], + textTranslate: json['text-translate'], + textTranslateAnchor: json['text-translate-anchor'], + symbolPlacement: json['symbol-placement'], + symbolSpacing: json['symbol-spacing'], + symbolAvoidEdges: json['symbol-avoid-edges'], + symbolSortKey: json['symbol-sort-key'], + symbolZOrder: json['symbol-z-order'], + iconAllowOverlap: json['icon-allow-overlap'], + iconIgnorePlacement: json['icon-ignore-placement'], + iconOptional: json['icon-optional'], + iconRotationAlignment: json['icon-rotation-alignment'], + iconSize: json['icon-size'], + iconTextFit: json['icon-text-fit'], + iconTextFitPadding: json['icon-text-fit-padding'], + iconImage: json['icon-image'], + iconRotate: json['icon-rotate'], + iconPadding: json['icon-padding'], + iconKeepUpright: json['icon-keep-upright'], + iconOffset: json['icon-offset'], + iconAnchor: json['icon-anchor'], + iconPitchAlignment: json['icon-pitch-alignment'], + textPitchAlignment: json['text-pitch-alignment'], + textRotationAlignment: json['text-rotation-alignment'], + textField: json['text-field'], + textFont: json['text-font'], + textSize: json['text-size'], + textMaxWidth: json['text-max-width'], + textLineHeight: json['text-line-height'], + textLetterSpacing: json['text-letter-spacing'], + textJustify: json['text-justify'], + textRadialOffset: json['text-radial-offset'], + textVariableAnchor: json['text-variable-anchor'], + textAnchor: json['text-anchor'], + textMaxAngle: json['text-max-angle'], + textWritingMode: json['text-writing-mode'], + textRotate: json['text-rotate'], + textPadding: json['text-padding'], + textKeepUpright: json['text-keep-upright'], + textTransform: json['text-transform'], + textOffset: json['text-offset'], + textAllowOverlap: json['text-allow-overlap'], + textIgnorePlacement: json['text-ignore-placement'], + textOptional: json['text-optional'], + visibility: json['visibility'], + ); + } +} + +class CircleLayerProperties implements LayerProperties { + // Paint Properties + /// Circle radius. + /// + /// Type: number + /// default: 5 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleRadius; + + /// The fill color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleColor; + + /// Amount to blur the circle. 1 blurs the circle such that only the + /// centerpoint is full opacity. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleBlur; + + /// The opacity at which the circle will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleOpacity; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslate; + + /// Controls the frame of reference for `circle-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The circle is translated relative to the map. + /// "viewport" + /// The circle is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslateAnchor; + + /// Controls the scaling behavior of the circle when the map is pitched. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Circles are scaled according to their apparent distance to the + /// camera. + /// "viewport" + /// Circles are not scaled. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchScale; + + /// Orientation of circle when map is pitched. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The circle is aligned to the plane of the map. + /// "viewport" + /// The circle is aligned to the plane of the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchAlignment; + + /// The width of the circle's stroke. Strokes are placed outside of the + /// `circle-radius`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeWidth; + + /// The stroke color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeColor; + + /// The opacity of the circle's stroke. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeOpacity; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic circleSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const CircleLayerProperties({ + this.circleRadius, + this.circleColor, + this.circleBlur, + this.circleOpacity, + this.circleTranslate, + this.circleTranslateAnchor, + this.circlePitchScale, + this.circlePitchAlignment, + this.circleStrokeWidth, + this.circleStrokeColor, + this.circleStrokeOpacity, + this.circleSortKey, + this.visibility, + }); + + CircleLayerProperties copyWith(CircleLayerProperties changes) { + return CircleLayerProperties( + circleRadius: changes.circleRadius ?? circleRadius, + circleColor: changes.circleColor ?? circleColor, + circleBlur: changes.circleBlur ?? circleBlur, + circleOpacity: changes.circleOpacity ?? circleOpacity, + circleTranslate: changes.circleTranslate ?? circleTranslate, + circleTranslateAnchor: + changes.circleTranslateAnchor ?? circleTranslateAnchor, + circlePitchScale: changes.circlePitchScale ?? circlePitchScale, + circlePitchAlignment: + changes.circlePitchAlignment ?? circlePitchAlignment, + circleStrokeWidth: changes.circleStrokeWidth ?? circleStrokeWidth, + circleStrokeColor: changes.circleStrokeColor ?? circleStrokeColor, + circleStrokeOpacity: changes.circleStrokeOpacity ?? circleStrokeOpacity, + circleSortKey: changes.circleSortKey ?? circleSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('circle-radius', circleRadius); + addIfPresent('circle-color', circleColor); + addIfPresent('circle-blur', circleBlur); + addIfPresent('circle-opacity', circleOpacity); + addIfPresent('circle-translate', circleTranslate); + addIfPresent('circle-translate-anchor', circleTranslateAnchor); + addIfPresent('circle-pitch-scale', circlePitchScale); + addIfPresent('circle-pitch-alignment', circlePitchAlignment); + addIfPresent('circle-stroke-width', circleStrokeWidth); + addIfPresent('circle-stroke-color', circleStrokeColor); + addIfPresent('circle-stroke-opacity', circleStrokeOpacity); + addIfPresent('circle-sort-key', circleSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory CircleLayerProperties.fromJson(Map json) { + return CircleLayerProperties( + circleRadius: json['circle-radius'], + circleColor: json['circle-color'], + circleBlur: json['circle-blur'], + circleOpacity: json['circle-opacity'], + circleTranslate: json['circle-translate'], + circleTranslateAnchor: json['circle-translate-anchor'], + circlePitchScale: json['circle-pitch-scale'], + circlePitchAlignment: json['circle-pitch-alignment'], + circleStrokeWidth: json['circle-stroke-width'], + circleStrokeColor: json['circle-stroke-color'], + circleStrokeOpacity: json['circle-stroke-opacity'], + circleSortKey: json['circle-sort-key'], + visibility: json['visibility'], + ); + } +} + +class LineLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the line will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOpacity; + + /// The color with which the line will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslate; + + /// Controls the frame of reference for `line-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The line is translated relative to the map. + /// "viewport" + /// The line is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslateAnchor; + + /// Stroke thickness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineWidth; + + /// Draws a line casing outside of a line's actual path. Value indicates + /// the width of the inner gap. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineGapWidth; + + /// The line's offset. For linear features, a positive value offsets the + /// line to the right, relative to the direction of the line, and a + /// negative value to the left. For polygon features, a positive value + /// results in an inset, and a negative value results in an outset. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOffset; + + /// Blur applied to the line, in pixels. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineBlur; + + /// Specifies the lengths of the alternating dashes and gaps that form the + /// dash pattern. The lengths are later scaled by the line width. To + /// convert a dash length to pixels, multiply the length by the current + /// line width. Note that GeoJSON sources with `lineMetrics: true` + /// specified won't render dashed lines to the expected scale. Also note + /// that zoom-dependent expressions will be evaluated only at integer zoom + /// levels. + /// + /// Type: array + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineDasharray; + + /// Name of image in sprite to use for drawing image lines. For seamless + /// patterns, image width must be a factor of two (2, 4, 8, ..., 512). + /// Note that zoom-dependent expressions will be evaluated only at integer + /// zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic linePattern; + + /// Defines a gradient with which to color a line feature. Can only be + /// used with GeoJSON sources that specify `"lineMetrics": true`. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineGradient; + + // Layout Properties + /// The display of line endings. + /// + /// Type: enum + /// default: butt + /// Options: + /// "butt" + /// A cap with a squared-off end which is drawn to the exact endpoint + /// of the line. + /// "round" + /// A cap with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "square" + /// A cap with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineCap; + + /// The display of lines when joining. + /// + /// Type: enum + /// default: miter + /// Options: + /// "bevel" + /// A join with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// "round" + /// A join with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "miter" + /// A join with a sharp, angled corner which is drawn with the outer + /// sides beyond the endpoint of the path until they meet. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineJoin; + + /// Used to automatically convert miter joins to bevel joins for sharp + /// angles. + /// + /// Type: number + /// default: 2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineMiterLimit; + + /// Used to automatically convert round joins to miter joins for shallow + /// angles. + /// + /// Type: number + /// default: 1.05 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineRoundLimit; + + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic lineSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const LineLayerProperties({ + this.lineOpacity, + this.lineColor, + this.lineTranslate, + this.lineTranslateAnchor, + this.lineWidth, + this.lineGapWidth, + this.lineOffset, + this.lineBlur, + this.lineDasharray, + this.linePattern, + this.lineGradient, + this.lineCap, + this.lineJoin, + this.lineMiterLimit, + this.lineRoundLimit, + this.lineSortKey, + this.visibility, + }); + + LineLayerProperties copyWith(LineLayerProperties changes) { + return LineLayerProperties( + lineOpacity: changes.lineOpacity ?? lineOpacity, + lineColor: changes.lineColor ?? lineColor, + lineTranslate: changes.lineTranslate ?? lineTranslate, + lineTranslateAnchor: changes.lineTranslateAnchor ?? lineTranslateAnchor, + lineWidth: changes.lineWidth ?? lineWidth, + lineGapWidth: changes.lineGapWidth ?? lineGapWidth, + lineOffset: changes.lineOffset ?? lineOffset, + lineBlur: changes.lineBlur ?? lineBlur, + lineDasharray: changes.lineDasharray ?? lineDasharray, + linePattern: changes.linePattern ?? linePattern, + lineGradient: changes.lineGradient ?? lineGradient, + lineCap: changes.lineCap ?? lineCap, + lineJoin: changes.lineJoin ?? lineJoin, + lineMiterLimit: changes.lineMiterLimit ?? lineMiterLimit, + lineRoundLimit: changes.lineRoundLimit ?? lineRoundLimit, + lineSortKey: changes.lineSortKey ?? lineSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('line-opacity', lineOpacity); + addIfPresent('line-color', lineColor); + addIfPresent('line-translate', lineTranslate); + addIfPresent('line-translate-anchor', lineTranslateAnchor); + addIfPresent('line-width', lineWidth); + addIfPresent('line-gap-width', lineGapWidth); + addIfPresent('line-offset', lineOffset); + addIfPresent('line-blur', lineBlur); + addIfPresent('line-dasharray', lineDasharray); + addIfPresent('line-pattern', linePattern); + addIfPresent('line-gradient', lineGradient); + addIfPresent('line-cap', lineCap); + addIfPresent('line-join', lineJoin); + addIfPresent('line-miter-limit', lineMiterLimit); + addIfPresent('line-round-limit', lineRoundLimit); + addIfPresent('line-sort-key', lineSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory LineLayerProperties.fromJson(Map json) { + return LineLayerProperties( + lineOpacity: json['line-opacity'], + lineColor: json['line-color'], + lineTranslate: json['line-translate'], + lineTranslateAnchor: json['line-translate-anchor'], + lineWidth: json['line-width'], + lineGapWidth: json['line-gap-width'], + lineOffset: json['line-offset'], + lineBlur: json['line-blur'], + lineDasharray: json['line-dasharray'], + linePattern: json['line-pattern'], + lineGradient: json['line-gradient'], + lineCap: json['line-cap'], + lineJoin: json['line-join'], + lineMiterLimit: json['line-miter-limit'], + lineRoundLimit: json['line-round-limit'], + lineSortKey: json['line-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillLayerProperties implements LayerProperties { + // Paint Properties + /// Whether or not the fill should be antialiased. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillAntialias; + + /// The opacity of the entire fill layer. In contrast to the `fill-color`, + /// this value will also affect the 1px stroke around the fill, if the + /// stroke is used. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOpacity; + + /// The color of the filled part of this layer. This color can be + /// specified as `rgba` with an alpha component and the color's opacity + /// will not affect the opacity of the 1px stroke, if it is used. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillColor; + + /// The outline color of the fill. Matches the value of `fill-color` if + /// unspecified. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOutlineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslate; + + /// Controls the frame of reference for `fill-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill is translated relative to the map. + /// "viewport" + /// The fill is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslateAnchor; + + /// Name of image in sprite to use for drawing image fills. For seamless + /// patterns, image width and height must be a factor of two (2, 4, 8, + /// ..., 512). Note that zoom-dependent expressions will be evaluated only + /// at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillPattern; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic fillSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillLayerProperties({ + this.fillAntialias, + this.fillOpacity, + this.fillColor, + this.fillOutlineColor, + this.fillTranslate, + this.fillTranslateAnchor, + this.fillPattern, + this.fillSortKey, + this.visibility, + }); + + FillLayerProperties copyWith(FillLayerProperties changes) { + return FillLayerProperties( + fillAntialias: changes.fillAntialias ?? fillAntialias, + fillOpacity: changes.fillOpacity ?? fillOpacity, + fillColor: changes.fillColor ?? fillColor, + fillOutlineColor: changes.fillOutlineColor ?? fillOutlineColor, + fillTranslate: changes.fillTranslate ?? fillTranslate, + fillTranslateAnchor: changes.fillTranslateAnchor ?? fillTranslateAnchor, + fillPattern: changes.fillPattern ?? fillPattern, + fillSortKey: changes.fillSortKey ?? fillSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('fill-antialias', fillAntialias); + addIfPresent('fill-opacity', fillOpacity); + addIfPresent('fill-color', fillColor); + addIfPresent('fill-outline-color', fillOutlineColor); + addIfPresent('fill-translate', fillTranslate); + addIfPresent('fill-translate-anchor', fillTranslateAnchor); + addIfPresent('fill-pattern', fillPattern); + addIfPresent('fill-sort-key', fillSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory FillLayerProperties.fromJson(Map json) { + return FillLayerProperties( + fillAntialias: json['fill-antialias'], + fillOpacity: json['fill-opacity'], + fillColor: json['fill-color'], + fillOutlineColor: json['fill-outline-color'], + fillTranslate: json['fill-translate'], + fillTranslateAnchor: json['fill-translate-anchor'], + fillPattern: json['fill-pattern'], + fillSortKey: json['fill-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillExtrusionLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity of the entire fill extrusion layer. This is rendered on a + /// per-layer, not per-feature, basis, and data-driven styling is not + /// available. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionOpacity; + + /// The base color of the extruded fill. The extrusion's surfaces will be + /// shaded differently based on this color in combination with the root + /// `light` settings. If this color is specified as `rgba` with an alpha + /// component, the alpha component will be ignored; use + /// `fill-extrusion-opacity` to set layer opacity. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up (on the flat plane), respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslate; + + /// Controls the frame of reference for `fill-extrusion-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill extrusion is translated relative to the map. + /// "viewport" + /// The fill extrusion is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslateAnchor; + + /// Name of image in sprite to use for drawing images on extruded fills. + /// For seamless patterns, image width and height must be a factor of two + /// (2, 4, 8, ..., 512). Note that zoom-dependent expressions will be + /// evaluated only at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillExtrusionPattern; + + /// The height with which to extrude this layer. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionHeight; + + /// The height with which to extrude the base of this layer. Must be less + /// than or equal to `fill-extrusion-height`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionBase; + + /// Whether to apply a vertical gradient to the sides of a fill-extrusion + /// layer. If true, sides will be shaded slightly darker farther down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, ios, macos + final dynamic fillExtrusionVerticalGradient; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillExtrusionLayerProperties({ + this.fillExtrusionOpacity, + this.fillExtrusionColor, + this.fillExtrusionTranslate, + this.fillExtrusionTranslateAnchor, + this.fillExtrusionPattern, + this.fillExtrusionHeight, + this.fillExtrusionBase, + this.fillExtrusionVerticalGradient, + this.visibility, + }); + + FillExtrusionLayerProperties copyWith(FillExtrusionLayerProperties changes) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: + changes.fillExtrusionOpacity ?? fillExtrusionOpacity, + fillExtrusionColor: changes.fillExtrusionColor ?? fillExtrusionColor, + fillExtrusionTranslate: + changes.fillExtrusionTranslate ?? fillExtrusionTranslate, + fillExtrusionTranslateAnchor: + changes.fillExtrusionTranslateAnchor ?? fillExtrusionTranslateAnchor, + fillExtrusionPattern: + changes.fillExtrusionPattern ?? fillExtrusionPattern, + fillExtrusionHeight: changes.fillExtrusionHeight ?? fillExtrusionHeight, + fillExtrusionBase: changes.fillExtrusionBase ?? fillExtrusionBase, + fillExtrusionVerticalGradient: changes.fillExtrusionVerticalGradient ?? + fillExtrusionVerticalGradient, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('fill-extrusion-opacity', fillExtrusionOpacity); + addIfPresent('fill-extrusion-color', fillExtrusionColor); + addIfPresent('fill-extrusion-translate', fillExtrusionTranslate); + addIfPresent( + 'fill-extrusion-translate-anchor', fillExtrusionTranslateAnchor); + addIfPresent('fill-extrusion-pattern', fillExtrusionPattern); + addIfPresent('fill-extrusion-height', fillExtrusionHeight); + addIfPresent('fill-extrusion-base', fillExtrusionBase); + addIfPresent( + 'fill-extrusion-vertical-gradient', fillExtrusionVerticalGradient); + addIfPresent('visibility', visibility); + return json; + } + + factory FillExtrusionLayerProperties.fromJson(Map json) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: json['fill-extrusion-opacity'], + fillExtrusionColor: json['fill-extrusion-color'], + fillExtrusionTranslate: json['fill-extrusion-translate'], + fillExtrusionTranslateAnchor: json['fill-extrusion-translate-anchor'], + fillExtrusionPattern: json['fill-extrusion-pattern'], + fillExtrusionHeight: json['fill-extrusion-height'], + fillExtrusionBase: json['fill-extrusion-base'], + fillExtrusionVerticalGradient: json['fill-extrusion-vertical-gradient'], + visibility: json['visibility'], + ); + } +} + +class RasterLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the image will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterOpacity; + + /// Rotates hues around the color wheel. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterHueRotate; + + /// Increase or reduce the brightness of the image. The value is the + /// minimum brightness. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMin; + + /// Increase or reduce the brightness of the image. The value is the + /// maximum brightness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMax; + + /// Increase or reduce the saturation of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterSaturation; + + /// Increase or reduce the contrast of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterContrast; + + /// The resampling/interpolation method to use for overscaling, also known + /// as texture magnification filter + /// + /// Type: enum + /// default: linear + /// Options: + /// "linear" + /// (Bi)linear filtering interpolates pixel values using the weighted + /// average of the four closest original source pixels creating a + /// smooth but blurry look when overscaled + /// "nearest" + /// Nearest neighbor filtering interpolates pixel values using the + /// nearest original source pixel creating a sharp but pixelated look + /// when overscaled + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterResampling; + + /// Fade duration when a new tile is added. + /// + /// Type: number + /// default: 300 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterFadeDuration; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const RasterLayerProperties({ + this.rasterOpacity, + this.rasterHueRotate, + this.rasterBrightnessMin, + this.rasterBrightnessMax, + this.rasterSaturation, + this.rasterContrast, + this.rasterResampling, + this.rasterFadeDuration, + this.visibility, + }); + + RasterLayerProperties copyWith(RasterLayerProperties changes) { + return RasterLayerProperties( + rasterOpacity: changes.rasterOpacity ?? rasterOpacity, + rasterHueRotate: changes.rasterHueRotate ?? rasterHueRotate, + rasterBrightnessMin: changes.rasterBrightnessMin ?? rasterBrightnessMin, + rasterBrightnessMax: changes.rasterBrightnessMax ?? rasterBrightnessMax, + rasterSaturation: changes.rasterSaturation ?? rasterSaturation, + rasterContrast: changes.rasterContrast ?? rasterContrast, + rasterResampling: changes.rasterResampling ?? rasterResampling, + rasterFadeDuration: changes.rasterFadeDuration ?? rasterFadeDuration, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('raster-opacity', rasterOpacity); + addIfPresent('raster-hue-rotate', rasterHueRotate); + addIfPresent('raster-brightness-min', rasterBrightnessMin); + addIfPresent('raster-brightness-max', rasterBrightnessMax); + addIfPresent('raster-saturation', rasterSaturation); + addIfPresent('raster-contrast', rasterContrast); + addIfPresent('raster-resampling', rasterResampling); + addIfPresent('raster-fade-duration', rasterFadeDuration); + addIfPresent('visibility', visibility); + return json; + } + + factory RasterLayerProperties.fromJson(Map json) { + return RasterLayerProperties( + rasterOpacity: json['raster-opacity'], + rasterHueRotate: json['raster-hue-rotate'], + rasterBrightnessMin: json['raster-brightness-min'], + rasterBrightnessMax: json['raster-brightness-max'], + rasterSaturation: json['raster-saturation'], + rasterContrast: json['raster-contrast'], + rasterResampling: json['raster-resampling'], + rasterFadeDuration: json['raster-fade-duration'], + visibility: json['visibility'], + ); + } +} + +class HillshadeLayerProperties implements LayerProperties { + // Paint Properties + /// The direction of the light source used to generate the hillshading + /// with 0 as the top of the viewport if `hillshade-illumination-anchor` + /// is set to `viewport` and due north if `hillshade-illumination-anchor` + /// is set to `map`. + /// + /// Type: number + /// default: 335 + /// minimum: 0 + /// maximum: 359 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationDirection; + + /// Direction of light source when map is rotated. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The hillshade illumination is relative to the north direction. + /// "viewport" + /// The hillshade illumination is relative to the top of the + /// viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationAnchor; + + /// Intensity of the hillshade + /// + /// Type: number + /// default: 0.5 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeExaggeration; + + /// The shading color of areas that face away from the light source. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeShadowColor; + + /// The shading color of areas that faces towards the light source. + /// + /// Type: color + /// default: #FFFFFF + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeHighlightColor; + + /// The shading color used to accentuate rugged terrain like sharp cliffs + /// and gorges. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeAccentColor; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HillshadeLayerProperties({ + this.hillshadeIlluminationDirection, + this.hillshadeIlluminationAnchor, + this.hillshadeExaggeration, + this.hillshadeShadowColor, + this.hillshadeHighlightColor, + this.hillshadeAccentColor, + this.visibility, + }); + + HillshadeLayerProperties copyWith(HillshadeLayerProperties changes) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: changes.hillshadeIlluminationDirection ?? + hillshadeIlluminationDirection, + hillshadeIlluminationAnchor: + changes.hillshadeIlluminationAnchor ?? hillshadeIlluminationAnchor, + hillshadeExaggeration: + changes.hillshadeExaggeration ?? hillshadeExaggeration, + hillshadeShadowColor: + changes.hillshadeShadowColor ?? hillshadeShadowColor, + hillshadeHighlightColor: + changes.hillshadeHighlightColor ?? hillshadeHighlightColor, + hillshadeAccentColor: + changes.hillshadeAccentColor ?? hillshadeAccentColor, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent( + 'hillshade-illumination-direction', hillshadeIlluminationDirection); + addIfPresent('hillshade-illumination-anchor', hillshadeIlluminationAnchor); + addIfPresent('hillshade-exaggeration', hillshadeExaggeration); + addIfPresent('hillshade-shadow-color', hillshadeShadowColor); + addIfPresent('hillshade-highlight-color', hillshadeHighlightColor); + addIfPresent('hillshade-accent-color', hillshadeAccentColor); + addIfPresent('visibility', visibility); + return json; + } + + factory HillshadeLayerProperties.fromJson(Map json) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: json['hillshade-illumination-direction'], + hillshadeIlluminationAnchor: json['hillshade-illumination-anchor'], + hillshadeExaggeration: json['hillshade-exaggeration'], + hillshadeShadowColor: json['hillshade-shadow-color'], + hillshadeHighlightColor: json['hillshade-highlight-color'], + hillshadeAccentColor: json['hillshade-accent-color'], + visibility: json['visibility'], + ); + } +} + +class HeatmapLayerProperties implements LayerProperties { + // Paint Properties + /// Radius of influence of one heatmap point in pixels. Increasing the + /// value makes the heatmap smoother, but less detailed. + /// + /// Type: number + /// default: 30 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapRadius; + + /// A measure of how much an individual point contributes to the heatmap. + /// A value of 10 would be equivalent to having 10 points of weight 1 in + /// the same spot. Especially useful when combined with clustering. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapWeight; + + /// Similar to `heatmap-weight` but controls the intensity of the heatmap + /// globally. Primarily used for adjusting the heatmap based on zoom + /// level. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapIntensity; + + /// Defines the color of each pixel based on its density value in a + /// heatmap. Should be an expression that uses `["heatmap-density"]` as + /// input. + /// + /// Type: color + /// default: [interpolate, [linear], [heatmap-density], 0, rgba(0, 0, 255, 0), 0.1, royalblue, 0.3, cyan, 0.5, lime, 0.7, yellow, 1, red] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapColor; + + /// The global opacity at which the heatmap layer will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapOpacity; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HeatmapLayerProperties({ + this.heatmapRadius, + this.heatmapWeight, + this.heatmapIntensity, + this.heatmapColor, + this.heatmapOpacity, + this.visibility, + }); + + HeatmapLayerProperties copyWith(HeatmapLayerProperties changes) { + return HeatmapLayerProperties( + heatmapRadius: changes.heatmapRadius ?? heatmapRadius, + heatmapWeight: changes.heatmapWeight ?? heatmapWeight, + heatmapIntensity: changes.heatmapIntensity ?? heatmapIntensity, + heatmapColor: changes.heatmapColor ?? heatmapColor, + heatmapOpacity: changes.heatmapOpacity ?? heatmapOpacity, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('heatmap-radius', heatmapRadius); + addIfPresent('heatmap-weight', heatmapWeight); + addIfPresent('heatmap-intensity', heatmapIntensity); + addIfPresent('heatmap-color', heatmapColor); + addIfPresent('heatmap-opacity', heatmapOpacity); + addIfPresent('visibility', visibility); + return json; + } + + factory HeatmapLayerProperties.fromJson(Map json) { + return HeatmapLayerProperties( + heatmapRadius: json['heatmap-radius'], + heatmapWeight: json['heatmap-weight'], + heatmapIntensity: json['heatmap-intensity'], + heatmapColor: json['heatmap-color'], + heatmapOpacity: json['heatmap-opacity'], + visibility: json['visibility'], + ); + } +} diff --git a/third_party/maplibre_gl/lib/src/maplibre_map.dart b/third_party/maplibre_gl/lib/src/maplibre_map.dart new file mode 100644 index 00000000..87f4ba50 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/maplibre_map.dart @@ -0,0 +1,563 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +enum AnnotationType { fill, line, circle, symbol } + +typedef MapCreatedCallback = void Function(MapLibreMapController controller); + +@Deprecated('MaplibreMap was renamed to MapLibreMap. ') +typedef MaplibreMap = MapLibreMap; + +/// Shows a MapLibre map. +/// Also refer to the documentation of [maplibre_gl] and [MapLibreMapController]. +class MapLibreMap extends StatefulWidget { + const MapLibreMap({ + super.key, + required this.initialCameraPosition, + this.styleString = MapLibreStyles.demo, + this.onMapCreated, + this.onStyleLoadedCallback, + this.locationEnginePlatforms = LocationEnginePlatforms.defaultPlatform, + this.gestureRecognizers, + this.compassEnabled = true, + this.cameraTargetBounds = CameraTargetBounds.unbounded, + this.minMaxZoomPreference = MinMaxZoomPreference.unbounded, + this.rotateGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.zoomGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.doubleClickZoomEnabled, + this.dragEnabled = true, + this.trackCameraPosition = false, + this.myLocationEnabled = false, + this.myLocationTrackingMode = MyLocationTrackingMode.none, + this.myLocationRenderMode = MyLocationRenderMode.normal, + this.logoEnabled = false, + this.logoViewPosition, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition = AttributionButtonPosition.bottomRight, + this.attributionButtonMargins, + this.iosLongClickDuration, + this.webPreserveDrawingBuffer = false, + this.onMapClick, + this.onUserLocationUpdated, + this.onMapLongClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onCameraMove, + this.onCameraIdle, + this.onMapIdle, + this.annotationOrder = const [ + AnnotationType.line, + AnnotationType.symbol, + AnnotationType.circle, + AnnotationType.fill, + ], + this.annotationConsumeTapEvents = const [ + AnnotationType.symbol, + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + ], + this.foregroundLoadColor = Colors.transparent, + this.translucentTextureSurface = false, + }) : assert( + myLocationRenderMode == MyLocationRenderMode.normal || + myLocationEnabled, + "$myLocationRenderMode requires [myLocationEnabled] set to true.", + ), + assert(annotationOrder.length <= 4), + assert(annotationConsumeTapEvents.length > 0); + + /// The properties for the platform-specific location engine. + /// Only has an impact if [myLocationEnabled] is set to true. + final LocationEnginePlatforms locationEnginePlatforms; + + /// The color used for the map loading foreground. + /// Pass a [Color] and it will be converted to ARGB int for the platform. + /// + /// **Available only on Android. Has no effect on iOS or Web.** + final Color? foregroundLoadColor; + + /// Enable translucent texture surface for the map. + /// This allows the map to have a transparent background, useful for overlay scenarios. + /// + /// **Available only on Android. Has no effect on iOS or Web.** + final bool translucentTextureSurface; + + /// Defines the layer order of annotations displayed on map. + /// Order them from bottom to top. Bottom annotation will be rendered first. + /// + /// Any annotation type can only be contained once, so 0 to 4 types. + /// + /// Note that setting this to be empty gives a big perfomance boost for + /// android. However if you do so annotations will not work. + final List annotationOrder; + + /// Defines the layer order of click annotations + /// + /// (must contain at least 1 annotation type, 4 items max) + final List annotationConsumeTapEvents; + + /// Please note: you should only add annotations (e.g. symbols or circles) after `onStyleLoadedCallback` has been called. + final MapCreatedCallback? onMapCreated; + + /// Called when the map style has been successfully loaded and the annotation managers have been enabled. + /// Please note: you should only add annotations (e.g. symbols or circles) after this callback has been called. + final OnStyleLoadedCallback? onStyleLoadedCallback; + + /// The initial position of the map's camera. + final CameraPosition initialCameraPosition; + + /// How long a user has to click the map **on iOS** until a long click is registered. + /// Has no effect on web or Android. Can not be changed at runtime, only the initial value is used. + /// If null, the default value of the native MapLibre library / of the OS is used. + final Duration? iosLongClickDuration; + + /// If true, the map's canvas can be exported to a PNG using map.getCanvas().toDataURL(). + /// This is false by default as a performance optimization. + /// **Web only** - has no effect on other platforms. + final bool? webPreserveDrawingBuffer; + + /// True if the map should show a compass when rotated. + final bool compassEnabled; + + /// True if drag functionality should be enabled. + /// + /// Disable to avoid performance issues that from the drag event listeners. + /// Biggest impact in android + final bool dragEnabled; + + /// Geographical bounding box for the camera target. + final CameraTargetBounds cameraTargetBounds; + + /// A MapLibre GL style document defining the map's appearance. + /// The style document specification is at [https://maplibre.org/maplibre-style-spec]. + /// A short introduction can be found in the documentation of the [maplibre_gl] library. + /// The following formats are supported: + /// + /// 1. Passing the URL of the map style. This should be a custom map style served remotely using a URL that start with 'http(s)://' + /// 2. Passing the style as a local asset. Create a JSON file in the `assets` and add a reference in `pubspec.yml`. Set the style string to the relative path for this asset in order to load it into the map. + /// 3. Passing the style as a local file. create an JSON file in app directory (e.g. ApplicationDocumentsDirectory). Set the style string to the absolute path of this JSON file. + /// 4. Passing the raw JSON of the map style. + final String styleString; + + /// Preferred bounds for the camera zoom level. + /// + /// Actual bounds depend on map data and device. + final MinMaxZoomPreference minMaxZoomPreference; + + /// True if the map view should respond to rotate gestures. + final bool rotateGesturesEnabled; + + /// True if the map view should respond to scroll gestures. + final bool scrollGesturesEnabled; + + /// True if the map view should respond to zoom gestures. + final bool zoomGesturesEnabled; + + /// True if the map view should respond to tilt gestures. + final bool tiltGesturesEnabled; + + /// Set to true to forcefully disable/enable if map should respond to double + /// click to zoom. + /// + /// This takes presedence over zoomGesturesEnabled. Only supported for web. + final bool? doubleClickZoomEnabled; + + /// True if you want to be notified of map camera movements by the [MapLibreMapController]. Default is false. + /// + /// If this is set to true and the user pans/zooms/rotates the map, [MapLibreMapController] (which is a [ChangeNotifier]) + /// will notify it's listeners and you can then get the new [MapLibreMapController].cameraPosition. + final bool trackCameraPosition; + + /// True if a "My Location" layer should be shown on the map. + /// + /// This layer includes a location indicator at the current device location, + /// as well as a My Location button. + /// * The indicator is a small blue dot if the device is stationary, or a + /// chevron if the device is moving. + /// * The My Location button animates to focus on the user's current location + /// if the user's location is currently known. + /// + /// Enabling this feature requires adding location permissions to both native + /// platforms of your app. + /// * On Android add either + /// `` + /// or `` + /// to your `AndroidManifest.xml` file. `ACCESS_COARSE_LOCATION` returns a + /// location with an accuracy approximately equivalent to a city block, while + /// `ACCESS_FINE_LOCATION` returns as precise a location as possible, although + /// it consumes more battery power. You will also need to request these + /// permissions during run-time. If they are not granted, the My Location + /// feature will fail silently. + /// * On iOS add a `NSLocationWhenInUseUsageDescription` key to your + /// `Info.plist` file. This will automatically prompt the user for permissions + /// when the map tries to turn on the My Location layer. + final bool myLocationEnabled; + + /// The mode used to let the map's camera follow the device's physical location. + /// `myLocationEnabled` needs to be true for values other than `MyLocationTrackingMode.None` to work. + final MyLocationTrackingMode myLocationTrackingMode; + + /// Specifies if and how the user's heading/bearing is rendered in the user location indicator. + /// See the documentation of [MyLocationRenderMode] for details. + /// If this is set to a value other than [MyLocationRenderMode.normal], [myLocationEnabled] needs to be true. + final MyLocationRenderMode myLocationRenderMode; + + /// True if the MapLibre logo should be shown on the map. + /// Defaults to false. + final bool logoEnabled; + + /// Set the position for the Logo + final LogoViewPosition? logoViewPosition; + + /// Set the layout margins for the Logo + final Point? logoViewMargins; + + /// Set the position for the Compass + final CompassViewPosition? compassViewPosition; + + /// Set the layout margins for the Compass + final Point? compassViewMargins; + + /// Set the position for the MapLibre Attribution Button + /// When set to null, the default value of the underlying MapLibre libraries is used, + /// which differs depending on the operating system the app is being run on. + final AttributionButtonPosition? attributionButtonPosition; + + /// Set the layout margins for the MapLibre Attribution Buttons. If you set this + /// value, you may also want to set [attributionButtonPosition] to harmonize + /// the layout between iOS and Android, since the underlying frameworks have + /// different defaults. + final Point? attributionButtonMargins; + + /// Which gestures should be consumed by the map. + /// + /// It is possible for other gesture recognizers to be competing with the map on pointer + /// events, e.g if the map is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The map will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the map will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + final OnMapClickCallback? onMapClick; + final OnMapClickCallback? onMapLongClick; + + /// While the `myLocationEnabled` property is set to `true`, this method is + /// called whenever a new location update is received by the map view. + final OnUserLocationUpdated? onUserLocationUpdated; + + /// Called when the map's camera no longer follows the physical device location, e.g. because the user moved the map + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + + /// Called when the location tracking mode changes + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + /// Called when camera is moving. + final OnCameraMoveCallback? onCameraMove; + + /// Called when camera movement has ended. + final OnCameraIdleCallback? onCameraIdle; + + /// Called when map view is entering an idle state, and no more drawing will + /// be necessary until new data is loaded or there is some interaction with + /// the map. + /// * No camera transitions are in progress + /// * All currently requested tiles have loaded + /// * All fade/transition animations have completed + final OnMapIdleCallback? onMapIdle; + + /// Set `MapLibreMap.useHybridComposition` to `false` in order use Virtual-Display + /// (better for Android 9 and below but may result in errors on Android 12) + /// or leave it `true` (default) to use Hybrid composition (Slower on Android 9 and below). + static bool get useHybridComposition => + MapLibreMethodChannel.useHybridComposition; + + static set useHybridComposition(bool useHybridComposition) => + MapLibreMethodChannel.useHybridComposition = useHybridComposition; + + @override + State createState() => _MapLibreMapState(); +} + +class _MapLibreMapState extends State { + final Completer _controller = + Completer(); + MapLibreMapController? _mapController; + + late _MapLibreMapOptions _maplibreMapOptions; + final MapLibrePlatform _maplibrePlatform = MapLibrePlatform.createInstance(); + + @override + Widget build(BuildContext context) { + assert( + widget.annotationOrder.toSet().length == widget.annotationOrder.length, + "annotationOrder must not have duplicate types"); + final creationParams = { + 'initialCameraPosition': widget.initialCameraPosition.toMap(), + 'styleString': widget.styleString, + 'options': _MapLibreMapOptions.fromWidget(widget).toMap(), + 'dragEnabled': widget.dragEnabled, + if (widget.iosLongClickDuration != null) + 'iosLongClickDurationMilliseconds': + widget.iosLongClickDuration!.inMilliseconds, + if (widget.webPreserveDrawingBuffer != null) + 'webPreserveDrawingBuffer': widget.webPreserveDrawingBuffer, + }; + return _maplibrePlatform.buildView( + creationParams, onPlatformViewCreated, widget.gestureRecognizers); + } + + @override + void initState() { + super.initState(); + _maplibreMapOptions = _MapLibreMapOptions.fromWidget(widget); + } + + @override + void dispose() { + if (_controller.isCompleted) { + _mapController?.dispose(); + } + + super.dispose(); + } + + @override + void didUpdateWidget(MapLibreMap oldWidget) { + super.didUpdateWidget(oldWidget); + final newOptions = _MapLibreMapOptions.fromWidget(widget); + final updates = _maplibreMapOptions.updatesMap(newOptions); + + if (updates.isNotEmpty) { + // Intentionally not awaited: updating map options asynchronously to avoid blocking widget update. + unawaited(_updateOptions(updates)); + } + _maplibreMapOptions = newOptions; + } + + Future _updateOptions(Map updates) async { + if (updates.isEmpty) { + return; + } + final controller = await _controller.future; + await controller._updateMapOptions(updates); + } + + Future onPlatformViewCreated(int id) async { + final controller = MapLibreMapController( + maplibrePlatform: _maplibrePlatform, + initialCameraPosition: widget.initialCameraPosition, + onStyleLoadedCallback: () async { + if (_controller.isCompleted) { + widget.onStyleLoadedCallback?.call(); + } else { + await _controller.future + .then((_) => widget.onStyleLoadedCallback?.call()); + } + }, + onMapClick: widget.onMapClick, + onUserLocationUpdated: widget.onUserLocationUpdated, + onMapLongClick: widget.onMapLongClick, + onCameraTrackingDismissed: widget.onCameraTrackingDismissed, + onCameraTrackingChanged: widget.onCameraTrackingChanged, + onCameraMove: widget.onCameraMove, + onCameraIdle: widget.onCameraIdle, + onMapIdle: widget.onMapIdle, + annotationOrder: widget.annotationOrder, + annotationConsumeTapEvents: widget.annotationConsumeTapEvents, + ); + await _maplibrePlatform.initPlatform(id); + _mapController = controller; + _controller.complete(controller); + widget.onMapCreated?.call(controller); + } +} + +/// Configuration options for the MapLibreMap user interface. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class _MapLibreMapOptions { + _MapLibreMapOptions( + {this.compassEnabled, + this.cameraTargetBounds, + this.styleString, + this.minMaxZoomPreference, + required this.rotateGesturesEnabled, + required this.scrollGesturesEnabled, + required this.tiltGesturesEnabled, + required this.zoomGesturesEnabled, + required this.doubleClickZoomEnabled, + this.trackCameraPosition, + this.myLocationEnabled, + this.myLocationTrackingMode, + this.myLocationRenderMode, + this.logoEnabled, + this.logoViewPosition, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition, + this.attributionButtonMargins, + this.locationEnginePlatforms, + this.foregroundLoadColor, + this.translucentTextureSurface}); + + _MapLibreMapOptions.fromWidget(MapLibreMap map) + : this( + locationEnginePlatforms: map.locationEnginePlatforms, + compassEnabled: map.compassEnabled, + cameraTargetBounds: map.cameraTargetBounds, + styleString: map.styleString, + minMaxZoomPreference: map.minMaxZoomPreference, + rotateGesturesEnabled: map.rotateGesturesEnabled, + scrollGesturesEnabled: map.scrollGesturesEnabled, + tiltGesturesEnabled: map.tiltGesturesEnabled, + trackCameraPosition: map.trackCameraPosition, + zoomGesturesEnabled: map.zoomGesturesEnabled, + doubleClickZoomEnabled: + map.doubleClickZoomEnabled ?? map.zoomGesturesEnabled, + myLocationEnabled: map.myLocationEnabled, + myLocationTrackingMode: map.myLocationTrackingMode, + myLocationRenderMode: map.myLocationRenderMode, + logoEnabled: map.logoEnabled, + logoViewPosition: map.logoViewPosition, + logoViewMargins: map.logoViewMargins, + compassViewPosition: map.compassViewPosition, + compassViewMargins: map.compassViewMargins, + attributionButtonPosition: map.attributionButtonPosition, + attributionButtonMargins: map.attributionButtonMargins, + foregroundLoadColor: map.foregroundLoadColor, + translucentTextureSurface: map.translucentTextureSurface, + ); + + final bool? compassEnabled; + + final CameraTargetBounds? cameraTargetBounds; + + final String? styleString; + + final MinMaxZoomPreference? minMaxZoomPreference; + + final bool rotateGesturesEnabled; + + final bool scrollGesturesEnabled; + + final bool tiltGesturesEnabled; + + final bool zoomGesturesEnabled; + + final bool doubleClickZoomEnabled; + + final bool? trackCameraPosition; + + final bool? myLocationEnabled; + + final MyLocationTrackingMode? myLocationTrackingMode; + + final MyLocationRenderMode? myLocationRenderMode; + + final bool? logoEnabled; + + final LogoViewPosition? logoViewPosition; + + final Point? logoViewMargins; + + final CompassViewPosition? compassViewPosition; + + final Point? compassViewMargins; + + final AttributionButtonPosition? attributionButtonPosition; + + final Point? attributionButtonMargins; + + final LocationEnginePlatforms? locationEnginePlatforms; + + final Color? foregroundLoadColor; + + final bool? translucentTextureSurface; + + final _gestureGroup = { + 'rotateGesturesEnabled', + 'scrollGesturesEnabled', + 'tiltGesturesEnabled', + 'zoomGesturesEnabled', + 'doubleClickZoomEnabled' + }; + + Map toMap() { + final optionsMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + optionsMap[fieldName] = value; + } + } + + List? pointToArray(Point? fieldName) { + if (fieldName != null) { + return [fieldName.x, fieldName.y]; + } + + return null; + } + + addIfNonNull('compassEnabled', compassEnabled); + addIfNonNull('cameraTargetBounds', cameraTargetBounds?.toJson()); + addIfNonNull('styleString', styleString); + addIfNonNull('minMaxZoomPreference', minMaxZoomPreference?.toJson()); + + addIfNonNull('rotateGesturesEnabled', rotateGesturesEnabled); + addIfNonNull('scrollGesturesEnabled', scrollGesturesEnabled); + addIfNonNull('tiltGesturesEnabled', tiltGesturesEnabled); + addIfNonNull('zoomGesturesEnabled', zoomGesturesEnabled); + addIfNonNull('doubleClickZoomEnabled', doubleClickZoomEnabled); + + addIfNonNull('trackCameraPosition', trackCameraPosition); + addIfNonNull('myLocationEnabled', myLocationEnabled); + addIfNonNull('myLocationTrackingMode', myLocationTrackingMode?.index); + addIfNonNull('myLocationRenderMode', myLocationRenderMode?.index); + addIfNonNull('logoEnabled', logoEnabled); + addIfNonNull('logoViewPosition', logoViewPosition?.index); + addIfNonNull('logoViewMargins', pointToArray(logoViewMargins)); + addIfNonNull('compassViewPosition', compassViewPosition?.index); + addIfNonNull('compassViewMargins', pointToArray(compassViewMargins)); + addIfNonNull('attributionButtonPosition', attributionButtonPosition?.index); + addIfNonNull( + 'attributionButtonMargins', pointToArray(attributionButtonMargins)); + addIfNonNull('locationEngineProperties', locationEnginePlatforms?.toList()); + addIfNonNull('foregroundLoadColor', foregroundLoadColor?.toARGB32()); + addIfNonNull('translucentTextureSurface', translucentTextureSurface); + return optionsMap; + } + + Map updatesMap(_MapLibreMapOptions newOptions) { + final prevOptionsMap = toMap(); + final newOptionsMap = newOptions.toMap(); + + // if any gesture is updated also all other gestures have to the saved to + // the update + + final gesturesRequireUpdate = + _gestureGroup.any((key) => newOptionsMap[key] != prevOptionsMap[key]); + + return newOptionsMap + ..removeWhere((String key, dynamic value) { + if (_gestureGroup.contains(key)) return !gesturesRequireUpdate; + final oldValue = prevOptionsMap[key]; + if (oldValue is List && value is List) { + return listEquals(oldValue, value); + } + return oldValue == value; + }); + } +} diff --git a/third_party/maplibre_gl/lib/src/maplibre_styles.dart b/third_party/maplibre_gl/lib/src/maplibre_styles.dart new file mode 100644 index 00000000..c1f55cc6 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/maplibre_styles.dart @@ -0,0 +1,15 @@ +part of '../maplibre_gl.dart'; + +@Deprecated('MaplibreStyles was renamed to MapLibreStyles.') +typedef MaplibreStyles = MapLibreStyles; + +/// MapLibre styles used mostly for demonstration. +abstract class MapLibreStyles { + /// A very simple MapLibre demo style that shows only countries with their + /// boundaries. + static const String demo = 'https://demotiles.maplibre.org/style.json'; + + /// The OpenFreeMap liberty style + static const String openfreemapLiberty = + 'https://tiles.openfreemap.org/styles/liberty'; +} diff --git a/third_party/maplibre_gl/lib/src/offline_region.dart b/third_party/maplibre_gl/lib/src/offline_region.dart new file mode 100644 index 00000000..e5e70cc7 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/offline_region.dart @@ -0,0 +1,76 @@ +part of '../maplibre_gl.dart'; + +/// Description of region to be downloaded. Identifier will be generated when +/// the download is initiated. +class OfflineRegionDefinition { + const OfflineRegionDefinition({ + required this.bounds, + required this.mapStyleUrl, + required this.minZoom, + required this.maxZoom, + this.includeIdeographs = false, + }); + + final LatLngBounds bounds; + final String mapStyleUrl; + final double minZoom; + final double maxZoom; + final bool includeIdeographs; + + @override + String toString() => + "OfflineRegionDefinition, bounds = $bounds, mapStyleUrl = $mapStyleUrl, minZoom = $minZoom, maxZoom = $maxZoom"; + + Map toMap() { + final data = {}; + data['bounds'] = bounds.toList(); + data['mapStyleUrl'] = mapStyleUrl; + data['minZoom'] = minZoom; + data['maxZoom'] = maxZoom; + data['includeIdeographs'] = includeIdeographs; + return data; + } + + factory OfflineRegionDefinition.fromMap(Map map) { + return OfflineRegionDefinition( + bounds: _latLngBoundsFromList(map['bounds']), + mapStyleUrl: map['mapStyleUrl'], + // small integers may deserialize to Int + minZoom: map['minZoom'].toDouble(), + maxZoom: map['maxZoom'].toDouble(), + includeIdeographs: map['includeIdeographs'] ?? false, + ); + } + + static LatLngBounds _latLngBoundsFromList(List json) { + return LatLngBounds( + southwest: LatLng(json[0][0], json[0][1]), + northeast: LatLng(json[1][0], json[1][1]), + ); + } +} + +/// Description of a downloaded region including its identifier. +class OfflineRegion { + const OfflineRegion({ + required this.id, + required this.definition, + required this.metadata, + }); + + final int id; + final OfflineRegionDefinition definition; + final Map metadata; + + factory OfflineRegion.fromMap(Map json) { + return OfflineRegion( + id: json['id'], + definition: OfflineRegionDefinition.fromMap(json['definition']), + metadata: json['metadata'], + ); + } + + @override + String toString() => + "OfflineRegion, id = $id, definition = $definition, metadata = $metadata"; +} diff --git a/third_party/maplibre_gl/lib/src/util.dart b/third_party/maplibre_gl/lib/src/util.dart new file mode 100644 index 00000000..22945ea2 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/util.dart @@ -0,0 +1,14 @@ +part of '../maplibre_gl.dart'; + +Map buildFeatureCollection( + List> features) { + return {"type": "FeatureCollection", "features": features}; +} + +final _random = Random(); +String getRandomString([int length = 10]) { + const charSet = + 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + return String.fromCharCodes(Iterable.generate( + length, (_) => charSet.codeUnitAt(_random.nextInt(charSet.length)))); +} diff --git a/third_party/maplibre_gl/pubspec.yaml b/third_party/maplibre_gl/pubspec.yaml new file mode 100644 index 00000000..f5d99da7 --- /dev/null +++ b/third_party/maplibre_gl/pubspec.yaml @@ -0,0 +1,35 @@ +name: maplibre_gl +description: A Flutter plugin for integrating MapLibre Maps inside a Flutter application on Android, iOS and web platforms. +version: 0.25.0 +repository: https://github.com/maplibre/flutter-maplibre-gl +issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues +resolution: workspace + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: '>=3.22.0' + +dependencies: + flutter: + sdk: flutter + maplibre_gl_platform_interface: ^0.25.0 + maplibre_gl_web: ^0.25.0 + +dev_dependencies: + very_good_analysis: ^10.0.0 + +flutter: + plugin: + platforms: + android: + package: org.maplibre.maplibregl + pluginClass: MapLibreMapsPlugin + ios: + pluginClass: MapLibreMapsPlugin + web: + default_package: maplibre_gl_web + +platforms: + android: + ios: + web: From 3323e4eb557b02ceeba0241af8e77528ef42a249 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 23:43:11 -0400 Subject: [PATCH 156/360] Fix RX pin z-order: newest pin always renders on top When many RX pins arrived within a few seconds, newer pins could render underneath older ones. The recency sort key was seconds-since-2025-01-01, now ~46M, which exceeds the native float32 symbol-sort-key's exact-integer ceiling (2^24). Float32 quantizes that range to multiples of 4, so any pings within ~4s collided on one sort key and MapLibre ordered the ties arbitrarily. (Even as a true int, .inSeconds tied pings in the same second.) Replace the timestamp-derived zIndex with a monotonic counter assigned once per coverage symbol key (_coverageZIndex / _coverageZCounter): - Stays well under 2^24, so float32 stays exact. - Unique per key, so no ties. - Assign-once, so existing symbols never need re-pushing (the incremental sync skip path is untouched). - Newest key gets the highest value -> always on top and wins the topmost-first tap hit-test. An SNR-updated RX changes its key (new ts) -> fresh top counter, correctly redrawn on top. Drop the counter entry in the symbol cleanup loop (prevents an unbounded leak) and clear the map on style reload (the SymbolManager is rebuilt empty there; keep the counter climbing). --- lib/widgets/map_widget.dart | 44 +++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 139f9354..274094cd 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -710,6 +710,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // this, every sync re-pushed ALL accumulated symbols (O(n)/event), which // made marker/ping display lag grow with session length. See _syncCoverageSymbols. final Map _coverageSymbolSig = {}; + // Per-key render order: a monotonic counter assigned on first sight, so a + // newer symbol always sorts above an older one. Replaces a timestamp-derived + // sort key that overflowed float32 (the native symbol-sort-key) and quantized + // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. + final Map _coverageZIndex = {}; + int _coverageZCounter = 0; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -2828,6 +2834,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _gpsSymbol = null; _coverageSymbols.clear(); _coverageSymbolSig.clear(); + // The native SymbolManager is rebuilt empty here, so the next sync re-adds + // every symbol via addSymbol. Drop the stale z-order map so re-added keys + // get fresh (higher) counter values and stay monotonic with re-creation + // order — but keep _coverageZCounter climbing (never reset it). + _coverageZIndex.clear(); _distanceLabelSymbols.clear(); // Distance-label companions: the native side wipes registered images on // style reload, so the "already registered" cache must be cleared too or @@ -4432,22 +4443,26 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${type}_${ts.millisecondsSinceEpoch}_' '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; - /// Fixed base epoch for coverage-marker z-ordering. Keeps [_recencyZIndex] - /// small enough to stay exact in the native float32 symbol sort-key (integers - /// are exact up to 16,777,216). - static final DateTime _kZBaseEpoch = DateTime.utc(2025, 1, 1); - - /// Render/tap z-order for a coverage marker: seconds since [_kZBaseEpoch], so a - /// more-recent ping always sorts above (renders on top of, and is tapped in - /// preference to) an older one — globally, across TX/RX/DISC/Trace. + /// Render/tap z-order for a coverage marker: a monotonic counter assigned + /// once per [_coverageKey], so a newer symbol always renders on top of (and + /// wins the topmost-first tap hit-test over) an older overlapping one. /// /// The annotation manager's symbol layer uses `symbol-sort-key: ["get", /// "zIndex"]`; with `icon-allow-overlap = true` a higher sort key overlaps a - /// lower one. Setting this also flips `symbol-z-order: auto` off its default - /// `viewport-y` (southernmost-on-top) fallback. It's a pure function of the - /// ping's own timestamp, so existing symbols never need re-pushing — the - /// incremental-sync skip path (gated on the icon/size signature) is untouched. - int _recencyZIndex(DateTime ts) => ts.difference(_kZBaseEpoch).inSeconds; + /// lower one (and flips `symbol-z-order: auto` off its `viewport-y` default). + /// + /// Assign-once → an existing symbol's key never changes value, so the + /// incremental-sync skip path never needs to re-push it. The counter stays + /// well under 2^24, the exact-integer limit of the native float32 sort-key — + /// the previous timestamp-in-seconds key had grown to ~46M, where float32 + /// quantizes to multiples of 4, so pings within ~4s collided on one sort key + /// and stacked in undefined order (the RX burst bug this replaces). + /// + /// NOTE: within a single sync, brand-new keys are numbered in iteration order + /// (TX→RX→DISC→Trace, each oldest→newest); cross-type ties in the same ~250ms + /// window are cosmetic (different icons) and intentionally not pre-sorted. + int _zIndexFor(String key) => + _coverageZIndex.putIfAbsent(key, () => ++_coverageZCounter); /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. /// One symbol per ping, image varies by type/success state, opacity reflects @@ -4509,7 +4524,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Recency-ordered sort key: the most recent ping renders on top // and wins the native topmost-first tap hit-test, so an older // overlapping marker is never selected in its place. - zIndex: _recencyZIndex(ts), + zIndex: _zIndexFor(key), ), {'kind': type, 'id': idForMetadata}, ); @@ -4621,6 +4636,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { for (final key in toRemove) { final sym = _coverageSymbols.remove(key); _coverageSymbolSig.remove(key); + _coverageZIndex.remove(key); if (sym != null) { try { await _mapController!.removeSymbol(sym); From 3e78a43fb1a681cd6819653f4651d0d7f54ab0d8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 23:57:04 -0400 Subject: [PATCH 157/360] fix: text out of bounds --- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- lib/widgets/repeater_id_chip.dart | 83 ++++++++++++------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb29..95d6e55f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 32371b8f..aeedbb05 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -43,38 +43,65 @@ class RepeaterIdChip extends StatelessWidget { ? fontSize - 1.0 // 4-char IDs (2-byte) : fontSize; // 2-char IDs (1-byte) - final child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - repeaterId, - softWrap: false, - overflow: TextOverflow.clip, - style: TextStyle( - fontSize: effectiveFontSize, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 2), - Icon( - Icons.info_outline, - size: fontSize - 1, - color: isAmbiguous - ? const Color(0xFFF59E0B) - : Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.5), - ), - ], + final text = Text( + repeaterId, + softWrap: false, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: effectiveFontSize, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ); + final icon = Icon( + Icons.info_outline, + size: fontSize - 1, + color: isAmbiguous + ? const Color(0xFFF59E0B) + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ); + // Fixed-width form: the box can be narrower than the ID's intrinsic width + // (long 3-byte IDs), so wrap the text in Flexible to let it shrink/clip and + // avoid a sub-pixel RenderFlex overflow. Safe here because the SizedBox + // bounds the Row's width. if (width != null) { - return SizedBox(width: width, child: child); + return SizedBox( + width: width, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: text), + const SizedBox(width: 2), + icon, + ], + ), + ); } - return child; + + // Intrinsic-width form: usually laid out under unbounded width (e.g. as a + // non-flex child of another Row), where a Flexible child would throw — but + // it can also land inside a Flexible/bounded Row (the DISC node column), + // where a non-flex text overflows on long 3-byte IDs. Probe the incoming + // constraint: shrink/clip the text only when the parent bounds our width, + // otherwise keep it non-flex so it sizes to its content. + return LayoutBuilder( + builder: (context, constraints) { + final bounded = constraints.maxWidth.isFinite; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + bounded ? Flexible(child: text) : text, + const SizedBox(width: 2), + icon, + ], + ); + }, + ); } /// Show a dialog with matching repeater names for the given [repeaterId]. From be7d5bea45506169660c67a9a17ddb5644e6a5f9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 00:05:28 -0400 Subject: [PATCH 158/360] update: podfile --- ios/Podfile | 8 ++++++++ ios/Podfile.lock | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/Podfile b/ios/Podfile index 620e46eb..22d33ba4 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,5 +39,13 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + # Clang module verifier builds each pod's module in isolation, where the + # `Flutter` module isn't on the search path. Swift plugins (e.g. + # disk_space_plus) import Flutter in their generated -Swift.h, so the + # verifier fails with "module 'Flutter' not found". Disable it for pods. + # (Xcode's "Update to recommended settings" silently turns this on.) + config.build_settings['ENABLE_MODULE_VERIFIER'] = 'NO' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3eb0bde7..a367b48b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -35,6 +35,6 @@ SPEC CHECKSUMS: flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 COCOAPODS: 1.16.2 From a2ae7402b3975e5d3735a6c3f8d00a85a170edad Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 00:38:19 -0400 Subject: [PATCH 159/360] Offline upload: distinguish network/timeout from auth failure - Add OfflineUploadResult.networkError; a null /auth (timeout) is now a retryable network error, not a misleading auth rejection - Retry /auth on timeout (2s/4s backoff) before giving up, mirroring the batch-upload retry - Success snackbar reads 'Upload Success'; network errors show 'Network error - tap again to retry'. The 'advert your device' message is reserved for genuine auth rejections. --- lib/providers/app_state_provider.dart | 71 +++++++++++++++++++-------- lib/screens/settings_screen.dart | 6 ++- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4702bdc1..997e8a17 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -81,9 +81,12 @@ enum OfflineUploadResult { /// Session data is invalid or empty invalidSession, - /// API authentication failed + /// API authentication failed (device not registered / genuine rejection) authFailed, + /// Network/timeout error reaching the API (not an auth rejection) — retryable + networkError, + /// Some pings failed to upload partialFailure, @@ -4698,6 +4701,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final pings = _apiQueueService.getAndClearOfflinePings(); if (pings.isEmpty) { debugLog('[APP] No offline pings to save'); + // Still break the auto-save tracker so the next offline session starts a + // fresh file instead of appending to the previously tracked session. + // No-op when nothing is tracked. + _offlineSessionService.finalizeCurrentSession(); return; } @@ -4707,7 +4714,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? _originalDeviceName : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - await _offlineSessionService.saveSession( + // Finalize the in-progress session (created by periodic auto-save) in place + // rather than creating a new one — otherwise the auto-saved session and this + // final save become two identical sessions at the same time. updateCurrentSession + // creates a fresh session only when no auto-save has run yet. + await _offlineSessionService.updateCurrentSession( pings, devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, @@ -4899,27 +4910,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[OFFLINE] Authenticating for offline upload with device: $deviceName ' '(model: $uploadModel, power: ${uploadPower}w, ver: $uploadVersion)'); - final authResult = await _apiService.requestAuth( - reason: 'connect', - publicKey: publicKey, - who: deviceName, - appVersion: uploadVersion, - power: uploadPower, - iataCode: zoneCode ?? _preferences.iataCode, - model: uploadModel, - radioFreq: session.radioConfig, - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - offlineMode: true, - skipSessionStore: true, - ); + // Retry the auth on transient network/timeout errors — requestAuth returns null + // on a TimeoutException. A single slow first request shouldn't abort the upload + // and surface a misleading "auth failed" (mirrors the batch-upload retry below). + const authRetryBackoff = [2, 4]; // seconds, after the initial attempt + Map? authResult; + for (var attempt = 0;; attempt++) { + authResult = await _apiService.requestAuth( + reason: 'connect', + publicKey: publicKey, + who: deviceName, + appVersion: uploadVersion, + power: uploadPower, + iataCode: zoneCode ?? _preferences.iataCode, + model: uploadModel, + radioFreq: session.radioConfig, + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + if (authResult != null) break; // got a response (success OR a real rejection) + if (attempt >= authRetryBackoff.length) break; // retries exhausted + final delay = authRetryBackoff[attempt]; + debugWarn( + '[OFFLINE] Auth network error, retry ${attempt + 1}/${authRetryBackoff.length} after ${delay}s'); + onProgress?.call('Authenticating (retry ${attempt + 1})...'); + await Future.delayed(Duration(seconds: delay)); + } Map? effectiveAuth = authResult; if (authResult == null) { - debugError('[OFFLINE] Auth failed: network error'); - return OfflineUploadResult.authFailed; + debugError('[OFFLINE] Auth failed: network error after retries'); + return OfflineUploadResult.networkError; } if (authResult['success'] != true) { @@ -4946,8 +4971,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { skipSessionStore: true, ); - if (registerResult == null || registerResult['success'] != true) { - final regReason = registerResult?['reason'] as String? ?? 'unknown'; + if (registerResult == null) { + debugError('[OFFLINE] Stage 2 registration network error'); + return OfflineUploadResult.networkError; + } + if (registerResult['success'] != true) { + final regReason = registerResult['reason'] as String? ?? 'unknown'; debugError('[OFFLINE] Stage 2 registration failed: $regReason'); return OfflineUploadResult.authFailed; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f1dedd0e..b1681959 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2259,7 +2259,7 @@ class _SettingsScreenState extends State { switch (result) { case OfflineUploadResult.success: - message = 'Uploaded: $filename'; + message = 'Upload Success'; backgroundColor = Colors.green; break; case OfflineUploadResult.notFound: @@ -2274,6 +2274,10 @@ class _SettingsScreenState extends State { message = 'Authentication failed - Advert your device on the mesh'; backgroundColor = Colors.red; break; + case OfflineUploadResult.networkError: + message = 'Network error - tap again to retry'; + backgroundColor = Colors.orange; + break; case OfflineUploadResult.gpsRequired: message = 'GPS required - enable location services to upload'; backgroundColor = Colors.red; From 42446e60768c1d5ef29c3295ad551a846b892ca8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 00:39:06 -0400 Subject: [PATCH 160/360] add test --- .../offline_session_service_test.dart | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/services/offline_session_service_test.dart diff --git a/test/services/offline_session_service_test.dart b/test/services/offline_session_service_test.dart new file mode 100644 index 00000000..e7e7230d --- /dev/null +++ b/test/services/offline_session_service_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mesh_mapper/services/offline_session_service.dart'; + +/// Regression tests for the offline-session lifecycle duplicate bug. +/// +/// Repro: during offline wardriving the 60s auto-save (and app-pause) call +/// `updateCurrentSession()`, which on first fire creates and *tracks* a session. +/// The final save (`_saveOfflineSession` in AppStateProvider) must finalize that +/// SAME tracked session — if it instead calls `saveSession()` it creates a second +/// session holding the same pings, surfacing as two identical sessions at the +/// same time under Settings → Offline Sessions. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + List> pings(int n) => + List.generate(n, (i) => {'type': 'TX', 'seq': i}); + + group('OfflineSessionService lifecycle', () { + test( + 'auto-save then final save through updateCurrentSession yields ONE session (the fix)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Auto-save fires (60s timer / app pause) — creates + tracks session A. + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + expect(service.sessionCount, 1); + + // Final save (stop/disconnect) routes through updateCurrentSession with the + // full ping set, then finalizes the tracker. + await service.updateCurrentSession(pings(5), deviceName: 'Test'); + service.finalizeCurrentSession(); + + expect(service.sessionCount, 1, + reason: 'final save must update the tracked session in place'); + expect(service.sessions.first.pingCount, 5, + reason: 'session holds the complete final ping set'); + }); + + test( + 'auto-save then final save through saveSession DUPLICATES (the bug being fixed)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Auto-save creates + tracks session A. + await service.updateCurrentSession(pings(5), deviceName: 'Test'); + // Old buggy final-save path: saveSession() ignores the tracked session. + await service.saveSession(pings(5), deviceName: 'Test'); + + expect(service.sessionCount, 2, + reason: + 'documents the duplicate: saveSession creates a second session ' + 'alongside the tracked auto-save session'); + }); + + test('finalize then save creates a fresh session (clean break preserved)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + service.finalizeCurrentSession(); + // New offline session after a finalize should be a distinct file. + await service.updateCurrentSession(pings(2), deviceName: 'Test'); + + expect(service.sessionCount, 2, + reason: 'finalize breaks tracking so the next save is a new session'); + }); + + test( + 'final save with empty queue still finalizes, so next session does not append to the old one', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Offline session 1: auto-save tracks a 3-ping session. + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + + // Final save runs with no new pings — the provider's empty-queue path now + // finalizes the tracker instead of leaving it set. + service.finalizeCurrentSession(); + + // Offline session 2 starts and auto-saves 2 pings. + await service.updateCurrentSession(pings(2), deviceName: 'Test'); + + // sessions are sorted newest-first: .last is session 1, .first is session 2. + expect(service.sessionCount, 2, + reason: + 'second session must be its own file, not appended to the first'); + expect(service.sessions.last.pingCount, 3, + reason: 'first session keeps its original 3 pings (no append)'); + expect(service.sessions.first.pingCount, 2, + reason: 'second session holds only its own 2 pings'); + }); + }); +} From c72cd645e216743110a93d13f86d77c931c71a38 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 23:24:09 -0400 Subject: [PATCH 161/360] Fix multi-hop TX display in history; GPS puck z-order; cooldown UI - History map popups: pass pathHops through when reconstructing a TxPing from stored markers so multi-hop repeats render distinctly instead of all appearing as direct repeats - Map: keep the GPS puck above all coverage pings via a fixed z-index - ping_controls: landscape button keeps its color during cooldown - app_state_provider: ping/auto-mode adjustments and added debug logging --- lib/providers/app_state_provider.dart | 248 ++++++++++++++------------ lib/widgets/map_widget.dart | 9 + lib/widgets/ping_controls.dart | 6 +- 3 files changed, 152 insertions(+), 111 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 997e8a17..01429469 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -191,6 +191,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { DateTime? _idleAutoStopReference; static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked + bool _autoPingStarting = + false; // True while an auto mode is starting (before the first notify) int _queueSize = 0; int? _currentNoiseFloor; int? _currentBatteryPercent; @@ -490,6 +492,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; + bool get isAutoPingStarting => _autoPingStarting; bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) @@ -3945,24 +3948,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return false; } - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) return false; + // Ignore re-taps while a ping is already being sent (prevents the + // double-tap / concurrent-heartbeat storm during the session check) + if (_isPingSending) { + debugLog('[PING] Ignoring tap — ping already sending'); + return false; } - // Reset idle disconnect timer (user is actively pinging) - _startIdleDisconnectTimer(); - - // Set sending state immediately for instant UI feedback + // Set sending state immediately for instant UI feedback, BEFORE the + // (awaited, network) session check so the button locks the moment it's tapped _isPingSending = true; notifyListeners(); - debugLog('[PING] Sending manual TX ping'); try { + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; + } + + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + + debugLog('[PING] Sending manual TX ping'); return await _pingService!.sendTxPing(manual: true); } finally { - // Clear sending state when done (RX window timer will show listening state) + // Clear sending state on every path: session-check failure, exception, + // or success (RX window timer takes over showing the listening state) _isPingSending = false; notifyListeners(); } @@ -4075,115 +4087,131 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { - // Cancel idle disconnect timer — auto-ping keeps the session active - _cancelIdleDisconnectTimer(); + // Ignore re-taps while a start is already in flight (prevents the + // double-tap / concurrent-heartbeat storm during the session check) + if (_autoPingStarting) return false; - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) return false; - } + // Set starting state immediately for instant UI feedback, BEFORE the + // (awaited, network) session check so the buttons lock the moment it's tapped + _autoPingStarting = true; + notifyListeners(); - // Block starting if shared cooldown is active (TX modes only) - // Passive Mode is listening only and can start during cooldown - if (isTxMode && _cooldownTimer.isRunning) { - debugLog( - '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); - return false; - } + try { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); - // Stop any existing mode first - if (_autoPingEnabled) { - await _pingService!.forceDisableAutoPing(); - // Stop TX echo tracking to prevent late timer callbacks - _pingService!.stopEchoTracking(); - _rxLogger?.stopWardriving(trigger: 'mode_switch'); - await BackgroundServiceManager.stopService(); - // Stop countdown timers when switching modes - _autoPingTimer.stop(); - _rxWindowTimer.stop(); - // Clear top-heard overlay on mode switch - _clearOverlayState(); - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; + } + + // Block starting if shared cooldown is active (TX modes only) + // Passive Mode is listening only and can start during cooldown + if (isTxMode && _cooldownTimer.isRunning) { + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + return false; } - // End existing noise floor session before starting new mode - await _endNoiseFloorSession(); - } - // Start new mode - debugLog('[PING] Starting auto mode: ${mode.name}'); - _autoMode = mode; + // Stop any existing mode first + if (_autoPingEnabled) { + await _pingService!.forceDisableAutoPing(); + // Stop TX echo tracking to prevent late timer callbacks + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'mode_switch'); + await BackgroundServiceManager.stopService(); + // Stop countdown timers when switching modes + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); + // Save offline session if offline mode is enabled + if (_preferences.offlineMode) { + await _saveOfflineSession(); + } + // End existing noise floor session before starting new mode + await _endNoiseFloorSession(); + } - // Set interval from user preferences before starting - final intervalMs = _preferences.autoPingInterval * 1000; - _pingService!.setAutoPingInterval(intervalMs); - debugLog( - '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + // Start new mode + debugLog('[PING] Starting auto mode: ${mode.name}'); + _autoMode = mode; - final started = await _pingService!.enableAutoPing( - passiveMode: isPassive, - hybridMode: isHybrid, - targetedMode: isTargeted, - targetRepeaterId: isTargeted ? _targetRepeaterId : null, - ); - if (!started) { - // Blocked by cooldown or already enabled - if (_pingService!.isInCooldown()) { - debugLog( - '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); - } else { - debugLog('[PING] Auto mode start blocked'); + // Set interval from user preferences before starting + final intervalMs = _preferences.autoPingInterval * 1000; + _pingService!.setAutoPingInterval(intervalMs); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); + if (!started) { + // Blocked by cooldown or already enabled + if (_pingService!.isInCooldown()) { + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + } else { + debugLog('[PING] Auto mode start blocked'); + } + return false; } - return false; - } - // Start RX wardriving for all modes - // Reference: state.rxTracking.isWardriving = true in wardrive.js - _rxLogger?.startWardriving(); - _autoPingEnabled = true; - _idleAutoStopReference = DateTime.now(); - - // Start noise floor session for graph tracking - final sessionLabel = isPassive - ? 'passive' - : isHybrid - ? 'hybrid' - : isTargeted - ? 'targeted' - : 'active'; - _startNoiseFloorSession(sessionLabel); - - // Enable heartbeat for all auto-ping modes (not offline mode) - // Heartbeat sends keepalive ~1 min before session expiry (4 min timer) - // Active/Hybrid pings renew session when moving, but heartbeat is the - // safety net when stationary (25m distance filter skips TX pings) - if (!_preferences.offlineMode) { - _apiService.enableHeartbeat( - gpsProvider: () { - // Provide current GPS coordinates for heartbeat (matching wardrive.js) - final pos = _gpsService.lastPosition; - if (pos == null) return null; - return (lat: pos.latitude, lon: pos.longitude); - }, + // Start RX wardriving for all modes + // Reference: state.rxTracking.isWardriving = true in wardrive.js + _rxLogger?.startWardriving(); + _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); + + // Start noise floor session for graph tracking + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; + _startNoiseFloorSession(sessionLabel); + + // Enable heartbeat for all auto-ping modes (not offline mode) + // Heartbeat sends keepalive ~1 min before session expiry (4 min timer) + // Active/Hybrid pings renew session when moving, but heartbeat is the + // safety net when stationary (25m distance filter skips TX pings) + if (!_preferences.offlineMode) { + _apiService.enableHeartbeat( + gpsProvider: () { + // Provide current GPS coordinates for heartbeat (matching wardrive.js) + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled for ${mode.name} Mode'); + } + + // Start background service for continuous operation + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; + await BackgroundServiceManager.startService( + mode: modeName, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + queueSize: _queueSize, ); - debugLog('[HEARTBEAT] Enabled for ${mode.name} Mode'); - } - - // Start background service for continuous operation - final modeName = isPassive - ? 'Passive Mode' - : isHybrid - ? 'Hybrid Mode' - : isTargeted - ? 'Trace Mode' - : 'Active Mode'; - await BackgroundServiceManager.startService( - mode: modeName, - txCount: _pingStats.txCount, - rxCount: _pingStats.rxCount, - queueSize: _queueSize, - ); + } finally { + // Clear starting state on every path (session/cooldown/blocked early + // returns, exceptions, and success) so the buttons never stay disabled + _autoPingStarting = false; + notifyListeners(); + } } notifyListeners(); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 274094cd..20f366e0 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -716,6 +716,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. final Map _coverageZIndex = {}; int _coverageZCounter = 0; + /// GPS puck z-order: a fixed sort key far above the coverage counter + /// (_coverageZCounter, which climbs from 1 per unique ping) so the GPS + /// marker always renders on top of every TX/RX/DISC/Trace pin. Stays exact + /// in the native float32 symbol-sort-key (< 2^24). See _syncCoverageSymbols. + static const int _gpsZIndex = 1 << 23; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -4711,6 +4716,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { geometry: LatLng(pos.latitude, pos.longitude), iconImage: _MapImages.gps(style), iconRotate: iconRotate, + // Keep the GPS puck above all coverage pings in the shared annotation + // layer (Critical Rule 9 / _zIndexFor). See _gpsZIndex. + zIndex: _gpsZIndex, ); if (_gpsSymbol == null) { @@ -7902,6 +7910,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeaterId: r.repeaterId, snr: r.snr, rssi: r.rssi, + pathHops: r.pathHops, )) .toList(), )); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index a43174af..4de32186 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -1732,7 +1732,11 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final showColor = widget.enabled || widget.isActive; + // Keep the button's color (not grey) whenever the countdown badge is shown, + // mirroring portrait's `_ActionButton` showCooldown handling. Otherwise the + // badge renders white text on grey (onSurfaceVariant) during cooldown. + final showColor = + widget.enabled || widget.isActive || widget.countdown != null; final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; // Static active-state opacity (continuous pulse animation removed — see From f8979a0e33f7f32045d406be8f76bd1d67e91756 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 22 Jun 2026 13:16:51 -0400 Subject: [PATCH 162/360] Encode session date in TX wire-tag to fix cross-day collision (DEAD tiles) The wire-tag packed only (region, NNNN, counter) with no date. NNNN recycles daily, so a tag re-minted on a later day was byte-identical to the prior day's and the server's idempotency skip dropped the new ping -> DEAD tiles when driving new areas. Encode the session date too: region(15) | date(15: year-2020/month/day) | NNNN(14) | counter(11) = 55 bits -> "MM:"+10 chars. decode() now returns the date and reconstructs the full session_id. Web-safe on dart2js (value > 2^53) by working on two <=28-bit halves with arithmetic only; verified byte-identical to the PHP twin and under flutter test --platform chrome. Canonical vectors regenerated. Also: [COVERAGE] Patched log now includes the cell status histogram, and the API queue logs wire_tag + ping_counter on upload, so collisions self-document. --- lib/providers/app_state_provider.dart | 13 +- lib/services/api_queue_service.dart | 8 +- lib/services/meshcore/wire_tag_codec.dart | 143 ++++++++++------- test/services/wire_tag_codec_test.dart | 182 ++++++++++++++++------ 4 files changed, 241 insertions(+), 105 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 01429469..dca61abf 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -748,8 +748,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _coveragePatchCells.remove(_coveragePatchCells.keys.first); } _coveragePatchVersion++; + // Status histogram of the patched cells (st = server-rendered status category), + // so a debug log carries hard proof of what the server actually rendered for the + // user's own pings (e.g. st={1:3,2:5}) — see the "tiles not green is server-side" note. + final stHist = {}; + for (final cell in patched) { + stHist[cell.st] = (stHist[cell.st] ?? 0) + 1; + } + final stSummary = (stHist.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key))) + .map((e) => '${e.key}:${e.value}') + .join(','); debugLog( - '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt)'); + '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt) st={$stSummary}'); notifyListeners(); } else { debugLog( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 50ea4d16..ced5ea86 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -579,11 +579,15 @@ class ApiQueueService { // Convert to API format final pings = items.map((item) => item.toApiJson()).toList(); - // Log each item with external_antenna value + // Log each item with external_antenna value. Token-mode TX entries also log their + // wire_tag + ping_counter so a debug log self-documents any tag collision/drop. for (int i = 0; i < items.length; i++) { final item = items[i]; + final tagInfo = item.wireTag != null + ? ', wire_tag=${item.wireTag}, ping_counter=${item.pingCounter}' + : ''; debugLog( - '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}$tagInfo'); } final memoryCount = memoryItems.length; diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart index 61f4994c..f4cfd8aa 100644 --- a/lib/services/meshcore/wire_tag_codec.dart +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -6,25 +6,28 @@ import 'package:crypto/crypto.dart'; /// Encodes/decodes the on-air TX "wire tag" that replaces plaintext GPS /// coordinates on the `#wardriving` channel. /// -/// A tag is `"MM:" + 7 base64url chars` (10 chars total → a single 16-byte AES -/// block once the channel layer encrypts it). It packs the origin region, the -/// session number, and the per-session ping counter into 40 bits, then runs a -/// keyed 4-round Feistel so it can only be decoded with the shared secret. +/// A tag is `"MM:" + 10 base64url chars` (13 chars total). It packs the origin +/// region, the **session date**, the session number, and the per-session ping +/// counter into 55 bits, then runs a keyed 4-round Feistel so it can only be +/// decoded with the shared secret. /// -/// The session *date* is intentionally NOT encoded — a decoder always holds a -/// receive timestamp (or, server-side, the full session_id), so the date is -/// supplied from context. The full session_id is reconstructed as -/// `(decoded region)-(date from context)-(decoded session#)`. +/// Layout (MSB→LSB): `region(15) | date(15) | NNNN(14) | counter(11)` where +/// `date = (year-2020)*512 + month*32 + day`. The date IS encoded (unlike the +/// original 40-bit tag) so a recycled daily NNNN mints a globally-unique tag — +/// this is the "green TX → DEAD tile" fix. `decode` fully reconstructs the +/// session_id `(region)-(YYYYMMDD)-(NNNN)`; no external date context is needed. /// /// This MUST stay byte-identical to the PHP `wireTagEncode`/`wireTagDecode` /// twins — see `test/services/wire_tag_codec_test.dart` for the canonical /// cross-language vectors. /// /// Web safety: this app runs on web (dart2js), where the bitwise operators -/// `<<`, `>>`, `&`, `|` truncate to 32 bits. Our packed value is up to 40 bits, -/// so anything that can exceed 31 bits uses plain arithmetic (`*`, `~/`, `%`), -/// which is exact for integers below 2^53. Bitwise ops are used only on the -/// two ≤20-bit Feistel halves, which are always < 2^31. +/// `<<`, `>>`, `&`, `|` truncate to 32 bits. The packed value is up to 55 bits +/// (> 2^53, the exact-integer ceiling for JS doubles), so the codec NEVER +/// materializes the full value — it works on two ≤28-bit halves and keeps every +/// intermediate below 2^40, using plain arithmetic (`*`, `~/`, `%`) which is +/// exact below 2^53. Bitwise ops appear only on the two ≤28-bit Feistel halves +/// (always < 2^31). class WireTagCodec { WireTagCodec._(); @@ -32,9 +35,15 @@ class WireTagCodec { static const String prefix = 'MM:'; static const int _pow11 = 2048; // 2^11 (counter field) - static const int _pow20 = 1048576; // 2^20 (Feistel half) - static const int _pow25 = 33554432; // 2^25 (region field shift) - static const int _mask20 = 0xFFFFF; // 20 bits — safe (< 2^31) + static const int _pow25 = 33554432; // 2^25 (date field shift) + static const int _pow28 = 268435456; // 2^28 (Feistel half size) + static const int _pow12 = 4096; // 2^12 (region within the high half) + static const int _mask28 = 0xFFFFFFF; // 28 bits — safe (< 2^31) + + // Date sub-fields: date = (year - _yearBase)*_pow9 + month*_pow5 + day. + static const int _yearBase = 2020; + static const int _pow9 = 512; // year shift (6-bit year → 2020..2083) + static const int _pow5 = 32; // month shift (4-bit month / 5-bit day) static int _regionPack(String iata) { final u = iata.toUpperCase(); @@ -46,53 +55,61 @@ class WireTagCodec { static String _regionUnpack(int n) => String.fromCharCodes([n ~/ 676 + 65, (n % 676) ~/ 26 + 65, n % 26 + 65]); - /// Feistel round function: first 3 bytes of SHA-256(secret ‖ round ‖ half), - /// masked to 20 bits. `half` is serialized big-endian as 3 bytes. + /// Feistel round function: first 4 bytes of SHA-256(secret ‖ round ‖ half), + /// masked to 28 bits. `half` (< 2^28) is serialized big-endian as 4 bytes. static int _f(List secret, int half, int round) { final input = [ ...secret, round, - (half ~/ 65536) % 256, + (half ~/ 16777216) % 256, // 2^24 + (half ~/ 65536) % 256, // 2^16 (half ~/ 256) % 256, half % 256, ]; final d = sha256.convert(input).bytes; - return (d[0] * 65536 + d[1] * 256 + d[2]) % _pow20; + return (d[0] * 16777216 + d[1] * 65536 + d[2] * 256 + d[3]) % _pow28; } - static int _feistel(List secret, int v, {required bool decrypt}) { - var l = v ~/ _pow20; // top 20 bits - var r = v % _pow20; // bottom 20 bits + /// 4-round balanced Feistel on the two 28-bit halves `(hi, lo)`. + /// Returns the transformed `(left, right)` pair — the full value is never + /// formed (it would exceed 2^53 on web). + static (int, int) _feistel(List secret, int hi, int lo, + {required bool decrypt}) { + var l = hi; + var r = lo; for (final round in decrypt ? const [3, 2, 1, 0] : const [0, 1, 2, 3]) { if (!decrypt) { final newL = r; - r = (l ^ _f(secret, r, round)) & _mask20; + r = (l ^ _f(secret, r, round)) & _mask28; l = newL; } else { final newR = l; - l = (r ^ _f(secret, l, round)) & _mask20; + l = (r ^ _f(secret, l, round)) & _mask28; r = newR; } } - return l * _pow20 + r; + return (l, r); } - static Uint8List _toBytes5(int v) { - final b = Uint8List(5); - var x = v; - for (var i = 4; i >= 0; i--) { - b[i] = x % 256; - x = x ~/ 256; - } - return b; + /// Serialize the 56-bit value `hi*2^28 + lo` (each half < 2^28) as 7 bytes, + /// big-endian — without ever forming the full integer. + static Uint8List _toBytes7(int hi, int lo) { + return Uint8List.fromList([ + (hi ~/ 1048576) % 256, // 2^20 → bits 48-55 + (hi ~/ 4096) % 256, // 2^12 → bits 40-47 + (hi ~/ 16) % 256, // 2^4 → bits 32-39 + (hi % 16) * 16 + (lo ~/ 16777216) % 16, // bits 24-31 (straddle) + (lo ~/ 65536) % 256, // bits 16-23 + (lo ~/ 256) % 256, // bits 8-15 + lo % 256, // bits 0-7 + ]); } - static int _fromBytes5(List b) { - var v = 0; - for (final byte in b) { - v = v * 256 + byte; - } - return v; + /// Inverse of [_toBytes7]: 7 bytes → `(hi, lo)`, each half < 2^28. + static (int, int) _fromBytes7(List b) { + final lo = b[6] + b[5] * 256 + b[4] * 65536 + (b[3] % 16) * 16777216; + final hi = (b[3] ~/ 16) + b[2] * 16 + b[1] * 4096 + b[0] * 1048576; + return (hi, lo); } static String _b64url(List bytes) => @@ -114,25 +131,47 @@ class WireTagCodec { throw ArgumentError( 'Cannot wire-tag encode an offline / non-region session id: $sessionId'); } - final v = _regionPack(parts[0]) * _pow25 + - int.parse(parts[2]) * _pow11 + - counter; - final cipher = _feistel(utf8.encode(key ?? ''), v, decrypt: false); - return prefix + _b64url(_toBytes5(cipher)); + final ymd = parts[1]; + final year = int.parse(ymd.substring(0, 4)); + final month = int.parse(ymd.substring(4, 6)); + final day = int.parse(ymd.substring(6, 8)); + final date = (year - _yearBase) * _pow9 + month * _pow5 + day; + + // Pack the low 40 bits, then split at bit 28 into the two Feistel halves. + // lowPart < 2^40, so it (and the carry) stay well below 2^53 — web-safe. + final lowPart = date * _pow25 + int.parse(parts[2]) * _pow11 + counter; + final lo = lowPart % _pow28; + final hi = _regionPack(parts[0]) * _pow12 + lowPart ~/ _pow28; + + final (cl, cr) = _feistel(utf8.encode(key ?? ''), hi, lo, decrypt: false); + return prefix + _b64url(_toBytes7(cl, cr)); } - /// Decode a wire body back to region / session# / counter using the key alone - /// (no database needed). The date is not recoverable from the tag. - static ({String region, int sessionNum, int counter}) decode( - String body, String? key) { + /// Decode a wire body back to region / date / session# / counter using the + /// key alone (no database needed). The session_id is fully recoverable as + /// `region-YYYYMMDD-NNNN`. + static ({ + String region, + int year, + int month, + int day, + int sessionNum, + int counter + }) decode(String body, String? key) { final token = body.startsWith(prefix) ? body.substring(prefix.length) : body; - final v = - _feistel(utf8.encode(key ?? ''), _fromBytes5(_unb64url(token)), decrypt: true); - final region = v ~/ _pow25; - final rem = v % _pow25; + final (chi, clo) = _fromBytes7(_unb64url(token)); + final (hi, lo) = _feistel(utf8.encode(key ?? ''), chi, clo, decrypt: true); + + final region = hi ~/ _pow12; + final lowPart = (hi % _pow12) * _pow28 + lo; + final date = lowPart ~/ _pow25; + final rem = lowPart % _pow25; return ( region: _regionUnpack(region), + year: date ~/ _pow9 + _yearBase, + month: (date % _pow9) ~/ _pow5, + day: date % _pow5, sessionNum: rem ~/ _pow11, counter: rem % _pow11, ); diff --git a/test/services/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart index bcf047cd..926a723b 100644 --- a/test/services/wire_tag_codec_test.dart +++ b/test/services/wire_tag_codec_test.dart @@ -1,71 +1,106 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/meshcore/wire_tag_codec.dart'; -/// Canonical cross-language vectors. These MUST stay byte-identical to the PHP -/// `wireTagEncode` and the Python reference oracle — they are the wire contract. -/// See docs / the TX Wire-Tag plan for how they were generated. +/// Wire-tag codec contract. The tag now encodes the SESSION DATE as well, so a +/// `(region, NNNN, counter)` triple mints a DIFFERENT tag on a different day — +/// this is what makes a recycled daily NNNN globally unique (the "green TX → +/// DEAD tile" fix). The codec MUST stay byte-identical to the PHP +/// `wireTagEncode`/`wireTagDecode` twins — the pinned canonical vectors below +/// are the cross-language wire contract. void main() { - group('WireTagCodec.encode (canonical vectors, key=TESTKEY)', () { - const key = 'TESTKEY'; - final vectors = <(String, int), String>{ - ('PAR-20260611-0013', 1): 'MM:zpCFQwc', - ('JKG-20260611-0009', 1): 'MM:zJHa-B8', - ('AAR-20260611-0014', 1): 'MM:GD59I2Q', - ('AAR-20260611-0123', 1000): 'MM:x6laqiY', - ('YOW-20260504-0005', 1): 'MM:2Oj9Xyg', - ('ZZZ-20260101-9999', 2047): 'MM:ETfo5FI', - }; + const key = 'TESTKEY'; - vectors.forEach((input, expected) { - test('${input.$1} ping ${input.$2} -> $expected', () { - expect(WireTagCodec.encode(input.$1, input.$2, key), expected); - }); + group('shape', () { + test('tag is "MM:" + 10 base64url chars (13 chars; 7-byte payload)', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(body.length, 13); + expect(RegExp(r'^MM:[A-Za-z0-9_-]{10}$').hasMatch(body), isTrue); }); - test('body is always "MM:" + 7 base64url chars (10 chars, one AES block)', () { - final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); - expect(body.length, 10); - expect(RegExp(r'^MM:[A-Za-z0-9_-]{7}$').hasMatch(body), isTrue); + test('same inputs are deterministic', () { + expect( + WireTagCodec.encode('PAR-20260611-0013', 7, key), + WireTagCodec.encode('PAR-20260611-0013', 7, key), + ); }); }); - group('WireTagCodec.encode (empty-key fallback)', () { - final vectors = <(String, int), String>{ - ('PAR-20260611-0013', 1): 'MM:jHHz-gQ', - ('JKG-20260611-0009', 1): 'MM:ozT0SI8', - ('AAR-20260611-0014', 1): 'MM:4y-cINQ', - ('AAR-20260611-0123', 1000): 'MM:ATsK8_8', - ('YOW-20260504-0005', 1): 'MM:EiC-3p4', - ('ZZZ-20260101-9999', 2047): 'MM:_CI9Xfs', - }; + group('date is encoded (the bug fix)', () { + test('same region/NNNN/counter on DIFFERENT days mint DIFFERENT tags', () { + final jun20 = WireTagCodec.encode('PAE-20260620-0007', 18, key); + final jun21 = WireTagCodec.encode('PAE-20260621-0007', 18, key); + expect(jun20, isNot(jun21), + reason: 'recycled daily NNNN must not collide across days'); + }); - vectors.forEach((input, expected) { - test('null key == "" key, ${input.$1} ping ${input.$2} -> $expected', () { - expect(WireTagCodec.encode(input.$1, input.$2, null), expected); - expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); - }); + test('all six WX7RAW counters differ across the two days', () { + for (var c = 18; c <= 23; c++) { + expect( + WireTagCodec.encode('PAE-20260620-0007', c, key), + isNot(WireTagCodec.encode('PAE-20260621-0007', c, key)), + reason: 'counter $c collided across days', + ); + } }); }); - group('WireTagCodec.decode (key only, no DB)', () { - test('recovers region/session#/counter from a known body', () { - final r = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); - expect(r.region, 'PAR'); - expect(r.sessionNum, 13); - expect(r.counter, 1); + group('key fallback', () { + test('null key == empty-string key, and both differ from a real key', () { + const sid = 'AAR-20260611-0123'; + expect(WireTagCodec.encode(sid, 5, null), WireTagCodec.encode(sid, 5, '')); + expect(WireTagCodec.encode(sid, 5, null), + isNot(WireTagCodec.encode(sid, 5, key))); }); + }); - test('decode with the wrong key yields a different region', () { - final right = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); - final wrong = WireTagCodec.decode('MM:zpCFQwc', 'nope'); - expect(right.region, 'PAR'); - expect(wrong.region, isNot('PAR')); + group('decode recovers region / full date / session# / counter', () { + test('a specific tag round-trips with the date intact', () { + final body = WireTagCodec.encode('PAE-20260621-0007', 18, key); + final d = WireTagCodec.decode(body, key); + expect(d.region, 'PAE'); + expect(d.year, 2026); + expect(d.month, 6); + expect(d.day, 21); + expect(d.sessionNum, 7); + expect(d.counter, 18); + }); + + test('reconstructs the full session_id', () { + final body = WireTagCodec.encode('YOW-20260504-0005', 42, key); + final d = WireTagCodec.decode(body, key); + final rebuilt = + '${d.region}-${d.year.toString().padLeft(4, '0')}${d.month.toString().padLeft(2, '0')}${d.day.toString().padLeft(2, '0')}-${d.sessionNum.toString().padLeft(4, '0')}'; + expect(rebuilt, 'YOW-20260504-0005'); + }); + + test('wrong key yields a different region', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(WireTagCodec.decode(body, 'nope').region, isNot('PAR')); }); }); - group('round-trip exactness across ping 1..1000', () { - test('encode then decode recovers the exact triple, all unique', () { - const key = 'TESTKEY'; + group('round-trip exactness (dates, NNNN, counters)', () { + test('boundary dates and the 14-bit NNNN / 11-bit counter extremes', () { + final cases = <(String, int)>[ + ('AAA-20200101-0001', 1), // epoch floor of the date field (year 2020) + ('ZZZ-20831231-9999', 2047), // ceilings: year 2083, NNNN 9999, counter 2047 + ('PAE-20260101-0007', 1), + ('PAE-20261231-0007', 2047), + ('JKG-20260229-0009', 100), // leap day + ]; + for (final (sid, c) in cases) { + final d = WireTagCodec.decode(WireTagCodec.encode(sid, c, key), key); + final p = sid.split('-'); + expect(d.region, p[0]); + expect(d.year, int.parse(p[1].substring(0, 4))); + expect(d.month, int.parse(p[1].substring(4, 6))); + expect(d.day, int.parse(p[1].substring(6, 8))); + expect(d.sessionNum, int.parse(p[2])); + expect(d.counter, c); + } + }); + + test('1..1000 counters all unique and exactly recovered', () { const sid = 'AAR-20260611-0123'; final bodies = {}; for (var c = 1; c <= 1000; c++) { @@ -73,10 +108,57 @@ void main() { bodies.add(body); final d = WireTagCodec.decode(body, key); expect(d.region, 'AAR'); + expect(d.year, 2026); + expect(d.month, 6); + expect(d.day, 11); expect(d.sessionNum, 123); expect(d.counter, c); } - expect(bodies.length, 1000, reason: 'every ping must be unique on the wire'); + expect(bodies.length, 1000); + }); + }); + + // Cross-language canonical vectors — confirmed byte-identical to PHP + // wireTagEncode (run MeshMapper_Server/wire_tag_codec.php on the same inputs). + // These are the wire contract; changing the codec MUST regenerate both sides. + group('canonical vectors (key=TESTKEY)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:YVNPAr5OIw', + ('JKG-20260611-0009', 1): 'MM:_ZqTR9KmUQ', + ('AAR-20260611-0014', 1): 'MM:xJkY4fMf0A', + ('AAR-20260611-0123', 1000): 'MM:yPPRVhdweg', + ('YOW-20260504-0005', 1): 'MM:q2REy6j1xQ', + ('ZZZ-20260101-9999', 2047): 'MM:tbOGo9kJHg', + ('PAE-20260620-0007', 18): 'MM:YPKG3YBefw', + ('PAE-20260621-0007', 18): 'MM:gCXS1s-0ew', // same NNNN/counter, next day → different tag + ('AAA-20200101-0001', 1): 'MM:mcvZkYjWyw', + ('ZZZ-20831231-9999', 2047): 'MM:lT-SF6aZAw', + }; + vectors.forEach((input, expected) { + test('${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, key), expected); + }); + }); + }); + + group('canonical vectors (empty-key fallback)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:sYVCDwfmyg', + ('JKG-20260611-0009', 1): 'MM:KzK7sA1D2w', + ('AAR-20260611-0014', 1): 'MM:5KWUc6SGLQ', + ('AAR-20260611-0123', 1000): 'MM:XVn2vjKTzQ', + ('YOW-20260504-0005', 1): 'MM:UIcMpMVBng', + ('ZZZ-20260101-9999', 2047): 'MM:f9QkqSCBKg', + ('PAE-20260620-0007', 18): 'MM:NWGgGQHdUg', + ('PAE-20260621-0007', 18): 'MM:bhBWfbLBfg', + ('AAA-20200101-0001', 1): 'MM:GlX5oUogpg', + ('ZZZ-20831231-9999', 2047): 'MM:v7UAnOrSZw', + }; + vectors.forEach((input, expected) { + test('null/empty key, ${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, null), expected); + expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); + }); }); }); } From 0e8858ba8dbdcc3899ab4bf0f5b8ed058e31e217 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 22 Jun 2026 13:20:05 -0400 Subject: [PATCH 163/360] Ping controls: instant start-mode feedback + cut rebuild churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Active/Hybrid and Passive buttons (portrait, compact, and landscape layouts) to the new `isAutoPingStarting` flag so they disable the instant a mode-start is tapped, instead of looking dead during the awaited session-check round-trip. Send Ping already disables via `isPingSending`. Replace each control widget's top-level `context.watch` with `context.select(_controlsDepsOf)` + `context.read`, so the controls rebuild only when a control-relevant field changes — not on every GPS / noise-floor / battery notify (~1-2 Hz during wardriving). Countdown values still update via the inner ListenableBuilder(timerListenable). The dependency records cover every appState.* read in the build bodies (incl. isConnected / targetRepeaterId / repeaters for the Trace section) so no button goes stale while idle. Companion to the sendPing()/toggleAutoPing() instant-feedback + re-entrancy guards already landed in app_state_provider.dart. --- lib/widgets/ping_controls.dart | 101 +++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 4de32186..5aa4560b 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -7,13 +7,90 @@ import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; import 'repeater_picker_sheet.dart'; +/// Fields the ping-control widgets depend on for their enabled/label state. +/// Used with `context.select` so the controls rebuild ONLY when one of these +/// changes — not on every GPS / noise-floor / battery `notifyListeners()` +/// (~1–2 Hz during wardriving). Timer (countdown) values are deliberately +/// excluded; they update via the inner `ListenableBuilder(timerListenable)` +/// in each widget. Keep this in sync with the `appState.*` reads in the build +/// bodies below — a missing field means a button can go stale while idle. +typedef _ControlsDeps = ({ + PingValidation pingValidation, + PingValidation manualValidation, + PingValidation autoValidation, + bool autoPingEnabled, + AutoMode autoMode, + bool isTargetedModeRunning, + bool hybridModeEnabled, + bool isPendingDisable, + bool isPingSending, + bool isAutoPingStarting, + bool isPingInProgress, + bool isConnected, + bool offlineMode, + bool txAllowed, + bool externalAntenna, + bool externalAntennaSet, + bool isPowerSet, + bool floodTrafficEnabled, + bool hasTargetRepeaterId, +}); + +_ControlsDeps _controlsDepsOf(AppStateProvider s) { + final prefs = s.preferences; + final targetId = s.targetRepeaterId; + return ( + pingValidation: s.pingValidation, + manualValidation: s.manualPingValidation, + autoValidation: s.autoModeValidation, + autoPingEnabled: s.autoPingEnabled, + autoMode: s.autoMode, + isTargetedModeRunning: s.isTargetedModeRunning, + hybridModeEnabled: prefs.hybridModeEnabled, + isPendingDisable: s.isPendingDisable, + isPingSending: s.isPingSending, + isAutoPingStarting: s.isAutoPingStarting, + isPingInProgress: s.isPingInProgress, + isConnected: s.isConnected, + offlineMode: s.offlineMode, + txAllowed: s.txAllowed, + externalAntenna: prefs.externalAntenna, + externalAntennaSet: prefs.externalAntennaSet, + isPowerSet: + prefs.autoPowerSet || prefs.powerLevelSet || s.deviceModel != null, + floodTrafficEnabled: s.floodTrafficEnabled, + hasTargetRepeaterId: targetId != null && targetId.isNotEmpty, + ); +} + +/// Subset of provider state the Trace Mode section depends on. +typedef _TargetedDeps = ({ + bool isTargetedModeRunning, + int traceHopBytes, + String? targetRepeaterId, + bool isConnected, + bool hasRepeaters, +}); + +_TargetedDeps _targetedDepsOf(AppStateProvider s) => ( + isTargetedModeRunning: s.isTargetedModeRunning, + traceHopBytes: s.traceHopBytes, + targetRepeaterId: s.targetRepeaterId, + isConnected: s.isConnected, + hasRepeaters: s.repeaters.isNotEmpty, + ); + /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { const PingControls({super.key}); @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when control-relevant state changes — NOT on every GPS / + // noise-floor / battery notify. Countdown values update via the inner + // ListenableBuilder(timerListenable) below, so they're excluded here. + context.select(_controlsDepsOf); + final appState = context.read(); return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { @@ -35,6 +112,8 @@ class PingControls extends StatelessWidget { final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState .isPendingDisable; // Disable pending, waiting for RX window to complete + final isAutoStarting = appState + .isAutoPingStarting; // True while an auto mode is starting (pre-first-notify) final cooldownActive = appState .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode final cooldownRemaining = appState.cooldownTimer.remainingSec; @@ -212,6 +291,7 @@ class PingControls extends StatelessWidget { : const Color(0xFF6366F1), // indigo-500 enabled: !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -271,6 +351,7 @@ class PingControls extends StatelessWidget { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -557,7 +638,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when Trace-relevant state changes (not on GPS/noise/battery). + context.select(_targetedDepsOf); + final appState = context.read(); final colorScheme = Theme.of(context).colorScheme; return ListenableBuilder( listenable: appState.timerListenable, @@ -774,7 +857,9 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when control-relevant state changes (not on GPS/noise/battery). + context.select(_controlsDepsOf); + final appState = context.read(); return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { @@ -794,6 +879,7 @@ class _CompactPingControlsState extends State { final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; final manualCooldownActive = appState @@ -880,6 +966,7 @@ class _CompactPingControlsState extends State { final activeModeEnabled = !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -896,6 +983,7 @@ class _CompactPingControlsState extends State { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -1340,7 +1428,9 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when control-relevant state changes (not on GPS/noise/battery). + context.select(_controlsDepsOf); + final appState = context.read(); return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { @@ -1360,6 +1450,7 @@ class LandscapePingControls extends StatelessWidget { final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; final manualCooldownActive = appState @@ -1454,6 +1545,7 @@ class LandscapePingControls extends StatelessWidget { : const Color(0xFF6366F1), // indigo-500 enabled: !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -1498,6 +1590,7 @@ class LandscapePingControls extends StatelessWidget { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && From d145dd4602035af8da2050669e5584078cd8f0c0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 23 Jun 2026 07:34:47 -0400 Subject: [PATCH 164/360] Fix GPS puck blink; log stuck-timer ping lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (GPS puck blink, regression from 764f0d4): the puck shared one MapLibre symbol layer with every coverage pin. Adding a pin rewrote and re-laid-out the whole layer, so for ~1 frame the new pin painted over the puck before the sort-key reordered it on top. New since 764f0d4 because the puck previously sat UNDER the pins. Move the puck into its own dedicated gps-puck-source + gps-puck-layer, installed topmost (no belowLayerId), mirroring _ensureCoverageLinesLayer. Always-on-top is now guaranteed by layer order, not sort-key contention, and a pin can never paint in the puck's isolated layer -> blink gone by construction. enableInteraction:false so taps fall through to repeaters. Re-installs on style reload. Removes the old _gpsZIndex sort-key + _gpsSymbol annotation approach. Bug 2 (intermittent "Send Ping locks out Hybrid/Passive", force-close to clear): NOT a regression from recent commits — the new _isPingSending / _autoPingStarting flags both clear in finally and the disable/RX-window lifecycle was untouched. Latent issue: CountdownTimerService.isRunning is _timer != null with no wall-clock check, so if the 500ms _update() stops firing (iOS suspends timers while backgrounded/driving) the timer never self-cancels; a stuck rxWindowActive then disables Send Ping itself, so the user can't ping to reset it. Add diagnostic logging only (_logStuckTimers): warns [TIMER] isRunning past its deadline when a countdown timer reads isRunning && remainingMs == 0. Runs on app-resume and throttled-5s off the GPS notify (no new timer). Real one-line fix deferred until a debug log confirms the trigger. --- lib/providers/app_state_provider.dart | 58 ++++++++++ lib/widgets/map_widget.dart | 147 +++++++++++++++++++------- 2 files changed, 166 insertions(+), 39 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index dca61abf..ae3f5587 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -423,6 +423,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); _checkTcpHealthAfterResume(); + // Diagnostic: timers can be suspended while backgrounded; on resume, log + // any countdown timer stuck past its deadline (intermittent ping lockout). + _logStuckTimers('resume'); } else if (state == AppLifecycleState.paused) { debugLog('[APP] App paused (backgrounded)'); // Save offline pings immediately on pause to prevent data loss if OS kills app @@ -460,6 +463,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Throttle for the stuck-timer diagnostic (driven off the ~1-2Hz GPS notify). + DateTime? _lastStuckTimerCheck; + + /// Diagnostic ONLY (no state change, no notify): logs any countdown timer that + /// still reports `isRunning` after its deadline has passed (`remainingMs == 0`). + /// That is the fingerprint of the intermittent "Send Ping locks out + /// Hybrid/Passive" lockout — a [CountdownTimerService] whose 500ms `_update()` + /// stopped firing (e.g. iOS suspended its timers while backgrounded/driving) + /// never self-cancels, so `isRunning` (`_timer != null`) sticks true and keeps + /// the ping controls disabled until a force-close. A stuck `rxWindowTimer` + /// additionally disables Send Ping itself, so the user can't ping to reset it. + /// This logging is here to capture the real trigger on-device; the actual fix + /// is deferred until a debug log confirms it. See countdown_timer_service.dart. + void _logStuckTimers(String reason) { + void check(String name, CountdownTimerService t) { + if (t.isRunning && t.remainingMs == 0) { + debugWarn( + '[TIMER] $name isRunning past its deadline (stuck, remaining=0) — ' + 'locks ping controls until restart [$reason]'); + } + } + + check('rxWindowTimer', _rxWindowTimer); + check('cooldownTimer', _cooldownTimer); + check('manualPingCooldownTimer', _manualPingCooldownTimer); + check('discoveryWindowTimer', _discoveryWindowTimer); + check('autoPingTimer', _autoPingTimer); + + // pendingDisable should clear when the RX/discovery window it is waiting on + // completes. Still true while no such window is counting down => stuck. + if (isPendingDisable && + !_rxWindowTimer.isRunning && + !_discoveryWindowTimer.isRunning) { + debugWarn( + '[TIMER] pendingDisable stuck true with no RX/discovery window ' + 'running — locks ping controls until restart [$reason]'); + } + } + + /// Runs [_logStuckTimers] at most once every 5s. Called from the GPS position + /// notify (~1-2Hz during wardriving) so the stuck condition is caught in the + /// foreground without adding a dedicated timer. Connected-only to avoid noise. + void _maybeLogStuckTimers() { + if (!isConnected) return; + final now = DateTime.now(); + final last = _lastStuckTimerCheck; + if (last != null && now.difference(last).inSeconds < 5) return; + _lastStuckTimerCheck = now; + _logStuckTimers('watchdog'); + } + // ============================================ // Getters // ============================================ @@ -1265,6 +1319,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // (keyed on mapRevision) stays cached. notifyListeners(); + // Diagnostic: catch a stuck countdown timer (the intermittent ping-control + // lockout) in the foreground. Throttled to 5s; logs only when stuck. + _maybeLogStuckTimers(); + // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 20f366e0..6ccd661f 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -716,11 +716,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. final Map _coverageZIndex = {}; int _coverageZCounter = 0; - /// GPS puck z-order: a fixed sort key far above the coverage counter - /// (_coverageZCounter, which climbs from 1 per unique ping) so the GPS - /// marker always renders on top of every TX/RX/DISC/Trace pin. Stays exact - /// in the native float32 symbol-sort-key (< 2^24). See _syncCoverageSymbols. - static const int _gpsZIndex = 1 << 23; + // GPS puck lives in its OWN dedicated GeoJSON source + symbol layer rendered + // ABOVE every other layer (coverage fills/pins, repeaters), so it is always on + // top by LAYER ORDER — not by competing for a symbol-sort-key inside the shared + // annotation source. Keeping it out of that shared source also removes the + // one-frame blink where a freshly-added coverage pin painted over the puck + // during the source's full re-layout (the old `_gpsZIndex` sort-key approach). + // Installed via _ensureGpsPuckLayer (re-installed on style reload, see + // _onStyleLoaded) and updated in place via setGeoJsonSource. + static const String _gpsPuckSourceId = 'gps-puck-source'; + static const String _gpsPuckLayerId = 'gps-puck-layer'; + bool _gpsPuckLayerInstalled = false; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -737,7 +743,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // addImage (deduped by "repchip_{status}_{hop}_{hex}"). Lazily grown by // _ensureRepeaterChipImages; cleared on style reload (native drops images). final Set _registeredChipImages = {}; - Symbol? _gpsSymbol; // single GPS marker // When true, _syncAllAnnotations skips _updateFocusLines and // _syncDistanceLabels so the 500ms zoom-to-fit animation runs without @@ -2830,13 +2835,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // CRITICAL: clear stale Symbol references from any previous style load. // Style reloads cause maplibre_gl to construct a brand-new SymbolManager - // with empty internal _idToAnnotation maps. Our _gpsSymbol / - // _coverageSymbols / _distanceLabelSymbols still reference the OLD + // with empty internal _idToAnnotation maps. Our _coverageSymbols / + // _distanceLabelSymbols still reference the OLD // Symbol objects whose IDs are not in the new manager — calling // updateSymbol on them throws "you can only set existing annotations". // Clearing them now means the next sync will call addSymbol (which // creates fresh symbols in the new manager) instead of updateSymbol. - _gpsSymbol = null; + // The dedicated GPS-puck source/layer is wiped by the style reload too; + // reset the guard so _ensureGpsPuckLayer recreates it (topmost) below. + _gpsPuckLayerInstalled = false; _coverageSymbols.clear(); _coverageSymbolSig.clear(); // The native SymbolManager is rebuilt empty here, so the next sync re-adds @@ -2916,6 +2923,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _lastBordersSignature = -1; await _refreshRegionBorders(appState); + // GPS puck: dedicated top-most source+layer. Install AFTER coverage + + // repeaters + borders so it sits above them all (always-on-top by layer + // order). Idempotent; _syncGpsSymbol also ensures it before its first push. + await _ensureGpsPuckLayer(); + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, // we assume tiles are failing to load (network down, server error, etc.) // and surface a banner. Cleared as soon as onMapIdle fires. @@ -4689,19 +4701,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return 0; } - /// Adds, updates, or removes the single GPS position symbol to match - /// [appState.currentPosition]. Called from the post-frame sync trigger. + /// Pushes the single GPS position feature into the dedicated puck source to + /// match [appState.currentPosition]. Called from the post-frame sync trigger. + /// The puck lives in its own top-most layer (see [_ensureGpsPuckLayer]), so + /// this only ever rewrites a one-feature source — it never touches the shared + /// coverage-pin source, which is what eliminated the blink. Future _syncGpsSymbol(AppStateProvider appState) async { if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + // Ensure the dedicated puck source+layer exists (idempotent). On style + // reload _gpsPuckLayerInstalled is reset so this recreates it topmost. + if (!await _ensureGpsPuckLayer()) return; + final pos = appState.currentPosition; if (pos == null) { - // No GPS lock — remove existing GPS symbol if present - if (_gpsSymbol != null) { - try { - await _mapController!.removeSymbol(_gpsSymbol!); - } catch (_) {} - _gpsSymbol = null; + // No GPS lock — clear the puck by pushing an empty collection. Keeps the + // styled layer in place (no remove/recreate). + try { + await _mapController! + .setGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + } catch (e) { + debugError('[MAP] clear gps puck failed: $e'); } return; } @@ -4712,27 +4732,75 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // even when pos.heading is stale or unset. final iconRotate = _gpsIconRotate(style, _computedHeading ?? 0); - final options = SymbolOptions( - geometry: LatLng(pos.latitude, pos.longitude), - iconImage: _MapImages.gps(style), - iconRotate: iconRotate, - // Keep the GPS puck above all coverage pings in the shared annotation - // layer (Critical Rule 9 / _zIndexFor). See _gpsZIndex. - zIndex: _gpsZIndex, - ); + try { + await _mapController!.setGeoJsonSource( + _gpsPuckSourceId, + _buildGpsPuckFeatureCollection( + pos.latitude, pos.longitude, style, iconRotate), + ); + } catch (e) { + debugError('[MAP] update gps puck failed: $e'); + } + } + + /// One-Point FeatureCollection for the GPS puck source. `iconImage` and + /// `iconRotate` are read by the puck symbol layer's data-driven expressions. + Map _buildGpsPuckFeatureCollection( + double lat, double lon, String style, double iconRotate) { + return { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': { + 'iconImage': _MapImages.gps(style), + 'iconRotate': iconRotate, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [lon, lat], + }, + }, + ], + }; + } - if (_gpsSymbol == null) { + /// Idempotently (re)create the dedicated GPS-puck source + symbol layer. + /// The layer is added with NO belowLayerId so it sits on TOP of every other + /// layer (coverage fills/pins, repeaters) — the puck is always-on-top by layer + /// order, with no symbol-sort-key contention. enableInteraction:false so taps + /// pass through to the repeaters/clusters underneath. Mirrors + /// [_ensureCoverageLinesLayer]. + Future _ensureGpsPuckLayer() async { + if (_gpsPuckLayerInstalled) return true; + if (_mapController == null) return false; + try { try { - _gpsSymbol = await _mapController!.addSymbol(options, {'kind': 'gps'}); - } catch (e) { - debugError('[MAP] addSymbol(gps) failed: $e'); - } - } else { + await _mapController!.removeLayer(_gpsPuckLayerId); + } catch (_) {} try { - await _mapController!.updateSymbol(_gpsSymbol!, options); - } catch (e) { - debugError('[MAP] updateSymbol(gps) failed: $e'); - } + await _mapController!.removeSource(_gpsPuckSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + await _mapController!.addSymbolLayer( + _gpsPuckSourceId, + _gpsPuckLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconRotate: ['get', 'iconRotate'], + iconAllowOverlap: true, + iconIgnorePlacement: true, + ), + // No belowLayerId => topmost, above all coverage pings and repeaters. + enableInteraction: false, + ); + _gpsPuckLayerInstalled = true; + return true; + } catch (e) { + debugError('[MAP] gps-puck layer create failed: $e'); + return false; } } @@ -4743,16 +4811,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// bearing animates. Non-rotating styles use iconRotate = 0 and don't care. /// Cheaper than calling [_syncGpsSymbol] which also updates position. Future _updateGpsSymbolRotation() async { - if (_gpsSymbol == null || _mapController == null) return; + if (!_gpsPuckLayerInstalled || _mapController == null) return; final appState = context.read(); final pos = appState.currentPosition; if (pos == null) return; final style = appState.preferences.gpsMarkerStyle; if (!_gpsStyleFacesHeading(style)) return; try { - await _mapController!.updateSymbol( - _gpsSymbol!, - SymbolOptions(iconRotate: _gpsIconRotate(style, _computedHeading ?? 0)), + await _mapController!.setGeoJsonSource( + _gpsPuckSourceId, + _buildGpsPuckFeatureCollection(pos.latitude, pos.longitude, style, + _gpsIconRotate(style, _computedHeading ?? 0)), ); } catch (_) {} } From 222a300decf0731d1bc08e4ece9d0ff70ff95368 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 25 Jun 2026 06:46:54 -0400 Subject: [PATCH 165/360] Hide donation link on iOS to satisfy App Store guideline 3.1.1 External 'Buy us a coffee' (Buy Me a Coffee) link triggered Apple rejection under 3.1.1. Gate the About-section tile behind a platform check so it's omitted on iOS while remaining on Android/Web, where external donation links are permitted. --- lib/screens/settings_screen.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b1681959..d4581280 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -818,12 +818,15 @@ class _SettingsScreenState extends State { 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), + // Buy Me a Coffee — external donation links are fine on Android/Web + // but violate Apple guideline 3.1.1 on iOS, so omit it on iOS. + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), ]), // Exit Options (Android only) From eb74c0e33cf794c5323dd12fd5baa4a37c379164 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 26 Jun 2026 15:27:25 -0400 Subject: [PATCH 166/360] Fix landscape nav/connect layout, 3-byte ID clipping, hybrid bubble, boot notification - #383: bump node-column width 80->88 for 6-char (3-byte) IDs so the last hex digit no longer clips (log cards + map popups) - #224: pad the landscape compact nav bar by viewPadding.bottom so the icons aren't hidden behind the Android system navigation bar - #356: collapse the connection bottom bar onto a single row in landscape so the Scan/Connect button stays reachable after a connection drop - #389: grey Send Ping while an auto mode is starting (add !isAutoStarting guard in all control variants) so the single-ping icon no longer shows until the first hybrid ping fires - #178: initialize the local-notifications plugin before cancel() in cleanupOrphanedService so the orphaned boot notification actually clears --- lib/screens/connection_screen.dart | 27 +++++++++++++++++++++++++++ lib/screens/log_screen.dart | 4 +++- lib/screens/main_scaffold.dart | 5 ++++- lib/services/background_service.dart | 26 +++++++++++++++++++------- lib/widgets/map_widget.dart | 3 ++- lib/widgets/ping_controls.dart | 12 ++++++++++++ 6 files changed, 67 insertions(+), 10 deletions(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ee9ccb23..afe79ad8 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -176,6 +176,33 @@ class _ConnectionScreenState extends State /// Persistent bottom action bar: transport picker + offline toggle + action button Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + + // Landscape has little vertical room: stacking the transport picker under + // the action row pushed the Scan/Connect button off-screen, making it + // impossible to reconnect after a drop (#356). In landscape, keep the + // picker, offline toggle, and action button on a single row so the action + // button is always reachable. + if (isLandscape) { + final showPicker = + !appState.isConnected && _availableTransports().length > 1; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 8), + child: Row( + children: [ + if (showPicker) ...[ + Expanded(child: _buildTransportPicker(context, appState)), + const SizedBox(width: 8), + ], + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 8), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + ); + } + return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 951eba47..a922dd5d 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -686,7 +686,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { double _nodeColumnWidthForLength(int idLength) { if (idLength <= 2) return 60; if (idLength <= 4) return 70; - return 80; + // 6-char (3-byte) IDs need extra room or the last digit clips on some + // devices (#383). + return 88; } Widget _buildTxCard(BuildContext context, TxLogEntry entry, diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 27815d73..e2fb9c5b 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -316,8 +316,11 @@ class _MainScaffoldState extends State { /// Compact navigation bar for landscape mode (icon-only, shorter height) Widget _buildCompactNavBar(AppStateProvider appState) { + // Pad for the system navigation/gesture bar so the icons aren't hidden + // behind it in landscape; the surface background fills the inset area (#224). return Container( - height: 56, + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewPadding.bottom), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border( diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index d5dd44e0..99f6cf7a 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -212,20 +212,32 @@ class BackgroundServiceManager { /// On Android, the foreground service can survive app process death. /// Call this at app startup to ensure no stale notification persists. /// - /// This intentionally does NOT call initialize()/configure(), because - /// configure() with isForegroundMode:true can cause Android to resurrect - /// a killed foreground service as a side effect — producing a phantom - /// notification hours later (the "3am notification" bug). + /// This intentionally does NOT call BackgroundService.configure()/initialize(), + /// because configure() with isForegroundMode:true can cause Android to + /// resurrect a killed foreground service as a side effect — producing a + /// phantom notification hours later (the "3am notification" bug). /// - /// Instead, we cancel the notification directly by ID. Without its - /// required notification, Android will terminate any orphaned foreground - /// service within seconds. + /// We DO initialize the local-notifications plugin first: cancel() silently + /// no-ops until the plugin's platform channel is initialized, which is why + /// the orphaned notification kept reappearing after a reboot (#178). + /// Initializing the notifications plugin has no foreground-service side + /// effects — only configure() does. static Future cleanupOrphanedService() async { if (kIsWeb) return; try { debugLog( '[BACKGROUND] Dismissing any orphaned notification from previous session'); final plugin = FlutterLocalNotificationsPlugin(); + // Establish the platform channel so cancel() actually reaches native. + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await plugin.initialize( + const InitializationSettings(android: androidInit, iOS: iosInit), + ); await plugin.cancel(_notificationId); debugLog('[BACKGROUND] Orphaned notification cleanup complete'); } catch (e) { diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 6ccd661f..d0328f10 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -8066,7 +8066,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { case 2: return 70 + extraPadding; case 3: - return 80 + extraPadding; + // 3-byte (6-char) hex needs extra room or the last digit clips (#383) + return 88 + extraPadding; default: return 60 + extraPadding; } diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 5aa4560b..de1a87c0 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -214,6 +214,10 @@ class PingControls extends StatelessWidget { : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 enabled: canPingManual && + // Grey Send Ping while an auto mode is starting so the + // single-ping icon doesn't show until the first auto ping + // fires (matches active/passive gating) (#389) + !isAutoStarting && !isTxModeRunning && !isTargetedRunning && !cooldownActive && @@ -948,6 +952,10 @@ class _CompactPingControlsState extends State { // Determine which buttons are colored (enabled or active) final sendPingEnabled = canPingManual && + // Grey Send Ping while an auto mode is starting so the single-ping icon + // doesn't show until the first auto ping fires (matches active/passive + // gating) (#389) + !isAutoStarting && !isTxModeRunning && !isTargetedRunning && !cooldownActive && @@ -1503,6 +1511,10 @@ class LandscapePingControls extends StatelessWidget { txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 enabled: canPingManual && + // Grey Send Ping while an auto mode is starting so the + // single-ping icon doesn't show until the first auto ping + // fires (matches active/passive gating) (#389) + !isAutoStarting && !isTxModeRunning && !isTargetedRunning && !cooldownActive && From 5e36b0f28ba3e059d2649167185a332161ff3b9d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 27 Jun 2026 21:42:27 -0400 Subject: [PATCH 167/360] Fix repeater search relevance and surface RX-no-GPS silence - #382: rank hex-prefix matches above name-substring matches when searching the repeater picker, so typing a hex prefix (e.g. "6a") no longer surfaces a different node ("63") whose name merely contains the query - #340: add a throttled onNoGpsDrop notice so RX dropped purely for a missing GPS fix is logged ("RX not logged - no GPS fix") instead of silently stopping and looking offline despite an active connection --- lib/providers/app_state_provider.dart | 11 +++++++++++ lib/services/meshcore/rx_logger.dart | 15 +++++++++++++++ lib/widgets/repeater_picker_sheet.dart | 18 +++++++++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ae3f5587..059dda58 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3216,6 +3216,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { logError('RX Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }, + + // Explain RX silence when there's no GPS fix (#340): without this the RX + // counter quietly stops and the app appears "offline despite Internet". + onNoGpsDrop: () { + debugLog('[APP] RX not logged: no GPS fix available'); + logError( + 'RX not logged\nNo GPS fix — waiting for location.\n' + 'Detection resumes automatically once GPS is restored.', + severity: ErrorSeverity.warning, + autoSwitch: false); + }, ); // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 93074456..5ad029ea 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -34,6 +34,12 @@ class RxLogger { /// Called with repeater ID and reason when a packet is dropped due to carpeater detection final void Function(String repeaterId, String reason)? onCarpeaterDrop; + /// Callback fired (throttled to once per 30s) when an otherwise-valid RX + /// packet is dropped purely because no GPS fix is available. Lets the UI + /// explain why detection went quiet instead of looking offline (#340). + final void Function()? onNoGpsDrop; + DateTime? _lastNoGpsNotify; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped /// to report the underlying repeater with null SNR/RSSI String? carpeaterPrefix; @@ -44,6 +50,7 @@ class RxLogger { required this.getGpsLocation, this.shouldIgnoreRepeater, this.onCarpeaterDrop, + this.onNoGpsDrop, this.carpeaterPrefix, }); @@ -110,6 +117,14 @@ class RxLogger { final gpsLocation = getGpsLocation(); if (gpsLocation == null) { debugLog('[RX LOG] No GPS fix available, skipping entry'); + // Surface the reason at most once per 30s so a missing GPS fix doesn't + // look like the app has gone offline despite an active connection (#340). + final now = DateTime.now(); + if (_lastNoGpsNotify == null || + now.difference(_lastNoGpsNotify!) > const Duration(seconds: 30)) { + _lastNoGpsNotify = now; + onNoGpsDrop?.call(); + } return false; } diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index 4bc45fa8..e7258590 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -50,9 +50,9 @@ class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { } List _filterAndSort(List repeaters, Position? position) { + final q = _query.toLowerCase(); List filtered; - if (_query.isNotEmpty) { - final q = _query.toLowerCase(); + if (q.isNotEmpty) { filtered = repeaters .where((r) => r.name.toLowerCase().contains(q) || @@ -62,8 +62,20 @@ class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { filtered = List.of(repeaters); } - // Sort: active first, then by distance (if GPS), then alphabetically + // When searching, a hex-prefix match is almost always the intended result, + // so it must rank above a repeater that merely has the query somewhere in + // its name. Without this, an active/closer name match sorts to the top and + // looks like the search returned the wrong ID (e.g. "6a" surfacing "63" + // because that node's name contains "6a") (#382). + int hexRank(Repeater r) => + q.isNotEmpty && r.hexId.toLowerCase().startsWith(q) ? 0 : 1; + + // Sort: hex-prefix matches first, then active, then by distance (if GPS), + // then alphabetically filtered.sort((a, b) { + // Hex-prefix matches first (only affects ordering while searching) + final byRelevance = hexRank(a).compareTo(hexRank(b)); + if (byRelevance != 0) return byRelevance; // Active first if (a.isActive != b.isActive) return a.isActive ? -1 : 1; // By distance if GPS available From 01ccec21c4b4ab824e2dc12775e787ee576ecbb5 Mon Sep 17 00:00:00 2001 From: DataArchitectPro Date: Thu, 2 Jul 2026 01:40:14 +0500 Subject: [PATCH 168/360] Forward offline wardrive batches to Custom API after upload After a successful offline batch upload to MeshMapper, mirror the online queue behavior by forwarding the same pings to the configured Custom API endpoint. Adds detailed [CUSTOM API] / [OFFLINE] debug logging for tracing. Co-authored-by: Cursor --- lib/providers/app_state_provider.dart | 33 +++++++++++++++++++++ lib/services/custom_api_service.dart | 41 +++++++++++++++++++++------ pubspec.lock | 24 ++++++++-------- pubspec.yaml | 2 +- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 059dda58..c903f009 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5100,6 +5100,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Extract session_id into local variable — never stored in shared state final offlineSessionId = effectiveAuth!['session_id'] as String?; + final uploadPublicKey = publicKey!; if (offlineSessionId == null) { debugError('[OFFLINE] Auth succeeded but no session_id in response'); return OfflineUploadResult.authFailed; @@ -5179,6 +5180,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (result == UploadResult.success) { uploadedCount += batch.length; debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + _forwardOfflineBatchToCustomApi( + batch, + batchNum: batchNum, + publicKey: uploadPublicKey, + auth: effectiveAuth, + ); continue; } @@ -5234,6 +5241,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Forward a successfully uploaded offline batch to the Custom API endpoint. + void _forwardOfflineBatchToCustomApi( + List> batch, { + required int batchNum, + required String publicKey, + required Map auth, + }) { + final contact = publicKey.length >= 8 + ? publicKey.substring(0, 8).toUpperCase() + : null; + var iata = zoneCode ?? _preferences.iataCode; + final zone = auth['zone']; + if (zone is Map && zone['code'] != null) { + iata = zone['code'].toString(); + } + debugLog( + '[OFFLINE] Custom API forward for batch $batchNum ' + '(${batch.length} pings, contact=$contact, iata=$iata)'); + _customApiService.forwardPings( + batch, + contactOverride: contact, + iataOverride: iata, + source: 'offline', + ); + } + /// Delete an offline session without uploading Future deleteOfflineSession(String filename) async { await _offlineSessionService.deleteSession(filename); diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index f5ba0c71..4615921a 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -41,17 +41,42 @@ class CustomApiService { _client = client ?? http.Client(); /// Fire-and-forget: forward pings to the custom endpoint. - /// Called after successful MeshMapper upload. Never throws. - void forwardPings(List> pings) { + /// Called after successful MeshMapper upload (online queue or offline session). + /// Never throws. + void forwardPings( + List> pings, { + String? contactOverride, + String? iataOverride, + String source = 'online', + }) { + if (pings.isEmpty) return; + final prefs = _prefsGetter(); - if (!prefs.customApiEnabled) return; - if (prefs.customApiUrl == null || prefs.customApiUrl!.isEmpty) return; - if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) return; + debugLog( + '[CUSTOM API] Forward requested ($source): ${pings.length} item(s)'); + + if (!prefs.customApiEnabled) { + debugLog('[CUSTOM API] Forward skipped ($source): Custom API disabled'); + return; + } + if (prefs.customApiUrl == null || prefs.customApiUrl!.isEmpty) { + debugLog('[CUSTOM API] Forward skipped ($source): URL not configured'); + return; + } + if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) { + debugLog('[CUSTOM API] Forward skipped ($source): API key not configured'); + return; + } // Enrich with contact and iata (custom API only — never sent to MeshMapper) - final contact = - prefs.customApiIncludeContact ? contactGetter?.call() : null; - final iata = iataGetter?.call(); + final contact = prefs.customApiIncludeContact + ? (contactOverride ?? contactGetter?.call()) + : null; + final iata = iataOverride ?? iataGetter?.call(); + + debugLog( + '[CUSTOM API] Forwarding ($source) → ${prefs.customApiUrl} ' + '(contact=${contact ?? "off"}, iata=${iata ?? "none"})'); final enriched = pings.map((ping) { final enrichedPing = Map.from(ping); diff --git a/pubspec.lock b/pubspec.lock index c63f0b25..5874ba96 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -468,10 +468,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 + sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935" url: "https://pub.dev" source: hosted - version: "10.12.0" + version: "11.0.0" frontend_server_client: dependency: transitive description: @@ -731,26 +731,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1160,10 +1160,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index adba3007..0902ab8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: just_audio: ^0.9.40 audio_session: ^0.1.21 dio: ^5.0.0 - font_awesome_flutter: ^10.7.0 + font_awesome_flutter: ^11.0.0 fl_chart: ^0.69.0 web: ^1.1.0 disk_space_plus: ^0.2.3 From 298dbd8ea0a8b70ff83675c0be69d88420348496 Mon Sep 17 00:00:00 2001 From: DataArchitectPro Date: Thu, 2 Jul 2026 02:36:11 +0500 Subject: [PATCH 169/360] Revert pubspec font_awesome bump (build-only, not part of feature) Keeps upstream PR limited to offline Custom API forward changes. Co-authored-by: Cursor --- pubspec.lock | 24 ++++++++++++------------ pubspec.yaml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 5874ba96..c63f0b25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -468,10 +468,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935" + sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 url: "https://pub.dev" source: hosted - version: "11.0.0" + version: "10.12.0" frontend_server_client: dependency: transitive description: @@ -731,26 +731,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1160,10 +1160,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.7" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0902ab8d..adba3007 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: just_audio: ^0.9.40 audio_session: ^0.1.21 dio: ^5.0.0 - font_awesome_flutter: ^11.0.0 + font_awesome_flutter: ^10.7.0 fl_chart: ^0.69.0 web: ^1.1.0 disk_space_plus: ^0.2.3 From 10599111da7f220d71f9dc5887e451e314822109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= Date: Wed, 15 Jul 2026 01:29:04 +0200 Subject: [PATCH 170/360] feat(ios): add Live Activity support for mapping sessions Add an ActivityKit and WidgetKit integration for iOS mapping sessions. - show the current wardriving phase, including sending, listening, cooldown, and waiting states - render system-driven countdowns from shared phase deadlines - display up to three heard repeaters with SNR and hop information - include session counters, queue state, zone, GPS, and connection status - provide layouts for the Lock Screen, Dynamic Island, and compact CarPlay presentation - throttle native updates and avoid duplicate Live Activities - mark stale session data and show a final summary when a session ends - keep the integration dependency-free and isolated from existing map presentation logic --- docs/LIVE_ACTIVITIES.md | 29 + ios/Flutter/LiveActivity.xcconfig | 1 + ios/MeshMapperLiveActivity/Info.plist | 29 + .../MeshMapperLiveActivity.swift | 497 +++++++++++++++ .../MeshMapperLiveActivityBundle.swift | 9 + ios/Runner.xcodeproj/project.pbxproj | 203 ++++++ ios/Runner/AppDelegate.swift | 16 + ios/Runner/Info.plist | 2 + ios/Runner/LiveActivityManager.swift | 258 ++++++++ ios/Shared/MeshMapperActivityAttributes.swift | 39 ++ lib/providers/app_state_provider.dart | 585 +++++++++++++++++- lib/services/countdown_timer_service.dart | 3 + .../live_activity/live_activity_models.dart | 136 ++++ .../live_activity/live_activity_service.dart | 154 +++++ .../live_activity_models_test.dart | 102 +++ 15 files changed, 2046 insertions(+), 17 deletions(-) create mode 100644 docs/LIVE_ACTIVITIES.md create mode 100644 ios/Flutter/LiveActivity.xcconfig create mode 100644 ios/MeshMapperLiveActivity/Info.plist create mode 100644 ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift create mode 100644 ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift create mode 100644 ios/Runner/LiveActivityManager.swift create mode 100644 ios/Shared/MeshMapperActivityAttributes.swift create mode 100644 lib/services/live_activity/live_activity_models.dart create mode 100644 lib/services/live_activity/live_activity_service.dart create mode 100644 test/services/live_activity/live_activity_models_test.dart diff --git a/docs/LIVE_ACTIVITIES.md b/docs/LIVE_ACTIVITIES.md new file mode 100644 index 00000000..f0f6075a --- /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 00000000..592ceee8 --- /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 00000000..9ff3b6bb --- /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 00000000..efe9fa71 --- /dev/null +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -0,0 +1,497 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct MeshMapperLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: MeshMapperActivityAttributes.self) { context in + Group { + if #available(iOSApplicationExtension 18.0, *) { + MeshMapperResponsiveActivityView(context: context) + } else { + MeshMapperLockScreenView(context: context) + } + } + .activityBackgroundTint(MeshMapperPalette.background) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + MeshMapperModeBadge(mode: context.state.mode) + } + DynamicIslandExpandedRegion(.trailing) { + MeshMapperBestSignal(state: context.state) + } + DynamicIslandExpandedRegion(.center) { + MeshMapperPhaseLabel(state: context.state) + } + DynamicIslandExpandedRegion(.bottom) { + MeshMapperIslandBottom(state: context.state) + } + } compactLeading: { + Image( + systemName: context.isStale + ? "exclamationmark.triangle.fill" : context.state.phaseSymbol + ) + .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) + .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) + } compactTrailing: { + MeshMapperCompactTrailing(state: context.state) + } minimal: { + Image( + systemName: context.isStale + ? "exclamationmark.triangle.fill" : context.state.phaseSymbol + ) + .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) + .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) + } + .keylineTint(context.state.phaseColor) + } + .meshMapperSupplementalActivityFamilies() + } +} + +extension ActivityConfiguration { + fileprivate func meshMapperSupplementalActivityFamilies() -> some WidgetConfiguration { + if #available(iOSApplicationExtension 18.0, *) { + return supplementalActivityFamilies([.small]) + } else { + return self + } + } +} + +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperResponsiveActivityView: View { + @Environment(\.activityFamily) private var activityFamily + let context: ActivityViewContext + + var body: some View { + if activityFamily == .small { + MeshMapperSmallActivityView(context: context) + } else { + MeshMapperLockScreenView(context: context) + } + } +} + +private struct MeshMapperLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + MeshMapperModeBadge(mode: context.state.mode) + Spacer(minLength: 8) + MeshMapperStatusLabel(context: context) + } + + HStack(alignment: .center, spacing: 9) { + Image(systemName: context.state.phaseSymbol) + .font(.title3.weight(.semibold)) + .foregroundStyle(context.state.phaseColor) + .frame(width: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(context.state.phaseTitle) + .font(.headline.weight(.semibold)) + .lineLimit(1) + if let detail = context.state.phaseDetail, !detail.isEmpty { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 6) + MeshMapperCountdown( + state: context.state, + font: .headline.monospacedDigit().weight(.semibold) + ) + } + + HStack(alignment: .bottom, spacing: 12) { + MeshMapperRepeaterSummary(state: context.state) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .trailing, spacing: 4) { + HStack(spacing: 9) { + MeshMapperMetric( + label: context.state.primaryMetricLabel, + value: context.state.primaryMetricValue + ) + MeshMapperMetric(label: "RX", value: context.state.rxCount) + } + if context.state.queueSize > 0 { + Label("Queue \(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .foregroundStyle(.white) + } +} + +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperSmallActivityView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 7) { + Image(systemName: context.state.phaseSymbol) + .foregroundStyle(context.state.phaseColor) + .accessibilityHidden(true) + Text(context.state.mode.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(0.6) + Spacer(minLength: 4) + if context.isStale { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + .accessibilityLabel("Update delayed") + } else if let zone = context.state.zoneCode { + Text(zone) + .font(.system(.caption2, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + } + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(context.state.phaseTitle) + .font(.headline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 4) + MeshMapperCountdown( + state: context.state, + font: .headline.monospacedDigit().weight(.semibold) + ) + } + + MeshMapperBestRepeaterRow(state: context.state) + + HStack(spacing: 10) { + Text("\(context.state.primaryMetricLabel) \(context.state.primaryMetricValue)") + Text("RX \(context.state.rxCount)") + Spacer(minLength: 0) + if context.state.queueSize > 0 { + Label("\(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + } + } + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(12) + .foregroundStyle(.white) + } +} + +private struct MeshMapperStatusLabel: View { + let context: ActivityViewContext + + var body: some View { + Label( + context.isStale + ? "Update delayed" : context.state.zoneCode ?? context.state.connectionLabel, + systemImage: context.isStale + ? "exclamationmark.triangle.fill" + : context.state.isConnected + ? "antenna.radiowaves.left.and.right" + : "wifi.slash" + ) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .foregroundStyle( + context.isStale || !context.state.isConnected + ? Color.orange : MeshMapperPalette.secondary + ) + } +} + +private struct MeshMapperRepeaterSummary: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD") + .font(.caption2.weight(.bold)) + .tracking(0.6) + .foregroundStyle(.secondary) + if state.totalHeardCount > 0 { + Text("\(state.totalHeardCount)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + } + } + + if state.repeaters.isEmpty { + Text(state.repeaterEmptyLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + ForEach(state.repeaters.prefix(2)) { repeater in + HStack(spacing: 6) { + Circle() + .fill(MeshMapperPalette.secondary) + .frame(width: 5, height: 5) + .accessibilityHidden(true) + Text(repeater.displayName) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text(repeater.snr.formattedSnr) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(repeater.displayName), SNR \(repeater.snr.formattedSnr)") + } + } + } + } +} + +private struct MeshMapperBestRepeaterRow: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if let best = state.repeaters.first { + HStack(spacing: 6) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.caption2) + .foregroundStyle(MeshMapperPalette.secondary) + .accessibilityHidden(true) + Text(best.displayName) + .font(.caption.weight(.medium)) + .lineLimit(1) + Spacer(minLength: 4) + Text(best.snr.formattedSnr) + .font(.caption.monospacedDigit().weight(.semibold)) + if state.totalHeardCount > 1 { + Text("+\(state.totalHeardCount - 1)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + "Best repeater \(best.displayName), SNR \(best.snr.formattedSnr), " + + "\(state.totalHeardCount) heard" + ) + } else { + Text(state.repeaterEmptyLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +private struct MeshMapperIslandBottom: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(spacing: 6) { + MeshMapperBestRepeaterRow(state: state) + HStack(spacing: 12) { + Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") + Text("RX \(state.rxCount)") + if let zone = state.zoneCode { + Spacer() + Text(zone) + } + } + .font(.caption.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + } +} + +private struct MeshMapperModeBadge: View { + let mode: String + + var body: some View { + Label(mode.uppercased(), systemImage: "antenna.radiowaves.left.and.right") + .font(.caption2.weight(.bold)) + .tracking(0.6) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(MeshMapperPalette.primary, in: Capsule()) + } +} + +private struct MeshMapperPhaseLabel: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(spacing: 2) { + Text(state.phaseTitle) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + MeshMapperCountdown( + state: state, + font: .caption.monospacedDigit().weight(.semibold) + ) + .foregroundStyle(.secondary) + } + } +} + +private struct MeshMapperBestSignal: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if let best = state.repeaters.first { + VStack(alignment: .trailing, spacing: 2) { + Text(best.snr.formattedSnr) + .font(.subheadline.monospacedDigit().weight(.semibold)) + Text("best SNR") + .font(.caption2) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + Image(systemName: "waveform.slash") + .foregroundStyle(.secondary) + .accessibilityLabel("No repeaters heard") + } + } +} + +private struct MeshMapperCompactTrailing: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if state.hasActiveCountdown { + MeshMapperCountdown( + state: state, + font: .caption2.monospacedDigit().weight(.bold) + ) + .frame(minWidth: 28) + } else if let best = state.repeaters.first { + Text(best.snr.formattedSnr) + .font(.caption2.monospacedDigit().weight(.bold)) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + Text("\(state.rxCount)") + .font(.caption2.monospacedDigit().weight(.bold)) + .accessibilityLabel("\(state.rxCount) received") + } + } +} + +private struct MeshMapperCountdown: View { + let state: MeshMapperActivityAttributes.ContentState + let font: Font + + var body: some View { + if let end = state.phaseEndsAt, end > Date() { + Text(timerInterval: Date()...end, countsDown: true, showsHours: false) + .font(font) + .lineLimit(1) + .accessibilityLabel("Time remaining") + } + } +} + +private struct MeshMapperMetric: View { + let label: String + let value: Int + + var body: some View { + Text("\(label) \(value)") + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityLabel("\(label) \(value)") + } +} + +private enum MeshMapperPalette { + static let background = Color(red: 0.055, green: 0.075, blue: 0.105) + static let primary = Color(red: 0.12, green: 0.43, blue: 0.92) + static let secondary = Color(red: 0.30, green: 0.82, blue: 0.78) +} + +extension MeshMapperActivityAttributes.ContentState { + fileprivate var hasActiveCountdown: Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt > Date() + } + + fileprivate var connectionLabel: String { + isConnected ? "Connected" : "Disconnected" + } + + fileprivate var primaryMetricLabel: String { + switch mode.lowercased() { + case "passive": return "DISC" + case "trace": return "TRACE" + default: return "TX" + } + } + + fileprivate var primaryMetricValue: Int { + switch mode.lowercased() { + case "passive": return discoveryCount + case "trace": return traceCount + default: return txCount + } + } + + fileprivate var repeaterEmptyLabel: String { + switch phase { + case "listening", "listening_discovery", "listening_trace": + return "No repeaters heard yet" + default: + return "No repeaters heard in the last cycle" + } + } + + fileprivate var phaseSymbol: String { + switch phase { + case "sending": return "arrow.up.circle.fill" + case "discovering": return "dot.radiowaves.left.and.right" + case "tracing": return "scope" + case "listening", "listening_discovery", "listening_trace": return "waveform" + case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return "timer" + case "skipped": return "forward.end.fill" + case "stopping", "stopped": return "stop.circle.fill" + case "waiting_for_gps": return "location.slash.fill" + case "paused_outside_zone": return "map.fill" + case "disconnected": return "wifi.slash" + case "tx_blocked": return "nosign" + case "starting": return "hourglass" + default: return "antenna.radiowaves.left.and.right" + } + } + + fileprivate var phaseColor: Color { + switch phase { + case "sending", "discovering", "tracing": return MeshMapperPalette.primary + case "listening", "listening_discovery", "listening_trace": return MeshMapperPalette.secondary + case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return .cyan + case "skipped", "waiting_for_gps", "paused_outside_zone": return .orange + case "disconnected", "tx_blocked": return .red + case "stopped": return .gray + default: return .white + } + } +} + +extension Double { + fileprivate var formattedSnr: String { + let sign = self >= 0 ? "+" : "" + return "\(sign)\(formatted(.number.precision(.fractionLength(1)))) dB" + } +} diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift new file mode 100644 index 00000000..08195fee --- /dev/null +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct MeshMapperLiveActivityBundle: WidgetBundle { + var body: some Widget { + MeshMapperLiveActivity() + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d045a55f..bcf84746 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,12 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + A10000000000000000000001 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LiveActivityManager.swift */; }; + A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; }; + A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; }; + A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MeshMapperLiveActivity.swift */; }; + A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */; }; + A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -27,6 +33,13 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + A60000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = MeshMapperLiveActivityExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -40,6 +53,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000004 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -67,6 +91,13 @@ D478469A7D705340684EFF2B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + A20000000000000000000001 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + A20000000000000000000002 /* MeshMapperActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperActivityAttributes.swift; sourceTree = ""; }; + A20000000000000000000003 /* MeshMapperLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivity.swift; sourceTree = ""; }; + A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivityBundle.swift; sourceTree = ""; }; + A20000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MeshMapperLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A20000000000000000000007 /* LiveActivity.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = LiveActivity.xcconfig; path = Flutter/LiveActivity.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,6 +118,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -128,6 +166,7 @@ 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, + A20000000000000000000007 /* LiveActivity.xcconfig */, ); name = Flutter; sourceTree = ""; @@ -137,6 +176,8 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + A30000000000000000000001 /* Shared */, + A30000000000000000000002 /* MeshMapperLiveActivity */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 88C2A374596BDB4F1DE4A0B4 /* Pods */, @@ -149,6 +190,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */, ); name = Products; sourceTree = ""; @@ -163,11 +205,30 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A20000000000000000000001 /* LiveActivityManager.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; + A30000000000000000000001 /* Shared */ = { + isa = PBXGroup; + children = ( + A20000000000000000000002 /* MeshMapperActivityAttributes.swift */, + ); + path = Shared; + sourceTree = ""; + }; + A30000000000000000000002 /* MeshMapperLiveActivity */ = { + isa = PBXGroup; + children = ( + A20000000000000000000003 /* MeshMapperLiveActivity.swift */, + A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */, + A20000000000000000000005 /* Info.plist */, + ); + path = MeshMapperLiveActivity; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -198,6 +259,7 @@ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( E42ACB57393E36F474C65ADB /* [CP] Check Pods Manifest.lock */, + A40000000000000000000004 /* Embed Foundation Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -210,12 +272,30 @@ buildRules = ( ); dependencies = ( + A70000000000000000000001 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + A50000000000000000000001 /* MeshMapperLiveActivityExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */; + buildPhases = ( + A40000000000000000000001 /* Sources */, + A40000000000000000000002 /* Frameworks */, + A40000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshMapperLiveActivityExtension; + productName = MeshMapperLiveActivityExtension; + productReference = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -237,6 +317,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + A50000000000000000000001 = { + CreatedOnToolsVersion = 15.1; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -254,6 +337,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + A50000000000000000000001 /* MeshMapperLiveActivityExtension */, ); }; /* End PBXProject section */ @@ -277,6 +361,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -405,10 +496,22 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A10000000000000000000001 /* LiveActivityManager.swift in Sources */, + A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */, + A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */, + A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -417,6 +520,11 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + A70000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */; + targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -728,6 +836,91 @@ }; name = Release; }; + A80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = DQC6TNKG5P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = DQC6TNKG5P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + A80000000000000000000003 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = DQC6TNKG5P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -761,6 +954,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000001 /* Debug */, + A80000000000000000000002 /* Release */, + A80000000000000000000003 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 34f47997..f12d4c36 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -67,6 +67,7 @@ class IOSMapOfflineBridge { @main @objc class AppDelegate: FlutterAppDelegate { private let mapOfflineBridge = IOSMapOfflineBridge() + private let liveActivityManager = LiveActivityManager() override func application( _ application: UIApplication, @@ -108,6 +109,21 @@ class IOSMapOfflineBridge { } } + // Method channel: local ActivityKit status for active wardriving + // sessions. The widget extension renders the Lock Screen, Dynamic Island, + // and compact system/CarPlay presentations from these snapshots. + let liveActivityChannel = FlutterMethodChannel( + name: "meshmapper/live_activity", + binaryMessenger: controller.binaryMessenger + ) + liveActivityChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + self.liveActivityManager.handle(call, result: result) + } + // Method channel: MapLibre tile cache management. Mirrors the Android // handler in MainActivity.kt. Dart's TileCacheService calls into these // from the Offline Maps screen's Tile Cache card. diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 251402f2..8722daf1 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 00000000..a806a083 --- /dev/null +++ b/ios/Runner/LiveActivityManager.swift @@ -0,0 +1,258 @@ +import ActivityKit +import Flutter +import Foundation + +/// Native ActivityKit endpoint for the Flutter method channel. +/// +/// Flutter sends complete snapshots. The manager keeps at most one activity, +/// updates it serially, and doesn't recreate a Live Activity the user dismissed +/// during the same wardriving session. +final class LiveActivityManager { + private enum BridgeError: LocalizedError { + case invalidArguments + + var errorDescription: String? { + "The Live Activity payload was invalid." + } + } + + private var requestedSessionID: String? + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "sync": + guard let payload = call.arguments as? [String: Any] else { + result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments")) + return + } + guard #available(iOS 16.2, *) else { + result(false) + return + } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + result(false) + return + } + + Task { @MainActor in + do { + let parsed = try parse(payload) + try await sync(attributes: parsed.attributes, state: parsed.state) + result(true) + } catch { + result(self.flutterError(error, code: "sync_failed")) + } + } + + case "end": + guard #available(iOS 16.2, *) else { + result(nil) + return + } + let immediate = + (call.arguments as? [String: Any])?["immediate"] as? Bool ?? false + Task { @MainActor in + await endAll(keepSummaryFor: immediate ? 0 : 60) + requestedSessionID = nil + result(nil) + } + + default: + result(FlutterMethodNotImplemented) + } + } + + private func flutterError(_ error: Error, code: String) -> FlutterError { + FlutterError( + code: code, + message: error.localizedDescription, + details: String(describing: error) + ) + } + + @available(iOS 16.2, *) + private func parse( + _ payload: [String: Any] + ) throws -> ( + attributes: MeshMapperActivityAttributes, + state: MeshMapperActivityAttributes.ContentState + ) { + guard let sessionID = boundedString(payload["sessionId"], maxLength: 64), + let mode = boundedString(payload["mode"], maxLength: 16), + let phase = boundedString(payload["phase"], maxLength: 32), + let phaseTitle = boundedString(payload["phaseTitle"], maxLength: 48), + let updatedAt = date(payload["updatedAt"]) + else { + throw BridgeError.invalidArguments + } + + let repeaterPayloads = payload["repeaters"] as? [Any] ?? [] + let repeaters = repeaterPayloads.prefix(3).compactMap { + rawItem -> MeshMapperActivityAttributes.HeardRepeater? in + guard let item = rawItem as? [String: Any], + let id = boundedString(item["id"], maxLength: 16), + let snr = finiteNumber(item["snr"]) + else { + return nil + } + return MeshMapperActivityAttributes.HeardRepeater( + id: id, + name: boundedString(item["name"], maxLength: 36), + snr: min(max(snr, -200), 200) + ) + } + + let attributes = MeshMapperActivityAttributes(sessionID: sessionID) + let state = MeshMapperActivityAttributes.ContentState( + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: boundedString(payload["phaseDetail"], maxLength: 80), + phaseEndsAt: date(payload["phaseEndsAt"]), + isConnected: payload["isConnected"] as? Bool ?? false, + zoneCode: boundedString(payload["zoneCode"], maxLength: 12), + txCount: nonnegativeInteger(payload["txCount"]), + rxCount: nonnegativeInteger(payload["rxCount"]), + discoveryCount: nonnegativeInteger(payload["discoveryCount"]), + traceCount: nonnegativeInteger(payload["traceCount"]), + queueSize: nonnegativeInteger(payload["queueSize"]), + repeaters: repeaters, + totalHeardCount: max(nonnegativeInteger(payload["totalHeardCount"]), repeaters.count), + repeatersAreCurrent: payload["repeatersAreCurrent"] as? Bool ?? false, + updatedAt: updatedAt + ) + return (attributes, state) + } + + @available(iOS 16.2, *) + @MainActor + private func sync( + attributes: MeshMapperActivityAttributes, + state: MeshMapperActivityAttributes.ContentState + ) async throws { + let activities = Activity.activities + let matching = activities.filter { $0.attributes.sessionID == attributes.sessionID } + let unrelated = activities.filter { $0.attributes.sessionID != attributes.sessionID } + + for activity in unrelated { + await end(activity, keepSummaryFor: 0) + } + + let content = ActivityContent( + state: state, + staleDate: staleDate(for: state) + ) + + if let activity = matching.first { + requestedSessionID = attributes.sessionID + await activity.update(content) + for duplicate in matching.dropFirst() { + await end(duplicate, keepSummaryFor: 0) + } + return + } + + // A missing activity with the same session ID means the user or system + // dismissed it. Don't recreate it until MeshMapper starts a new session. + if requestedSessionID == attributes.sessionID { + return + } + + requestedSessionID = attributes.sessionID + do { + _ = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + } catch { + requestedSessionID = nil + throw error + } + } + + @available(iOS 16.2, *) + private func staleDate( + for state: MeshMapperActivityAttributes.ContentState + ) -> Date { + if let phaseEndsAt = state.phaseEndsAt { + return phaseEndsAt.addingTimeInterval(45) + } + return state.updatedAt.addingTimeInterval(5 * 60) + } + + @available(iOS 16.2, *) + @MainActor + private func endAll(keepSummaryFor seconds: TimeInterval) async { + for activity in Activity.activities { + await end(activity, keepSummaryFor: seconds) + } + } + + @available(iOS 16.2, *) + @MainActor + private func end( + _ activity: Activity, + keepSummaryFor seconds: TimeInterval + ) async { + var finalState = activity.content.state + finalState.phase = "stopped" + finalState.phaseTitle = "Session ended" + finalState.phaseDetail = summary(for: finalState) + finalState.phaseEndsAt = nil + finalState.repeatersAreCurrent = false + finalState.updatedAt = Date() + + let dismissalPolicy: ActivityUIDismissalPolicy = + seconds > 0 + ? .after(Date().addingTimeInterval(seconds)) + : .immediate + await activity.end( + ActivityContent(state: finalState, staleDate: nil), + dismissalPolicy: dismissalPolicy + ) + } + + @available(iOS 16.2, *) + private func summary( + for state: MeshMapperActivityAttributes.ContentState + ) -> String { + var parts = ["TX \(state.txCount)", "RX \(state.rxCount)"] + if state.discoveryCount > 0 { parts.append("DISC \(state.discoveryCount)") } + if state.traceCount > 0 { parts.append("TRACE \(state.traceCount)") } + return parts.joined(separator: " · ") + } + + private func boundedString(_ value: Any?, maxLength: Int) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(maxLength)) + } + + private func date(_ value: Any?) -> Date? { + guard let milliseconds = finiteNumber(value) else { return nil } + return Date(timeIntervalSince1970: milliseconds / 1000) + } + + private func finiteNumber(_ value: Any?) -> Double? { + let parsed: Double? + if let number = value as? NSNumber { + parsed = number.doubleValue + } else if let value = value as? Double { + parsed = value + } else if let value = value as? Int { + parsed = Double(value) + } else { + parsed = nil + } + guard let parsed, parsed.isFinite else { return nil } + return parsed + } + + private func nonnegativeInteger(_ value: Any?) -> Int { + if let number = value as? NSNumber { return max(number.intValue, 0) } + if let value = value as? Int { return max(value, 0) } + return 0 + } +} diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift new file mode 100644 index 00000000..25f16d2e --- /dev/null +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -0,0 +1,39 @@ +import ActivityKit +import Foundation + +/// Shared ActivityKit contract used by Runner and the widget extension. +/// Keep the payload compact because ActivityKit limits attributes and state. +@available(iOS 16.2, *) +struct MeshMapperActivityAttributes: ActivityAttributes { + struct HeardRepeater: Codable, Hashable, Identifiable { + let id: String + let name: String? + let snr: Double + + var displayName: String { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? id : trimmed + } + } + + struct ContentState: Codable, Hashable { + var mode: String + var phase: String + var phaseTitle: String + var phaseDetail: String? + var phaseEndsAt: Date? + var isConnected: Bool + var zoneCode: String? + var txCount: Int + var rxCount: Int + var discoveryCount: Int + var traceCount: Int + var queueSize: Int + var repeaters: [HeardRepeater] + var totalHeardCount: Int + var repeatersAreCurrent: Bool + var updatedAt: Date + } + + let sessionID: String +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 059dda58..3f884f11 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -45,6 +45,8 @@ import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; +import '../services/live_activity/live_activity_models.dart'; +import '../services/live_activity/live_activity_service.dart'; import '../services/custom_api_service.dart'; import '../utils/constants.dart'; import '../utils/geo_validation.dart'; @@ -70,6 +72,8 @@ enum AutoMode { /// Ping type for the top-heard overlay dots enum OverlayPingType { tx, disc, trace, rx } +enum _LiveActivityOperation { sending, discovering, tracing } + /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully @@ -124,6 +128,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) late final Listenable _timerListenable; + + final LiveActivityService _liveActivityService = LiveActivityService(); + bool _liveActivitySessionActive = false; + bool _liveActivityManualSession = false; + String? _liveActivitySessionId; + DateTime? _liveActivityCycleStartedAt; + _LiveActivityOperation? _liveActivityOperation; MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -222,6 +233,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; + // Live Activity repeater snapshot. Kept separate from the map overlay so the + // system presentation cannot change existing in-app overlay behaviour. + List<({String repeaterId, double snr})> _liveActivityRepeaters = []; + int _liveActivityRepeaterTotalCount = 0; + DateTime? _liveActivityRepeatersUpdatedAt; + DateTime? _liveActivityRxUpdatedAt; + // Targeted mode state String? _targetRepeaterId; @@ -349,6 +367,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Timer? _zoneGracePollingTimer; // 5-second zone polling Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick int _zoneGraceSecondsRemaining = 0; + DateTime? _zoneGraceEndsAt; bool _autoPingWasEnabledBeforeGrace = false; AutoMode _autoModeBeforeGrace = AutoMode.active; static const Duration _zoneGraceTimeout = Duration(minutes: 5); @@ -598,15 +617,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _topRepeatersOverlay = fresh.take(3).toList(); } + void _updateLiveActivityRepeaters( + Iterable<({String repeaterId, double snr})> current) { + final bestSnr = {}; + for (final repeater in current) { + if (!repeater.snr.isFinite) continue; + final id = repeater.repeaterId.toUpperCase(); + final previous = bestSnr[id]; + if (previous == null || repeater.snr > previous) { + bestSnr[id] = repeater.snr; + } + } + + final sorted = bestSnr.entries + .map((entry) => (repeaterId: entry.key, snr: entry.value)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + _liveActivityRepeaters = sorted.take(3).toList(growable: false); + _liveActivityRepeaterTotalCount = sorted.length; + _liveActivityRepeatersUpdatedAt = DateTime.now(); + } + /// Update the RX overlay slot — window matches auto-ping interval (best SNR wins). void _updateRxOverlaySlot(String repeaterId, double snr) { final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); if (_rxOverlayWindowTimer?.isActive ?? false) { if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { _rxOverlaySlot = entry; + _liveActivityRxUpdatedAt = DateTime.now(); } } else { _rxOverlaySlot = entry; + _liveActivityRxUpdatedAt = DateTime.now(); _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared @@ -620,6 +662,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlaySlot = null; _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; + _liveActivityRepeaters = []; + _liveActivityRepeaterTotalCount = 0; + _liveActivityRepeatersUpdatedAt = null; + _liveActivityRxUpdatedAt = null; } List get txLogEntries => List.unmodifiable(_txLogEntries); @@ -1072,6 +1118,420 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveryWindowTimer; // Discovery listening window (Passive Mode) Listenable get timerListenable => _timerListenable; + void _handleLiveActivityTimerChange() { + if (_liveActivityManualSession && + !_isPingSending && + !_rxWindowTimer.isRunning && + !_manualPingCooldownTimer.isRunning) { + _finishLiveActivitySession(); + return; + } + _scheduleLiveActivitySync(); + } + + void _startLiveActivitySession({bool manual = false}) { + if (!_liveActivityService.isSupportedPlatform) return; + if (_liveActivitySessionActive) { + // Starting an automatic mode while a manual-ping activity is still in + // cooldown upgrades the existing activity instead of creating a second. + if (!manual && _liveActivityManualSession) { + _liveActivityManualSession = false; + _scheduleLiveActivitySync(immediate: true); + } + return; + } + _liveActivitySessionActive = true; + _liveActivityManualSession = manual; + _liveActivitySessionId = const Uuid().v4(); + _liveActivityCycleStartedAt = _activeLiveActivityCycleStartedAt; + _liveActivityOperation = null; + _scheduleLiveActivitySync(immediate: true); + } + + DateTime? get _activeLiveActivityCycleStartedAt { + if (_rxWindowTimer.isRunning && _txLogEntries.isNotEmpty) { + return _txLogEntries.last.timestamp; + } + if (!_discoveryWindowTimer.isRunning) return null; + if (_autoMode == AutoMode.targeted && _traceLogEntries.isNotEmpty) { + return _traceLogEntries.first.timestamp; + } + if (_discLogEntries.isNotEmpty) { + return _discLogEntries.first.timestamp; + } + return null; + } + + void _finishLiveActivitySession() { + if (!_liveActivitySessionActive) return; + _liveActivitySessionActive = false; + _liveActivityManualSession = false; + _liveActivityOperation = null; + _liveActivityCycleStartedAt = null; + _scheduleLiveActivitySync(immediate: true); + _liveActivitySessionId = null; + } + + void _markLiveActivityOperation(_LiveActivityOperation operation) { + if (!_liveActivitySessionActive || + !_liveActivityService.isSupportedPlatform) { + return; + } + final now = DateTime.now(); + _liveActivityOperation = operation; + _liveActivityCycleStartedAt = now; + _scheduleLiveActivitySync(immediate: true); + } + + void _scheduleLiveActivitySync({bool immediate = false}) { + if (_isDisposed || !_liveActivityService.isSupportedPlatform) return; + _liveActivityService.schedule( + _buildLiveActivitySnapshot, + immediate: immediate, + ); + } + + LiveActivitySnapshot? _buildLiveActivitySnapshot() { + final sessionId = _liveActivitySessionId; + if (!_liveActivitySessionActive || sessionId == null) { + return null; + } + + final phase = _resolveLiveActivityPhase(); + final repeaterState = _buildLiveActivityRepeaters(); + + return LiveActivitySnapshot( + sessionId: sessionId, + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + isConnected: isConnected, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + discoveryCount: _pingStats.discCount, + traceCount: _pingStats.traceCount, + queueSize: _queueSize, + repeaters: repeaterState.repeaters, + totalHeardCount: repeaterState.totalCount, + repeatersAreCurrent: repeaterState.isCurrent, + updatedAt: DateTime.now(), + ); + } + + ({ + LiveActivityPhase phase, + String title, + String? detail, + DateTime? endsAt, + }) _resolveLiveActivityPhase() { + if (_isInZoneGracePeriod) { + return ( + phase: LiveActivityPhase.pausedOutsideZone, + title: 'Outside service area', + detail: 'Searching for a nearby wardriving zone', + endsAt: _zoneGraceEndsAt, + ); + } + + if (_isZoneTransferInProgress) { + return ( + phase: LiveActivityPhase.pausedOutsideZone, + title: 'Changing region…', + detail: [_zoneTransferFrom, _zoneTransferTo] + .whereType() + .join(' → '), + endsAt: null, + ); + } + + if (_isAutoReconnecting || _connectionStep == ConnectionStep.reconnecting) { + return ( + phase: LiveActivityPhase.disconnected, + title: 'Reconnecting…', + detail: 'Restoring MeshCore connection', + endsAt: null, + ); + } + + if (!isConnected) { + return ( + phase: LiveActivityPhase.disconnected, + title: _connectionStep == ConnectionStep.disconnecting + ? 'Disconnecting…' + : 'Device disconnected', + detail: 'Open MeshMapper to reconnect', + endsAt: null, + ); + } + + if (isPendingDisable) { + return ( + phase: LiveActivityPhase.stopping, + title: 'Stopping…', + detail: 'Finishing the current listening window', + endsAt: _rxWindowTimer.endTime ?? _discoveryWindowTimer.endTime, + ); + } + + if (_gpsStatus != GpsStatus.locked) { + return ( + phase: LiveActivityPhase.waitingForGps, + title: 'Waiting for GPS', + detail: _liveActivityGpsLabel, + endsAt: null, + ); + } + + if ((_autoMode == AutoMode.active || + _autoMode == AutoMode.hybrid || + _autoMode == AutoMode.targeted) && + !txAllowed) { + return ( + phase: LiveActivityPhase.txBlocked, + title: 'TX unavailable', + detail: 'This zone is currently passive-only', + endsAt: null, + ); + } + + if (_liveActivityManualSession && _isPingSending) { + return ( + phase: LiveActivityPhase.sending, + title: 'Sending ping…', + detail: null, + endsAt: null, + ); + } + + if (_discoveryWindowTimer.isRunning) { + final isTrace = _autoMode == AutoMode.targeted; + return ( + phase: isTrace + ? LiveActivityPhase.listeningTrace + : LiveActivityPhase.listeningDiscovery, + title: isTrace ? 'Listening for trace…' : 'Listening…', + detail: isTrace ? _targetRepeaterDisplayName : 'Discovery responses', + endsAt: _discoveryWindowTimer.endTime, + ); + } + + if (_rxWindowTimer.isRunning) { + return ( + phase: LiveActivityPhase.listening, + title: 'Listening…', + detail: 'Waiting for repeater echoes', + endsAt: _rxWindowTimer.endTime, + ); + } + + if (_liveActivityManualSession && + _manualPingCooldownTimer.isRunning) { + return ( + phase: LiveActivityPhase.cooldown, + title: 'Cooldown', + detail: 'Manual ping available when the timer ends', + endsAt: _manualPingCooldownTimer.endTime, + ); + } + + if (_autoPingTimer.isRunning) { + if (_autoPingTimer.skipReason != null) { + return ( + phase: LiveActivityPhase.skipped, + title: 'Ping skipped', + detail: 'Move at least ${PingService.currentMinDistance} m', + endsAt: _autoPingTimer.endTime, + ); + } + + if (_autoMode == AutoMode.passive) { + return ( + phase: LiveActivityPhase.waitingDiscovery, + title: 'Next discovery', + detail: null, + endsAt: _autoPingTimer.endTime, + ); + } + + if (_autoMode == AutoMode.targeted) { + return ( + phase: LiveActivityPhase.waitingTrace, + title: 'Next trace', + detail: _targetRepeaterDisplayName, + endsAt: _autoPingTimer.endTime, + ); + } + + return ( + phase: LiveActivityPhase.waiting, + title: 'Next ping', + detail: null, + endsAt: _autoPingTimer.endTime, + ); + } + + switch (_liveActivityOperation) { + case _LiveActivityOperation.sending: + return ( + phase: LiveActivityPhase.sending, + title: 'Sending ping…', + detail: null, + endsAt: null, + ); + case _LiveActivityOperation.discovering: + return ( + phase: LiveActivityPhase.discovering, + title: 'Discovering…', + detail: 'Requesting nearby repeaters', + endsAt: null, + ); + case _LiveActivityOperation.tracing: + return ( + phase: LiveActivityPhase.tracing, + title: 'Tracing repeater…', + detail: _targetRepeaterDisplayName, + endsAt: null, + ); + case null: + break; + } + + if (_autoPingStarting || !_autoPingEnabled) { + return ( + phase: LiveActivityPhase.starting, + title: 'Preparing session…', + detail: null, + endsAt: null, + ); + } + + return ( + phase: LiveActivityPhase.active, + title: '${_liveActivityModeTitle} active', + detail: 'Waiting for the next cycle', + endsAt: null, + ); + } + + ({ + List repeaters, + int totalCount, + bool isCurrent, + }) _buildLiveActivityRepeaters() { + final cycleStartedAt = _liveActivityCycleStartedAt; + final topIsCurrent = cycleStartedAt != null && + _liveActivityRepeatersUpdatedAt != null && + !_liveActivityRepeatersUpdatedAt!.isBefore(cycleStartedAt); + final rxIsCurrent = cycleStartedAt != null && + _liveActivityRxUpdatedAt != null && + !_liveActivityRxUpdatedAt!.isBefore(cycleStartedAt); + final hasCurrent = topIsCurrent || rxIsCurrent; + + final includeTop = !hasCurrent || topIsCurrent; + final includeRx = !hasCurrent || rxIsCurrent; + final repeatersById = {}; + + if (includeTop) { + for (final repeater in _liveActivityRepeaters) { + if (!repeater.snr.isFinite) continue; + final id = repeater.repeaterId.toUpperCase(); + repeatersById[id] = LiveActivityRepeater( + id: id, + name: _resolveRepeaterDisplayName(id), + snr: repeater.snr, + ); + } + } + + final rx = _rxOverlaySlot; + if (includeRx && rx != null && rx.snr.isFinite) { + final id = rx.repeaterId.toUpperCase(); + final existing = repeatersById[id]; + if (existing == null || rx.snr > existing.snr) { + repeatersById[id] = LiveActivityRepeater( + id: id, + name: _resolveRepeaterDisplayName(id), + snr: rx.snr, + ); + } + } + + final repeaters = repeatersById.values.toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + + var totalCount = includeTop ? _liveActivityRepeaterTotalCount : 0; + if (includeRx && + rx != null && + rx.snr.isFinite && + !_liveActivityRepeaters.any( + (entry) => + entry.repeaterId.toUpperCase() == rx.repeaterId.toUpperCase(), + )) { + totalCount++; + } + if (totalCount < repeaters.length) totalCount = repeaters.length; + + return ( + repeaters: repeaters.take(3).toList(growable: false), + totalCount: totalCount, + isCurrent: hasCurrent, + ); + } + + String get _liveActivityModeTitle { + if (_liveActivityManualSession) return 'Manual'; + return switch (_autoMode) { + AutoMode.active => 'Active', + AutoMode.passive => 'Passive', + AutoMode.hybrid => 'Hybrid', + AutoMode.targeted => 'Trace', + }; + } + + String get _liveActivityGpsLabel => switch (_gpsStatus) { + GpsStatus.permissionDenied => 'Location permission required', + GpsStatus.disabled => 'Location services disabled', + GpsStatus.searching => 'Searching for GPS signal', + GpsStatus.locked => 'GPS locked', + GpsStatus.outsideGeofence => 'Outside service area', + }; + + String? get _targetRepeaterDisplayName { + final id = _targetRepeaterId; + if (id == null || id.isEmpty) return null; + return _resolveRepeaterDisplayName(id) ?? id.toUpperCase(); + } + + String? _resolveRepeaterDisplayName(String rawId) { + final id = rawId.toUpperCase(); + final exactMatches = _repeaters.where((repeater) { + return repeater.id.toUpperCase() == id || + repeater.hexId.toUpperCase() == id || + repeater + .displayHexId(overrideHopBytes: _hopBytes) + .toUpperCase() == + id; + }).toList(growable: false); + + if (exactMatches.length == 1) { + final name = exactMatches.single.name; + return name == 'Unknown' ? null : name; + } + if (exactMatches.isNotEmpty || id.length < 2) return null; + + final prefixMatches = _repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + return hexId.startsWith(id) || id.startsWith(hexId); + }).toList(growable: false); + if (prefixMatches.length != 1) return null; + + final name = prefixMatches.single.name; + return name == 'Unknown' ? null : name; + } + // ============================================ // Initialization // ============================================ @@ -1130,6 +1590,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer, _discoveryWindowTimer, ]); + if (_liveActivityService.isSupportedPlatform) { + _timerListenable.addListener(_handleLiveActivityTimerChange); + } // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -2513,6 +2976,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { () => handleSessionError('session_limit', null); _pingService!.onTxPing = (ping) { + _markLiveActivityOperation(_LiveActivityOperation.sending); _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -2614,13 +3078,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[APP] Updated TxLogEntry with ${existingEvents.length} direct, ' '${lastEntry.multiHopEvents.length} multi-hop events (real-time)'); - _updateTopRepeaters( - existingEvents - .where((e) => e.snr != null) - .map((e) => - (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), - OverlayPingType.tx); + final directRepeaters = existingEvents + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )) + .toList(growable: false); + _updateTopRepeaters(directRepeaters, OverlayPingType.tx); + _updateLiveActivityRepeaters([ + ...directRepeaters, + ...lastEntry.multiHopEvents + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )), + ]); debugLog('[APP] Calling notifyListeners() to update UI'); _notifyMapThrottled(); @@ -2675,6 +3149,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { multiHopEvents: multiHopEvents, ); + _updateLiveActivityRepeaters([ + ...lastEntry.events + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )), + ...multiHopEvents + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )), + ]); + _notifyMapThrottled(); } } @@ -2683,6 +3172,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.onPingProgressChanged = notifyListeners; _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { + _liveActivityOperation = null; _autoPingTimer.startWithSkipReason(intervalMs, skipReason); if (skipReason != null) { @@ -2700,6 +3190,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; _pingService!.onDiscPing = (entry) { + _markLiveActivityOperation(_LiveActivityOperation.discovering); _addDiscLogEntry(entry); }; @@ -2710,20 +3201,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _audioService.playReceiveSound(); } - _updateTopRepeaters( - discPing.discoveredNodes - .map((n) => - (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), - OverlayPingType.disc); + final heardRepeaters = discPing.discoveredNodes + .map((node) => ( + repeaterId: node.repeaterId.toUpperCase(), + snr: node.localSnr, + )) + .toList(growable: false); + _updateTopRepeaters(heardRepeaters, OverlayPingType.disc); + _updateLiveActivityRepeaters(heardRepeaters); _notifyMapThrottled(); }; _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { + _liveActivityOperation = null; double? lat; double? lon; List? allRepeaters; + final heardRepeaters = <({String repeaterId, double snr})>[]; if (_txLogEntries.isNotEmpty) { final lastTx = _txLogEntries.last; @@ -2750,7 +3245,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (directRepeaters.isNotEmpty || multiHopRepeaters.isNotEmpty) { allRepeaters = [...directRepeaters, ...multiHopRepeaters]; } - } + + heardRepeaters.addAll(lastTx.events + .where((event) => event.snr?.isFinite ?? false) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + ))); + heardRepeaters.addAll(multiHopEchoes + .where((event) => event.snr?.isFinite ?? false) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + ))); + } + _updateLiveActivityRepeaters(heardRepeaters); final PingEventType eventType; if (directSuccess) { @@ -2770,9 +3279,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; _pingService!.onDiscoveryWindowComplete = (success) { + _liveActivityOperation = null; double? lat; double? lon; List? repeaters; + final heardRepeaters = <({String repeaterId, double snr})>[]; if (_discLogEntries.isNotEmpty) { final lastDisc = _discLogEntries.first; @@ -2788,7 +3299,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )) .toList(); } + heardRepeaters.addAll(lastDisc.discoveredNodes + .where((node) => node.localSnr.isFinite) + .map((node) => ( + repeaterId: node.repeaterId.toUpperCase(), + snr: node.localSnr, + ))); } + _updateLiveActivityRepeaters(heardRepeaters); PingEventType eventType; if (success) { @@ -2808,10 +3326,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; _pingService!.onTracePing = (entry) { + _markLiveActivityOperation(_LiveActivityOperation.tracing); _addTraceLogEntry(entry); }; _pingService!.onTraceWindowComplete = (result) { + _liveActivityOperation = null; double? lat; double? lon; List? repeaters; @@ -2843,6 +3363,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + final traceSnr = result?.localSnr; + _updateLiveActivityRepeaters( + result != null && result.success && traceSnr != null + ? [(repeaterId: result.targetRepeaterId, snr: traceSnr)] + : const [], + ); + recordPingEvent( result != null && result.success ? PingEventType.traceSuccess @@ -2881,6 +3408,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingEnabled = false; _idleAutoStopReference = null; + _finishLiveActivitySession(); debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -3433,6 +3961,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } Future _fullDisconnectCleanup() async { + _finishLiveActivitySession(); // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) if (_connectionStep == ConnectionStep.disconnected) { debugLog('[CONN] Already disconnected, skipping duplicate cleanup'); @@ -3798,6 +4327,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Disconnect from current device Future disconnect() async { + _finishLiveActivitySession(); // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; @@ -4040,6 +4570,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isPingSending = true; notifyListeners(); + var ownsLiveActivity = false; + var keepLiveActivity = false; try { // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { @@ -4051,8 +4583,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _startIdleDisconnectTimer(); debugLog('[PING] Sending manual TX ping'); - return await _pingService!.sendTxPing(manual: true); + ownsLiveActivity = !_liveActivitySessionActive; + if (ownsLiveActivity) { + _startLiveActivitySession(manual: true); + } + final sent = await _pingService!.sendTxPing(manual: true); + keepLiveActivity = sent; + return sent; } finally { + if (ownsLiveActivity && !keepLiveActivity) { + _finishLiveActivitySession(); + } // Clear sending state on every path: session-check failure, exception, // or success (RX window timer takes over showing the listening state) _isPingSending = false; @@ -4153,6 +4694,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingEnabled = false; _idleAutoStopReference = null; + _finishLiveActivitySession(); // Clear top-heard overlay on stop _clearOverlayState(); @@ -4245,6 +4787,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogger?.startWardriving(); _autoPingEnabled = true; _idleAutoStopReference = DateTime.now(); + _startLiveActivitySession(); // Start noise floor session for graph tracking final sessionLabel = isPassive @@ -4773,6 +5316,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; _idleAutoStopReference = null; + _finishLiveActivitySession(); debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } @@ -6143,6 +6687,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Cancel all zone grace period timers. void _cancelZoneGraceTimers() { + _zoneGraceEndsAt = null; _zoneGraceTimer?.cancel(); _zoneGraceTimer = null; _zoneGracePollingTimer?.cancel(); @@ -6198,7 +6743,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Keep alive: BLE, _meshCoreConnection, _pingService, _unifiedRxHandler, // noise floor, and API session (backend auto-transfers on zone re-entry) - // Start 5-minute countdown + // Start 5-minute countdown. Keep an absolute deadline so ActivityKit can + // render the timer without receiving an update every second. + _zoneGraceEndsAt = DateTime.now().add(_zoneGraceTimeout); _zoneGraceSecondsRemaining = _zoneGraceTimeout.inSeconds; // Overall timeout — abandon grace period after 5 minutes @@ -7799,12 +8346,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { @override @override void notifyListeners() { - if (!_isDisposed) super.notifyListeners(); + if (_isDisposed) return; + super.notifyListeners(); + _scheduleLiveActivitySync(); } @override void dispose() { _isDisposed = true; + _timerListenable.removeListener(_handleLiveActivityTimerChange); + _liveActivityService.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); _connectionSubscription?.cancel(); diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index c7e61def..ccb6d580 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -14,6 +14,9 @@ import '../utils/debug_logger_io.dart'; class CountdownTimerService extends ChangeNotifier { Timer? _timer; DateTime? _endTime; + + /// Absolute deadline used by system surfaces such as iOS Live Activities. + DateTime? get endTime => _endTime; int? _durationMs; /// Check if timer is running diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart new file mode 100644 index 00000000..ec93f641 --- /dev/null +++ b/lib/services/live_activity/live_activity_models.dart @@ -0,0 +1,136 @@ +/// High-level phase shown by the iOS Live Activity. +enum LiveActivityPhase { + active, + starting, + sending, + discovering, + tracing, + listening, + listeningDiscovery, + listeningTrace, + waiting, + waitingDiscovery, + waitingTrace, + cooldown, + skipped, + stopping, + waitingForGps, + pausedOutsideZone, + disconnected, + txBlocked, +} + +extension LiveActivityPhaseWireValue on LiveActivityPhase { + String get wireValue => switch (this) { + LiveActivityPhase.active => 'active', + LiveActivityPhase.starting => 'starting', + LiveActivityPhase.sending => 'sending', + LiveActivityPhase.discovering => 'discovering', + LiveActivityPhase.tracing => 'tracing', + LiveActivityPhase.listening => 'listening', + LiveActivityPhase.listeningDiscovery => 'listening_discovery', + LiveActivityPhase.listeningTrace => 'listening_trace', + LiveActivityPhase.waiting => 'waiting', + LiveActivityPhase.waitingDiscovery => 'waiting_discovery', + LiveActivityPhase.waitingTrace => 'waiting_trace', + LiveActivityPhase.cooldown => 'cooldown', + LiveActivityPhase.skipped => 'skipped', + LiveActivityPhase.stopping => 'stopping', + LiveActivityPhase.waitingForGps => 'waiting_for_gps', + LiveActivityPhase.pausedOutsideZone => 'paused_outside_zone', + LiveActivityPhase.disconnected => 'disconnected', + LiveActivityPhase.txBlocked => 'tx_blocked', + }; +} + +/// Compact repeater observation included in a Live Activity update. +class LiveActivityRepeater { + const LiveActivityRepeater({ + required this.id, + required this.snr, + this.name, + }); + + final String id; + final String? name; + final double snr; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr.isFinite ? snr : 0.0, + }; +} + +/// Complete, serializable snapshot rendered by ActivityKit. +class LiveActivitySnapshot { + const LiveActivitySnapshot({ + required this.sessionId, + required this.mode, + required this.phase, + required this.phaseTitle, + required this.isConnected, + required this.txCount, + required this.rxCount, + required this.discoveryCount, + required this.traceCount, + required this.queueSize, + required this.repeaters, + required this.totalHeardCount, + required this.repeatersAreCurrent, + required this.updatedAt, + this.phaseDetail, + this.phaseEndsAt, + this.zoneCode, + }); + + final String sessionId; + final String mode; + final LiveActivityPhase phase; + final String phaseTitle; + final String? phaseDetail; + final DateTime? phaseEndsAt; + final bool isConnected; + final String? zoneCode; + final int txCount; + final int rxCount; + final int discoveryCount; + final int traceCount; + final int queueSize; + final List repeaters; + final int totalHeardCount; + final bool repeatersAreCurrent; + final DateTime updatedAt; + + Map toMap() => { + 'sessionId': sessionId, + 'mode': mode, + 'phase': phase.wireValue, + 'phaseTitle': phaseTitle, + 'phaseDetail': phaseDetail, + 'phaseEndsAt': phaseEndsAt?.millisecondsSinceEpoch, + 'isConnected': isConnected, + 'zoneCode': zoneCode, + 'txCount': txCount, + 'rxCount': rxCount, + 'discoveryCount': discoveryCount, + 'traceCount': traceCount, + 'queueSize': queueSize, + 'repeaters': repeaters.map((repeater) => repeater.toMap()).toList(), + 'totalHeardCount': totalHeardCount, + 'repeatersAreCurrent': repeatersAreCurrent, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }; + + /// Fields that must bypass the normal update throttle. + String get urgencyKey => [ + sessionId, + mode, + phase.wireValue, + phaseTitle, + phaseDetail ?? '', + phaseEndsAt?.millisecondsSinceEpoch ?? 0, + isConnected, + zoneCode ?? '', + ].join('|'); +} diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart new file mode 100644 index 00000000..bf5cb278 --- /dev/null +++ b/lib/services/live_activity/live_activity_service.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import 'live_activity_models.dart'; + +typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function(); + +/// Owns the Flutter-to-ActivityKit bridge and coalesces noisy app-state changes. +/// +/// Timer ticks are represented by absolute phase deadlines, allowing SwiftUI to +/// render the countdown locally without an ActivityKit update every second. +class LiveActivityService { + static const MethodChannel _channel = + MethodChannel('meshmapper/live_activity'); + static const Duration _debounceDelay = Duration(milliseconds: 200); + static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + + Timer? _scheduledUpdate; + LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; + String? _lastPayload; + String? _lastUrgencyKey; + DateTime? _lastSentAt; + String? _unavailableSessionId; + bool _disposed = false; + bool _didReconcileNativeState = false; + Future _operationChain = Future.value(); + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + void schedule( + LiveActivitySnapshotBuilder snapshotBuilder, { + bool immediate = false, + }) { + if (_disposed || !isSupportedPlatform) return; + + _pendingSnapshotBuilder = snapshotBuilder; + _scheduledUpdate?.cancel(); + + if (immediate) { + _enqueueFlush(); + return; + } + + _scheduledUpdate = Timer(_debounceDelay, _enqueueFlush); + } + + void _enqueueFlush() { + _operationChain = _operationChain.then((_) => _flush()).catchError( + (Object error) { + debugError('[LIVE ACTIVITY] Update queue failed: $error'); + }, + ); + } + + Future _flush() async { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + + if (_disposed || !isSupportedPlatform) return; + + final snapshot = _pendingSnapshotBuilder?.call(); + if (snapshot == null) { + if (_lastPayload == null && _didReconcileNativeState) return; + await _endCurrentActivity(immediate: _lastPayload == null); + return; + } + + if (_unavailableSessionId == snapshot.sessionId) return; + if (_unavailableSessionId != null) { + _unavailableSessionId = null; + } + + final payload = snapshot.toMap(); + // updatedAt is metadata for ActivityKit's stale date, not a visible state + // change. Excluding it from the fingerprint prevents 500 ms countdown + // timer ticks from causing native updates; SwiftUI renders countdowns from + // the absolute phaseEndsAt deadline instead. + final fingerprintPayload = Map.from(payload) + ..remove('updatedAt'); + final encoded = jsonEncode(fingerprintPayload); + if (encoded == _lastPayload) return; + + final urgent = snapshot.urgencyKey != _lastUrgencyKey; + final lastSentAt = _lastSentAt; + if (!urgent && lastSentAt != null) { + final elapsed = DateTime.now().difference(lastSentAt); + if (elapsed < _minimumNonUrgentInterval) { + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return; + } + } + + try { + final result = await _channel.invokeMethod('sync', payload); + _didReconcileNativeState = true; + if (result == false) { + _unavailableSessionId = snapshot.sessionId; + debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); + return; + } + _lastPayload = encoded; + _lastUrgencyKey = snapshot.urgencyKey; + _lastSentAt = DateTime.now(); + } on MissingPluginException { + // Expected on non-iOS test hosts and older generated iOS projects. + } on PlatformException catch (error) { + debugError( + '[LIVE ACTIVITY] ActivityKit sync failed: ' + '${error.code}: ${error.message}', + ); + } catch (error) { + debugError('[LIVE ACTIVITY] Unexpected sync failure: $error'); + } + } + + Future _endCurrentActivity({required bool immediate}) async { + try { + await _channel.invokeMethod( + 'end', + {'immediate': immediate}, + ); + } on MissingPluginException { + // Expected on non-iOS test hosts. + } on PlatformException catch (error) { + debugError( + '[LIVE ACTIVITY] ActivityKit end failed: ' + '${error.code}: ${error.message}', + ); + } catch (error) { + debugError('[LIVE ACTIVITY] Unexpected end failure: $error'); + } finally { + _didReconcileNativeState = true; + _lastPayload = null; + _lastUrgencyKey = null; + _lastSentAt = null; + _unavailableSessionId = null; + } + } + + void dispose() { + _disposed = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _pendingSnapshotBuilder = null; + } +} diff --git a/test/services/live_activity/live_activity_models_test.dart b/test/services/live_activity/live_activity_models_test.dart new file mode 100644 index 00000000..cb186dec --- /dev/null +++ b/test/services/live_activity/live_activity_models_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; + +void main() { + test('serializes the compact ActivityKit snapshot contract', () { + final updatedAt = DateTime.utc(2026, 7, 14, 18); + final phaseEndsAt = updatedAt.add(const Duration(seconds: 7)); + final snapshot = LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Hybrid', + phase: LiveActivityPhase.listening, + phaseTitle: 'Listening…', + phaseDetail: 'Waiting for repeater echoes', + phaseEndsAt: phaseEndsAt, + isConnected: true, + zoneCode: 'JKG', + txCount: 42, + rxCount: 318, + discoveryCount: 8, + traceCount: 0, + queueSize: 2, + repeaters: const [ + LiveActivityRepeater( + id: 'A6', + name: 'Huskvarna', + snr: 12.4, + ), + ], + totalHeardCount: 3, + repeatersAreCurrent: true, + updatedAt: updatedAt, + ); + + expect(snapshot.toMap(), { + 'sessionId': 'session-1', + 'mode': 'Hybrid', + 'phase': 'listening', + 'phaseTitle': 'Listening…', + 'phaseDetail': 'Waiting for repeater echoes', + 'phaseEndsAt': phaseEndsAt.millisecondsSinceEpoch, + 'isConnected': true, + 'zoneCode': 'JKG', + 'txCount': 42, + 'rxCount': 318, + 'discoveryCount': 8, + 'traceCount': 0, + 'queueSize': 2, + 'repeaters': [ + { + 'id': 'A6', + 'name': 'Huskvarna', + 'snr': 12.4, + }, + ], + 'totalHeardCount': 3, + 'repeatersAreCurrent': true, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }); + }); + + test('urgency key changes for phase deadlines but not counters', () { + final now = DateTime.utc(2026, 7, 14, 18); + + LiveActivitySnapshot make({ + int rxCount = 1, + DateTime? phaseEndsAt, + }) { + return LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Active', + phase: LiveActivityPhase.waiting, + phaseTitle: 'Next ping', + phaseEndsAt: phaseEndsAt ?? now.add(const Duration(seconds: 30)), + isConnected: true, + txCount: 1, + rxCount: rxCount, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: false, + updatedAt: now, + ); + } + + expect(make(rxCount: 1).urgencyKey, make(rxCount: 2).urgencyKey); + expect( + make().urgencyKey, + isNot(make(phaseEndsAt: now.add(const Duration(seconds: 15))).urgencyKey), + ); + }); + + test('normalizes non-finite repeater values before encoding', () { + const repeater = LiveActivityRepeater( + id: 'A6', + snr: double.nan, + ); + + expect(repeater.toMap()['snr'], 0.0); + }); +} From 2708c82e97ae78483ddba8a93dcee01f3eabbf78 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 16:39:06 -0700 Subject: [PATCH 171/360] Fix unnecessary string interpolation braces in Live Activity title The merged Live Activity work introduced the repo's only analyzer issue (unnecessary_brace_in_string_interps). `dev` analyzes clean, so this restores that baseline on the watch-app branch. --- lib/providers/app_state_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 3f884f11..b52b3ede 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1410,7 +1410,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return ( phase: LiveActivityPhase.active, - title: '${_liveActivityModeTitle} active', + title: '$_liveActivityModeTitle active', detail: 'Waiting for the next cycle', endsAt: null, ); From 2547c52fed5be7de7d58c2084cf6c7871d50b6af Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 16:53:06 -0700 Subject: [PATCH 172/360] Add MeshMapperWatch watchOS app target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Apple Watch companion: the target skeleton only. The watch is a mirror-and-remote for a session the iPhone owns, so this ships no session logic — WatchConnectivity, map, node list, and controls follow in later phases. Single-target watchOS app (WKApplication), watchOS 26.0, embedded into Runner via an Embed Watch Content phase so `flutter build ipa` carries it along. Also routes bundle IDs and signing team through MESHMAPPER_BUNDLE_PREFIX and MESHMAPPER_DEVELOPMENT_TEAM, defined once at project level. Both resolve to the previous literals, so nothing changes by default. This exists because a watch app's bundle ID must be prefixed by its companion's: testing on a Personal Team means moving every ID together, which is now one field instead of four targets. Verified: builds for simulator, embeds at Runner.app/Watch/MeshMapperWatch.app with all variables resolved, installs and launches on a paired iPhone 17 Pro / Apple Watch Series 11 simulator pair. --- .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + ios/MeshMapperWatch/ContentView.swift | 23 ++ ios/MeshMapperWatch/Info.plist | 30 ++ ios/MeshMapperWatch/MeshMapperWatchApp.swift | 18 ++ ios/Runner.xcodeproj/project.pbxproj | 273 ++++++++++++++++-- 7 files changed, 351 insertions(+), 32 deletions(-) create mode 100644 ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/MeshMapperWatch/Assets.xcassets/Contents.json create mode 100644 ios/MeshMapperWatch/ContentView.swift create mode 100644 ios/MeshMapperWatch/Info.plist create mode 100644 ios/MeshMapperWatch/MeshMapperWatchApp.swift diff --git a/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..6e000ca8 --- /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 00000000..49c81cd8 --- /dev/null +++ b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MeshMapperWatch/Assets.xcassets/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /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 00000000..b123f1e6 --- /dev/null +++ b/ios/MeshMapperWatch/ContentView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Placeholder root view. +/// +/// Phase 1's only job is proving the target builds, embeds in Runner, and +/// launches on the watch. Phase 3 replaces this with the map. +struct ContentView: View { + var body: some View { + VStack(spacing: 4) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.title2) + Text("MeshMapper") + .font(.headline) + Text("Waiting for iPhone") + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +#Preview { + ContentView() +} diff --git a/ios/MeshMapperWatch/Info.plist b/ios/MeshMapperWatch/Info.plist new file mode 100644 index 00000000..e481d35c --- /dev/null +++ b/ios/MeshMapperWatch/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MeshMapper + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MeshMapper + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + $(MESHMAPPER_BUNDLE_PREFIX) + WKRunsIndependentlyOfCompanionApp + + + diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift new file mode 100644 index 00000000..b2b5959b --- /dev/null +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Entry point for the MeshMapper watchOS companion app. +/// +/// The watch is a mirror-and-remote for a session the iPhone owns: the phone +/// holds the BLE link to the MeshCore device and the GPS fix, and pushes +/// snapshots over WatchConnectivity. Nothing here drives a session on its own. +/// +/// Phase 1 ships the target skeleton only — the WatchConnectivity client, map, +/// node list, and controls arrive in later phases. +@main +struct MeshMapperWatchApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bcf84746..5b0d0140 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,21 +8,26 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */; }; 73F9D344DD4B7EC7AC29CD86 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; A10000000000000000000001 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LiveActivityManager.swift */; }; A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; }; A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; }; A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MeshMapperLiveActivity.swift */; }; A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */; }; A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */; }; + E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */; }; + E83718073D76FB741949ED05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CC8647C002484845F02D0CE /* Assets.xcassets */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -33,6 +38,13 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26F4F6F7B0F55BC605124558; + remoteInfo = MeshMapperWatch; + }; A60000000000000000000001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -43,6 +55,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -67,18 +90,25 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 111B35B32FAB66FA2E78E0BE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 6316620B3FF7A48DF8F886CA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 6CC8647C002484845F02D0CE /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeshMapperWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 86A4D8E4F5F13D005DA717B4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchApp.swift; sourceTree = ""; }; 8BA04B67488852DCDA49C863 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -86,11 +116,6 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B1A260696FEFA53606484258 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - C5DCC2A7546C7F71461B567A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - D478469A7D705340684EFF2B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; A20000000000000000000001 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; A20000000000000000000002 /* MeshMapperActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperActivityAttributes.swift; sourceTree = ""; }; A20000000000000000000003 /* MeshMapperLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivity.swift; sourceTree = ""; }; @@ -98,9 +123,21 @@ A20000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MeshMapperLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; A20000000000000000000007 /* LiveActivity.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = LiveActivity.xcconfig; path = Flutter/LiveActivity.xcconfig; sourceTree = ""; }; + B1A260696FEFA53606484258 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + C5DCC2A7546C7F71461B567A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + D478469A7D705340684EFF2B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 27F55F79605B24B31D20B683 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 507BD8685B0F59BD768C20E3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -133,10 +170,31 @@ children = ( 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */, F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */, + 321ECD9614BDCAFCF545D162 /* watchOS */, ); name = Frameworks; sourceTree = ""; }; + 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */ = { + isa = PBXGroup; + children = ( + 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */, + 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */, + 111B35B32FAB66FA2E78E0BE /* Info.plist */, + 6CC8647C002484845F02D0CE /* Assets.xcassets */, + ); + name = MeshMapperWatch; + path = MeshMapperWatch; + sourceTree = ""; + }; + 321ECD9614BDCAFCF545D162 /* watchOS */ = { + isa = PBXGroup; + children = ( + 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */, + ); + name = watchOS; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -182,6 +240,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 88C2A374596BDB4F1DE4A0B4 /* Pods */, 1E85EC0983B84C8E2376ABCD /* Frameworks */, + 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */, ); sourceTree = ""; }; @@ -191,6 +250,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */, + 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */, ); name = Products; sourceTree = ""; @@ -232,6 +292,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */; + buildPhases = ( + FBC0EF55C50DCA90E416F7D1 /* Sources */, + 27F55F79605B24B31D20B683 /* Frameworks */, + 4039BA5EEB0F88665D8EAFE2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshMapperWatch; + productName = MeshMapperWatch; + productReference = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; + productType = "com.apple.product-type.application"; + }; 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; @@ -252,14 +329,12 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( E42ACB57393E36F474C65ADB /* [CP] Check Pods Manifest.lock */, A40000000000000000000004 /* Embed Foundation Extensions */, + 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -273,8 +348,12 @@ ); dependencies = ( A70000000000000000000001 /* PBXTargetDependency */, + F203972D6336527AC5F4F89D /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -300,9 +379,6 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -331,6 +407,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -338,6 +417,7 @@ 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, A50000000000000000000001 /* MeshMapperLiveActivityExtension */, + 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */, ); }; /* End PBXProject section */ @@ -350,6 +430,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4039BA5EEB0F88665D8EAFE2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E83718073D76FB741949ED05 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -512,6 +600,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBC0EF55C50DCA90E416F7D1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */, + 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -525,6 +622,12 @@ target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */; targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */; }; + F203972D6336527AC5F4F89D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = MeshMapperWatch; + target = 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */; + targetProxy = 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -580,7 +683,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -593,6 +696,8 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app; + MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -615,7 +720,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -633,7 +738,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -652,7 +757,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -669,13 +774,42 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; }; + 705BC20CAD6B880CACD9EA74 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -709,7 +843,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -728,6 +862,8 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app; + MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -769,7 +905,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -782,6 +918,8 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app; + MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -806,7 +944,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -828,7 +966,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -836,6 +974,35 @@ }; name = Release; }; + A64C5101607B9C9371C99AB5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; A80000000000000000000001 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; @@ -844,7 +1011,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.2; @@ -854,14 +1021,14 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -873,7 +1040,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.2; @@ -883,13 +1050,13 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; @@ -901,7 +1068,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.2; @@ -911,16 +1078,46 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - SWIFT_COMPILATION_MODE = wholemodule; }; name = Profile; }; + FBECC62B604ABFF4A75248F1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -964,13 +1161,25 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A64C5101607B9C9371C99AB5 /* Release */, + FBECC62B604ABFF4A75248F1 /* Debug */, + 705BC20CAD6B880CACD9EA74 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; /* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { isa = XCSwiftPackageProductDependency; From a9fff83ef17bc65f7b669dcdcf213dbf17078991 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 17:30:13 -0700 Subject: [PATCH 173/360] Add watch transport: snapshots down, commands up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Apple Watch companion. The wire is now real in both directions; the wrist UI is still a raw debug dump, replaced by the map in Phase 3. Shared contract in ios/Shared/MeshMapperWatchPayload.swift is compiled into both Runner and the watch target so it cannot drift, with the Dart mirror in lib/services/watch/. WatchSnapshot composes LiveActivitySnapshot rather than re-deriving phase and counter semantics, so both surfaces always agree. Three decisions worth keeping: - Countdowns ship as absolute deadlines, never ticks. The watch renders them with Text(timerInterval:), so a session sends about one update per phase transition instead of one per second. - Colours resolve on the phone. Dart owns the colour-vision palettes, so accessibility palettes work on the wrist with no duplicated code. - The watch sends intents, never state. Every guard is re-evaluated in _handleWatchCommand, so a stale payload cannot cause a transmit. Unlike the Live Activity, the watch receives snapshots even with no session running — otherwise the start button could never be reached from the wrist. Two bugs found by testing rather than review: - WatchSessionManager held its FlutterMethodChannel weakly. setMethodCallHandler makes the messenger retain the handler block, not the channel, so a channel left in an AppDelegate local deallocates and every inbound command was silently dropped. The other channels survive as locals because they only receive; this is the first that invokes Dart from native. - The watch pre-checked isReachable before sending. That flag lags reality — the simulator reported unreachable while still delivering messages seconds later — which turned a stale flag into a refused tap. errorHandler is now the source of truth. Verified on paired iPhone 17 Pro / Apple Watch Series 11 simulators: phone state renders on the wrist (phase, counters, GPS fix, disabled controls with reason), and requestSnapshot round-trips watch → Swift → Dart → ack. 138 tests pass, 27 of them new. --- ios/MeshMapperWatch/ContentView.swift | 160 ++++++++- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 8 +- ios/MeshMapperWatch/WatchSessionClient.swift | 159 +++++++++ ios/Runner.xcodeproj/project.pbxproj | 14 + ios/Runner/AppDelegate.swift | 17 + ios/Runner/WatchSessionManager.swift | 225 ++++++++++++ ios/Shared/MeshMapperWatchPayload.swift | 208 ++++++++++++ lib/providers/app_state_provider.dart | 207 ++++++++++- lib/services/watch/watch_bridge_service.dart | 210 ++++++++++++ lib/services/watch/watch_geo_builder.dart | 213 ++++++++++++ lib/services/watch/watch_models.dart | 320 ++++++++++++++++++ .../watch/watch_geo_builder_test.dart | 279 +++++++++++++++ .../watch/watch_wire_contract_test.dart | 244 +++++++++++++ 13 files changed, 2249 insertions(+), 15 deletions(-) create mode 100644 ios/MeshMapperWatch/WatchSessionClient.swift create mode 100644 ios/Runner/WatchSessionManager.swift create mode 100644 ios/Shared/MeshMapperWatchPayload.swift create mode 100644 lib/services/watch/watch_bridge_service.dart create mode 100644 lib/services/watch/watch_geo_builder.dart create mode 100644 lib/services/watch/watch_models.dart create mode 100644 test/services/watch/watch_geo_builder_test.dart create mode 100644 test/services/watch/watch_wire_contract_test.dart diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index b123f1e6..aed783be 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -1,23 +1,159 @@ import SwiftUI -/// Placeholder root view. +/// Phase 2 debug dump. /// -/// Phase 1's only job is proving the target builds, embeds in Runner, and -/// launches on the watch. Phase 3 replaces this with the map. +/// Deliberately ugly: this exists to prove the transport carries real state +/// and that commands round-trip with an ack. Phase 3 replaces it with the map. struct ContentView: View { + @Environment(WatchSessionClient.self) private var client + var body: some View { - VStack(spacing: 4) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.title2) - Text("MeshMapper") - .font(.headline) - Text("Waiting for iPhone") + ScrollView { + VStack(alignment: .leading, spacing: 8) { + header + + if client.versionMismatch { + Text("Update MeshMapper on iPhone — wire version mismatch") + .font(.caption2) + .foregroundStyle(.orange) + } + + if let snapshot = client.snapshot { + session(snapshot) + counters(snapshot) + geo(snapshot) + controls(snapshot) + } else { + Text("No snapshot yet") + .font(.caption2) + .foregroundStyle(.secondary) + } + + commandButtons + + if let refusal = client.lastRefusal { + Text(refusal) + .font(.caption2) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 4) + // The whole dump dims when the phone has gone quiet, so stale data is + // never mistaken for live data. + .opacity(client.isStale ? 0.45 : 1.0) + } + } + + private var header: some View { + HStack { + Circle() + .fill(client.isReachable ? .green : .gray) + .frame(width: 6, height: 6) + Text(client.isReachable ? "Reachable" : "Unreachable") .font(.caption2) + Spacer() + if let receivedAt = client.receivedAt { + Text(receivedAt, style: .relative) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } + + private func session(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(s.phaseTitle) + .font(.headline) + .lineLimit(2) + if let detail = s.phaseDetail { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + HStack(spacing: 4) { + if let color = s.pingColor { + Circle() + .fill(Color(red: color.r, green: color.g, blue: color.b)) + .frame(width: 8, height: 8) + } + Text(s.mode).font(.caption2) + // Absolute deadline rendered locally — no per-second traffic. + if let endsAt = s.phaseEndsAt, endsAt > Date() { + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.caption.monospacedDigit()) + } + } + } + } + + private func counters(_ s: WatchSnapshot) -> some View { + Text("TX \(s.txCount) · RX \(s.rxCount) · DISC \(s.discoveryCount) · Q \(s.queueSize)") + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + } + + private func geo(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + if let you = s.geo.you { + Text(String(format: "%.5f, %.5f", you.lat, you.lon)) + .font(.system(size: 10).monospacedDigit()) + } else { + Text("No GPS fix").font(.system(size: 10)).foregroundStyle(.secondary) + } + Text("pings \(s.geo.pings.count) · rptrs \(s.geo.repeaters.count) · heard \(s.geo.heard.count)") + .font(.system(size: 10).monospacedDigit()) .foregroundStyle(.secondary) + + ForEach(s.geo.heard) { node in + HStack(spacing: 4) { + if let c = node.snrColor { + Circle() + .fill(Color(red: c.r, green: c.g, blue: c.b)) + .frame(width: 5, height: 5) + } + Text(node.name).font(.system(size: 10)).lineLimit(1) + Spacer() + if let snr = node.snr { + Text(String(format: "%.1f", snr)) + .font(.system(size: 10).monospacedDigit()) + } + } + } } } -} -#Preview { - ContentView() + private func controls(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text("start/stop \(s.controls.canStartStop ? "✓" : "✗") · ping \(s.controls.canManualPing ? "✓" : "✗")") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + if let reason = s.controls.blockedReason { + Text(reason).font(.system(size: 10)).foregroundStyle(.secondary) + } + } + } + + private var commandButtons: some View { + VStack(spacing: 4) { + Button(sessionActive ? "Stop" : "Start") { + client.send(sessionActive ? .stopSession : .startSession) + } + .disabled(!(client.snapshot?.controls.canStartStop ?? false)) + + Button("Manual ping") { + client.send(.manualPing) + } + .disabled(!(client.snapshot?.controls.canManualPing ?? false)) + + Button("Refresh") { + client.send(.requestSnapshot) + } + } + .font(.caption2) + .buttonStyle(.bordered) + } + + private var sessionActive: Bool { + client.snapshot?.controls.isSessionActive ?? false + } } diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index b2b5959b..9fbce36e 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -6,13 +6,17 @@ import SwiftUI /// holds the BLE link to the MeshCore device and the GPS fix, and pushes /// snapshots over WatchConnectivity. Nothing here drives a session on its own. /// -/// Phase 1 ships the target skeleton only — the WatchConnectivity client, map, -/// node list, and controls arrive in later phases. +/// Phase 2 ships the transport and a raw debug dump; the map, node list, and +/// real controls arrive in later phases. @main struct MeshMapperWatchApp: App { + @State private var client = WatchSessionClient() + var body: some Scene { WindowGroup { ContentView() + .environment(client) + .onAppear { client.refresh() } } } } diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift new file mode 100644 index 00000000..82a7a9f1 --- /dev/null +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -0,0 +1,159 @@ +import Foundation +import SwiftUI +import WatchConnectivity + +/// Receives snapshots from the iPhone and sends intents back. +/// +/// The watch never decides anything: it renders what the phone sent and asks +/// for what the wearer tapped. The phone owns the BLE link, the GPS fix, and +/// every guard around transmitting. +@Observable +final class WatchSessionClient: NSObject { + /// Latest state from the phone, or nil before the first delivery. + private(set) var snapshot: WatchSnapshot? + + /// When the last snapshot arrived — drives the stale badge. + private(set) var receivedAt: Date? + + /// Set when the phone refuses a command, so the wrist can say why. + private(set) var lastRefusal: String? + + /// Set when a payload arrives from a wire version this build predates. + private(set) var versionMismatch = false + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + var isReachable: Bool { session?.isReachable ?? false } + + /// A snapshot older than this is shown greyed with an age badge. The phone + /// only sends on real change, so silence is normal — this threshold is + /// about "the phone has probably gone away", not "no update recently". + static let staleAfter: TimeInterval = 90 + + var isStale: Bool { + guard let receivedAt else { return true } + return Date().timeIntervalSince(receivedAt) > Self.staleAfter + } + + /// Bring the session up and pull a current snapshot. + /// + /// Activation is asynchronous, so a refresh requested before it completes is + /// deferred to the activation callback rather than failing as "unreachable". + func refresh() { + guard let session else { return } + session.delegate = self + + if session.activationState == .activated { + ingest(context: session.receivedApplicationContext) + send(.requestSnapshot, silent: true) + return + } + + pendingRefresh = true + session.activate() + } + + private var pendingRefresh = false + + // MARK: - Commands + + /// - Parameter silent: suppress the refusal banner. Used for the automatic + /// refresh, which the wearer never asked for and shouldn't see fail. + /// Sends without pre-checking `isReachable`. + /// + /// That flag lags reality — during testing the simulator reported + /// unreachable while messages were still being delivered a second or two + /// later. Gating on it turns a stale flag into a refused tap, so the send is + /// attempted unconditionally and `errorHandler` is the source of truth. + func send(_ kind: WatchCommand.Kind, silent: Bool = false) { + guard let session, session.activationState == .activated else { + if !silent { lastRefusal = "Not connected to iPhone" } + return + } + + let command = WatchCommand(kind: kind, id: UUID().uuidString) + guard let data = try? MeshMapperWatchWire.encoder.encode(command), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + if !silent { lastRefusal = "Could not encode command" } + return + } + + session.sendMessage( + [MeshMapperWatchWire.commandKey: dict], + replyHandler: { [weak self] reply in + NSLog("[WATCH] reply for \(kind.rawValue): \(reply)") + Task { @MainActor in + let accepted = reply["accepted"] as? Bool ?? false + if accepted { + self?.lastRefusal = nil + } else if !silent { + self?.lastRefusal = reply["reason"] as? String ?? "Refused" + } + } + }, + errorHandler: { [weak self] error in + NSLog("[WATCH] sendMessage(\(kind.rawValue)) failed: \(error.localizedDescription)") + Task { @MainActor in + if !silent { self?.lastRefusal = error.localizedDescription } + } + } + ) + } + + // MARK: - Ingest + + private func ingest(context: [String: Any]) { + guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return } + ingest(data: data) + } + + private func ingest(data: Data) { + guard let decoded = try? MeshMapperWatchWire.decoder.decode(WatchSnapshot.self, from: data) + else { + return + } + + // Refuse rather than render a payload whose fields may have changed + // meaning — a wrong reading on the wrist is worse than a blank one. + guard decoded.isSupportedVersion else { + Task { @MainActor in self.versionMismatch = true } + return + } + + Task { @MainActor in + self.versionMismatch = false + self.snapshot = decoded + self.receivedAt = Date() + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionClient: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if activationState == .activated { + ingest(context: session.receivedApplicationContext) + if pendingRefresh { + pendingRefresh = false + send(.requestSnapshot, silent: true) + } + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + ingest(context: applicationContext) + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + guard let data = message[MeshMapperWatchWire.payloadKey] as? Data else { return } + ingest(data: data) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5b0d0140..e0ec83f3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */; }; 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */; }; 73F9D344DD4B7EC7AC29CD86 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -25,9 +26,12 @@ A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MeshMapperLiveActivity.swift */; }; A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */; }; A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178C77B846A00943CD881203 /* WatchSessionClient.swift */; }; + B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; }; CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */; }; E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */; }; E83718073D76FB741949ED05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CC8647C002484845F02D0CE /* Assets.xcassets */; }; + F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -93,11 +97,14 @@ 111B35B32FAB66FA2E78E0BE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 178C77B846A00943CD881203 /* WatchSessionClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionClient.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; }; 6316620B3FF7A48DF8F886CA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchPayload.swift; sourceTree = ""; }; 6CC8647C002484845F02D0CE /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeshMapperWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; @@ -182,6 +189,7 @@ 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */, 111B35B32FAB66FA2E78E0BE /* Info.plist */, 6CC8647C002484845F02D0CE /* Assets.xcassets */, + 178C77B846A00943CD881203 /* WatchSessionClient.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -267,6 +275,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, A20000000000000000000001 /* LiveActivityManager.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */, ); path = Runner; sourceTree = ""; @@ -275,6 +284,7 @@ isa = PBXGroup; children = ( A20000000000000000000002 /* MeshMapperActivityAttributes.swift */, + 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */, ); path = Shared; sourceTree = ""; @@ -587,6 +597,8 @@ A10000000000000000000001 /* LiveActivityManager.swift in Sources */, A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */, + 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -606,6 +618,8 @@ files = ( E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */, 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */, + B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */, + A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index f12d4c36..3f7e7a16 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -68,6 +68,7 @@ class IOSMapOfflineBridge { @objc class AppDelegate: FlutterAppDelegate { private let mapOfflineBridge = IOSMapOfflineBridge() private let liveActivityManager = LiveActivityManager() + private let watchSessionManager = WatchSessionManager() override func application( _ application: UIApplication, @@ -124,6 +125,22 @@ class IOSMapOfflineBridge { self.liveActivityManager.handle(call, result: result) } + // Method channel: watchOS companion. Dart pushes WatchSnapshots down + // and the watch sends start/stop/manual-ping intents back up the same + // channel. Unlike the Live Activity, this needs no entitlement. + let watchChannel = FlutterMethodChannel( + name: "meshmapper/watch", + binaryMessenger: controller.binaryMessenger + ) + watchChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + self.watchSessionManager.handle(call, result: result) + } + watchSessionManager.attach(channel: watchChannel) + // Method channel: MapLibre tile cache management. Mirrors the Android // handler in MainActivity.kt. Dart's TileCacheService calls into these // from the Offline Maps screen's Tile Cache card. diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift new file mode 100644 index 00000000..f50cf5e8 --- /dev/null +++ b/ios/Runner/WatchSessionManager.swift @@ -0,0 +1,225 @@ +import Flutter +import Foundation +import WatchConnectivity + +/// Bridges Flutter app state to the watchOS companion and relays commands back. +/// +/// Two delivery paths, chosen by urgency: +/// +/// - `updateApplicationContext` for the steady state. It coalesces (latest +/// wins) and is delivered even when the watch app is backgrounded or not +/// running, which is what makes the wrist-down case work. +/// - `sendMessage` for phase transitions and ping results, which need to land +/// now. It requires a reachable counterpart, so it always falls back to the +/// application context rather than being the only path. +/// +/// WatchConnectivity needs no entitlement and no capability registration — +/// this whole file works without developer-portal access. +final class WatchSessionManager: NSObject { + private enum BridgeError: LocalizedError { + case invalidArguments + case unsupported + + var errorDescription: String? { + switch self { + case .invalidArguments: return "The watch payload was invalid." + case .unsupported: return "WatchConnectivity is unavailable on this device." + } + } + } + + /// Set by AppDelegate so inbound commands can reach Dart. + /// + /// Held strongly on purpose. `setMethodCallHandler` makes the binary + /// messenger retain the handler *block*, not the channel object, so a + /// channel left in a local goes away when `didFinishLaunchingWithOptions` + /// returns. The other channels in AppDelegate survive that because they + /// only ever receive calls; this one has to invoke Dart from native, so it + /// needs an owner. AppDelegate's handler block captures `self` weakly, so + /// this does not form a cycle. + private var channel: FlutterMethodChannel? + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + /// Last context we successfully handed to WatchConnectivity, so a repeated + /// identical payload doesn't churn the radio. + private var lastContextData: Data? + + func attach(channel: FlutterMethodChannel) { + self.channel = channel + activateIfNeeded() + } + + private func activateIfNeeded() { + guard let session else { return } + if session.activationState != .activated { + session.delegate = self + session.activate() + } + } + + // MARK: - Flutter → watch + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "sync": + guard let args = call.arguments as? [String: Any], + let payload = args["payload"] as? [String: Any] + else { + result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments")) + return + } + let urgent = args["urgent"] as? Bool ?? false + result(send(payload: payload, urgent: urgent)) + + case "clear": + lastContextData = nil + result(nil) + + case "status": + result(statusDictionary()) + + default: + result(FlutterMethodNotImplemented) + } + } + + /// Returns false when there is nowhere to deliver to — no paired watch, no + /// installed app, or no WatchConnectivity at all. Dart treats that as + /// "don't bother", not as an error. + private func send(payload: [String: Any], urgent: Bool) -> Bool { + guard let session, session.activationState == .activated else { + activateIfNeeded() + return false + } + guard session.isPaired, session.isWatchAppInstalled else { return false } + + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { + return false + } + + // Urgent updates try the immediate path first, but always fall through to + // the application context so a missed message can't strand the watch on + // stale state. + if urgent, session.isReachable { + session.sendMessage( + [MeshMapperWatchWire.payloadKey: data], + replyHandler: nil, + errorHandler: { [weak self] error in + NSLog("[WATCH] sendMessage failed, context still pending: \(error.localizedDescription)") + _ = self + } + ) + } + + guard data != lastContextData else { return true } + + do { + try session.updateApplicationContext([MeshMapperWatchWire.payloadKey: data]) + lastContextData = data + return true + } catch { + NSLog("[WATCH] updateApplicationContext failed: \(error.localizedDescription)") + return false + } + } + + private func statusDictionary() -> [String: Any] { + guard let session else { + return ["supported": false, "paired": false, "installed": false, "reachable": false] + } + return [ + "supported": true, + "paired": session.isPaired, + "installed": session.isWatchAppInstalled, + "reachable": session.isReachable, + "activated": session.activationState == .activated, + ] + } + + private func flutterError(_ error: Error, code: String) -> FlutterError { + FlutterError( + code: code, + message: error.localizedDescription, + details: String(describing: error) + ) + } + + // MARK: - watch → Flutter + + /// Relays a command to Dart and returns the ack. Dart owns the decision; + /// this side never evaluates whether a transmit is legal. + private func relayCommand(_ payload: [String: Any], reply: @escaping ([String: Any]) -> Void) { + guard let channel else { + NSLog("[WATCH] Command dropped: no method channel") + reply(["accepted": false, "reason": "App not ready"]) + return + } + + let kind = payload["kind"] as? String ?? "unknown" + NSLog("[WATCH] Command received: \(kind)") + + // Method channel calls must happen on the main thread; WatchConnectivity + // delivers on a background queue. + DispatchQueue.main.async { + channel.invokeMethod("command", arguments: payload) { response in + if let dict = response as? [String: Any] { + NSLog("[WATCH] Command \(kind) acked: accepted=\(dict["accepted"] ?? "?")") + reply(dict) + } else { + NSLog("[WATCH] Command \(kind) got no response from Dart") + reply(["accepted": false, "reason": "No response"]) + } + } + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionManager: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error { + NSLog("[WATCH] Activation failed: \(error.localizedDescription)") + } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + /// Reactivate after a watch switch, otherwise the session stays dead. + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func sessionWatchStateDidChange(_ session: WCSession) { + // A newly installed or newly paired watch has no context yet. + lastContextData = nil + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + guard let command = message[MeshMapperWatchWire.commandKey] as? [String: Any] else { + NSLog("[WATCH] Malformed command payload: \(Array(message.keys))") + replyHandler(["accepted": false, "reason": "Malformed command"]) + return + } + relayCommand(command, reply: replyHandler) + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + guard let command = message[MeshMapperWatchWire.commandKey] as? [String: Any] else { + NSLog("[WATCH] Malformed command payload: \(Array(message.keys))") + return + } + relayCommand(command) { _ in } + } +} diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift new file mode 100644 index 00000000..850b3cfb --- /dev/null +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -0,0 +1,208 @@ +import Foundation + +/// Wire contract between the iPhone app and the watchOS companion. +/// +/// Shared source: this file is compiled into both Runner and MeshMapperWatch, +/// so the two can never drift. Dart builds the equivalent JSON in +/// `lib/services/watch/watch_models.dart`; the golden fixtures in +/// `test/services/watch/` are what keep Dart and Swift honest with each other. +/// +/// Design notes that matter for battery and correctness: +/// +/// - **Countdowns are absolute deadlines** (`phaseEndsAt`), never tick counts. +/// The watch renders them with `Text(timerInterval:)`, so an active session +/// sends roughly one update per phase transition rather than one per second. +/// - **Colours are resolved on the phone.** Dart owns the colour-vision +/// palettes, so the watch receives sRGB components and stays dumb. This is +/// why accessibility palettes work on the wrist for free. +/// - **Everything is capped.** WatchConnectivity payloads should stay small; +/// the wrist is a glance surface and the phone is where the full history is. +enum MeshMapperWatchWire { + /// Bump when a field changes meaning or is removed. The receiver refuses + /// payloads it doesn't understand rather than rendering something wrong. + static let version = 1 + + /// Caps, mirrored in Dart. Enforced on send *and* validated on receive. + static let maxPings = 60 + static let maxRepeaters = 20 + static let maxHeard = 7 +} + +// MARK: - Colour + +/// An sRGB colour resolved by Dart from the active colour-vision palette. +struct WatchColor: Codable, Hashable { + let r: Double + let g: Double + let b: Double +} + +// MARK: - Geo + +struct WatchPosition: Codable, Hashable { + let lat: Double + let lon: Double + /// Degrees clockwise from true north; nil when the fix has no course. + let headingDeg: Double? + let accuracyM: Double? + /// Milliseconds since epoch, so the watch can age the fix itself. + let fixedAtMs: Double +} + +/// A ping marker, already coloured by outcome. +struct WatchPing: Codable, Hashable, Identifiable { + let id: String + let lat: Double + let lon: Double + /// "tx" | "rx" | "disc" | "trace" — for glyph choice, not colour. + let kind: String + let color: WatchColor + let atMs: Double +} + +/// A repeater pin. `heardThisCycle` drives the highlight ring. +struct WatchRepeater: Codable, Hashable, Identifiable { + let id: String + let name: String + let lat: Double + let lon: Double + let color: WatchColor + let heardThisCycle: Bool +} + +/// A row in the "recently responded" panel. +struct WatchHeardNode: Codable, Hashable, Identifiable { + let id: String + let name: String + let snr: Double? + let rssi: Int? + /// nil = direct echo; otherwise the number of hops. + let hops: Int? + let seenCount: Int + let atMs: Double + let distanceM: Double? + /// SNR traffic-light colour, resolved by Dart. + let snrColor: WatchColor? +} + +struct WatchGeo: Codable, Hashable { + let you: WatchPosition? + let pings: [WatchPing] + let repeaters: [WatchRepeater] + let heard: [WatchHeardNode] + /// Repeater IDs the last ping reached, for the optional map lines. + let linkedRepeaterIds: [String] +} + +// MARK: - Controls + +/// What the wrist is allowed to do right now. +/// +/// These drive button enablement only. The phone revalidates every command +/// against the same guards the in-app buttons use, so a stale payload can +/// never talk the phone into an illegal transmit. +struct WatchControls: Codable, Hashable { + let canStartStop: Bool + let canManualPing: Bool + let isSessionActive: Bool + /// Absolute deadline for the 15 s manual cooldown, if one is running. + let manualCooldownEndsAtMs: Double? + /// Human-readable reason a control is unavailable ("Not connected"). + let blockedReason: String? +} + +// MARK: - Haptics + +/// A one-shot event the watch should feel. +/// +/// Carries an `id` so the watch fires exactly once per event: state diffing +/// would double-fire on redelivery, which WatchConnectivity does routinely. +struct WatchHapticCue: Codable, Hashable { + let id: String + /// "success" | "failure" | "notification" + let kind: String +} + +// MARK: - Snapshot + +/// The complete state the watch renders. +struct WatchSnapshot: Codable, Hashable { + let wireVersion: Int + let sessionId: String + + // Session core — mirrors the Live Activity's fields so both surfaces agree. + let mode: String + let phase: String + let phaseTitle: String + let phaseDetail: String? + let phaseEndsAtMs: Double? + let isConnected: Bool + let zoneCode: String? + let txCount: Int + let rxCount: Int + let discoveryCount: Int + let traceCount: Int + let queueSize: Int + + /// Colour of the most recent completed ping result. + let pingColor: WatchColor? + + let geo: WatchGeo + let controls: WatchControls + let cue: WatchHapticCue? + let updatedAtMs: Double + + /// True when this payload came from a wire version the app understands. + var isSupportedVersion: Bool { wireVersion == MeshMapperWatchWire.version } + + var updatedAt: Date { + Date(timeIntervalSince1970: updatedAtMs / 1000) + } + + var phaseEndsAt: Date? { + phaseEndsAtMs.map { Date(timeIntervalSince1970: $0 / 1000) } + } +} + +// MARK: - Commands (watch → phone) + +/// An intent from the wrist. Never state — the phone decides what happens. +struct WatchCommand: Codable, Hashable { + enum Kind: String, Codable { + case startSession + case stopSession + case manualPing + /// Watch asking for a fresh snapshot (e.g. app just came to the front). + case requestSnapshot + } + + let kind: Kind + /// Client-generated, so the phone can dedupe redelivered commands. + let id: String +} + +/// The phone's answer to a command. +struct WatchCommandAck: Codable, Hashable { + let id: String + let accepted: Bool + /// Why it was refused, for display on the wrist. + let reason: String? +} + +// MARK: - Coding helpers + +extension MeshMapperWatchWire { + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + return encoder + }() + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + return decoder + }() + + /// Key used for the single `Data` blob inside a WatchConnectivity payload. + static let payloadKey = "snapshot" + static let commandKey = "command" +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b52b3ede..d08e8a78 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -47,6 +47,9 @@ import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../services/live_activity/live_activity_models.dart'; import '../services/live_activity/live_activity_service.dart'; +import '../services/watch/watch_bridge_service.dart'; +import '../services/watch/watch_geo_builder.dart'; +import '../services/watch/watch_models.dart'; import '../services/custom_api_service.dart'; import '../utils/constants.dart'; import '../utils/geo_validation.dart'; @@ -130,6 +133,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final Listenable _timerListenable; final LiveActivityService _liveActivityService = LiveActivityService(); + final WatchBridgeService _watchBridge = WatchBridgeService(); + + /// Last position sent to the watch, held until the fix moves far enough to + /// be worth an update. See [_resolveWatchPosition]. + WatchPosition? _lastWatchPosition; bool _liveActivitySessionActive = false; bool _liveActivityManualSession = false; String? _liveActivitySessionId; @@ -1184,13 +1192,206 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } void _scheduleLiveActivitySync({bool immediate = false}) { - if (_isDisposed || !_liveActivityService.isSupportedPlatform) return; + if (_isDisposed) return; + // The watch mirrors state even with no session running — otherwise you + // could never start one from the wrist. + _scheduleWatchSync(immediate: immediate); + if (!_liveActivityService.isSupportedPlatform) return; _liveActivityService.schedule( _buildLiveActivitySnapshot, immediate: immediate, ); } + void _scheduleWatchSync({bool immediate = false}) { + if (_isDisposed || !_watchBridge.isSupportedPlatform) return; + _watchBridge.schedule(_buildWatchSnapshot, immediate: immediate); + } + + /// Builds the watch payload. + /// + /// Unlike the Live Activity, this is never null while the app is alive: the + /// wrist shows idle and disconnected states too, and the start button has to + /// be reachable before a session exists. + WatchSnapshot? _buildWatchSnapshot() { + if (_isDisposed) return null; + + final phase = _resolveLiveActivityPhase(); + final repeaterState = _buildLiveActivityRepeaters(); + final now = DateTime.now(); + + final core = LiveActivitySnapshot( + sessionId: _liveActivitySessionId ?? 'idle', + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + isConnected: isConnected, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + discoveryCount: _pingStats.discCount, + traceCount: _pingStats.traceCount, + queueSize: _queueSize, + repeaters: repeaterState.repeaters, + totalHeardCount: repeaterState.totalCount, + repeatersAreCurrent: repeaterState.isCurrent, + updatedAt: now, + ); + + return WatchSnapshot( + core: core, + geo: _buildWatchGeo(now), + controls: _buildWatchControls(), + pingColor: _resolveWatchPingColor(), + updatedAt: now, + ); + } + + WatchGeo _buildWatchGeo(DateTime now) { + final position = _resolveWatchPosition(); + + // Repeaters heard during the current cycle get the highlight ring. + final heardIds = _liveActivityRepeaters + .map((r) => r.repeaterId.toUpperCase()) + .toSet(); + + // "Recently responded" is the newest TX ping that actually got answers. + final answered = _txPings.lastWhere( + (p) => p.heardRepeaters.isNotEmpty, + orElse: () => TxPing( + latitude: 0, + longitude: 0, + power: 0, + timestamp: now, + deviceId: '', + ), + ); + + final repeaterById = { + for (final repeater in _repeaters) ...{ + repeater.id: repeater, + if (repeater.hexId.isNotEmpty) repeater.hexId.toUpperCase(): repeater, + } + }; + + return WatchGeo( + you: position, + pings: WatchGeoBuilder.buildPings(txPings: _txPings, rxPings: _rxPings), + repeaters: WatchGeoBuilder.buildRepeaters( + repeaters: _repeaters, + heardThisCycle: heardIds, + lat: position?.lat, + lon: position?.lon, + ), + heard: WatchGeoBuilder.buildHeard( + heard: answered.heardRepeaters, + repeaterById: repeaterById, + at: answered.timestamp, + lat: position?.lat, + lon: position?.lon, + ), + linkedRepeaterIds: + answered.heardRepeaters.map((r) => r.repeaterId).toList(), + ); + } + + /// Current fix, held still until it moves meaningfully. + /// + /// Returning the previous position leaves the payload fingerprint unchanged, + /// so the bridge's dedupe suppresses the send. A parked phone therefore + /// stops talking to the watch instead of streaming GPS jitter at it. + WatchPosition? _resolveWatchPosition() { + final position = _currentPosition; + if (position == null) return _lastWatchPosition; + + final previous = _lastWatchPosition; + if (previous != null && + !WatchGeoBuilder.movedEnough( + lastLat: previous.lat, + lastLon: previous.lon, + lat: position.latitude, + lon: position.longitude, + )) { + return previous; + } + + final resolved = WatchPosition( + lat: position.latitude, + lon: position.longitude, + headingDeg: position.heading.isFinite && position.heading >= 0 + ? position.heading + : null, + accuracyM: position.accuracy.isFinite ? position.accuracy : null, + fixedAt: position.timestamp, + ); + _lastWatchPosition = resolved; + return resolved; + } + + WatchControls _buildWatchControls() { + final cooldownMs = _manualPingCooldownTimer.remainingMs; + final String? blockedReason; + if (!isConnected) { + blockedReason = 'Not connected'; + } else if (!hasGpsLock) { + blockedReason = 'No GPS fix'; + } else { + blockedReason = null; + } + + return WatchControls( + canStartStop: isConnected, + canManualPing: canPing && cooldownMs <= 0, + isSessionActive: _autoPingEnabled, + manualCooldownEndsAt: cooldownMs > 0 + ? DateTime.now().add(Duration(milliseconds: cooldownMs)) + : null, + blockedReason: blockedReason, + ); + } + + /// Colour of the most recent completed ping, matching the map's markers. + WatchColor? _resolveWatchPingColor() { + if (_txPings.isEmpty) return null; + final latest = _txPings.last; + return WatchGeoBuilder.pingColor('tx', latest.heardRepeaters.isNotEmpty); + } + + /// Applies an intent from the wrist. + /// + /// Returns null when accepted, or a reason to show on the watch. Every guard + /// is re-evaluated here: the watch's view of what's permitted may be stale, + /// and a stale payload must never be able to cause a transmit. + Future _handleWatchCommand(WatchCommandKind kind) async { + if (_isDisposed) return 'App closing'; + + switch (kind) { + case WatchCommandKind.requestSnapshot: + _scheduleWatchSync(immediate: true); + return null; + + case WatchCommandKind.startSession: + if (!isConnected) return 'Not connected'; + if (_autoPingEnabled) return null; // Already running. + final started = await toggleAutoPing(_autoMode); + return started ? null : 'Could not start'; + + case WatchCommandKind.stopSession: + if (!_autoPingEnabled) return null; // Already stopped. + await toggleAutoPing(_autoMode); + return null; + + case WatchCommandKind.manualPing: + if (!isConnected) return 'Not connected'; + if (!hasGpsLock) return 'No GPS fix'; + if (_manualPingCooldownTimer.remainingMs > 0) return 'Cooling down'; + final sent = await sendPing(); + return sent ? null : 'Ping failed'; + } + } + LiveActivitySnapshot? _buildLiveActivitySnapshot() { final sessionId = _liveActivitySessionId; if (!_liveActivitySessionActive || sessionId == null) { @@ -1593,6 +1794,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_liveActivityService.isSupportedPlatform) { _timerListenable.addListener(_handleLiveActivityTimerChange); } + if (_watchBridge.isSupportedPlatform) { + _watchBridge.attachCommandHandler(_handleWatchCommand); + } // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -8356,6 +8560,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; _timerListenable.removeListener(_handleLiveActivityTimerChange); _liveActivityService.dispose(); + _watchBridge.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); _connectionSubscription?.cancel(); diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart new file mode 100644 index 00000000..65b9594c --- /dev/null +++ b/lib/services/watch/watch_bridge_service.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import 'watch_models.dart'; + +typedef WatchSnapshotBuilder = WatchSnapshot? Function(); + +/// Handles a command from the wrist. Returns null when accepted, or a reason +/// string when refused — the reason is shown on the watch. +typedef WatchCommandHandler = Future Function(WatchCommandKind kind); + +/// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. +/// +/// Deliberately mirrors [LiveActivityService]'s shape — fingerprint dedupe, +/// urgency bypass, minimum non-urgent interval — because that pattern is +/// already proven in this app. The one addition is a movement gate: GPS +/// updates arrive continuously while driving, and forwarding every one would +/// flatten the watch battery for sub-pixel map changes. +class WatchBridgeService { + WatchBridgeService({@visibleForTesting MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(_channelName); + + static const String _channelName = 'meshmapper/watch'; + + static const Duration _debounceDelay = Duration(milliseconds: 200); + static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + + final MethodChannel _channel; + + Timer? _scheduledUpdate; + WatchSnapshotBuilder? _pendingSnapshotBuilder; + WatchCommandHandler? _commandHandler; + + String? _lastPayload; + String? _lastUrgencyKey; + DateTime? _lastSentAt; + bool _disposed = false; + bool _didReconcileNativeState = false; + Future _operationChain = Future.value(); + + /// Commands already handled, so redelivery can't fire a second transmit. + final Set _handledCommandIds = {}; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + /// Wire up the inbound command path. Safe to call more than once. + void attachCommandHandler(WatchCommandHandler handler) { + _commandHandler = handler; + if (!isSupportedPlatform) return; + _channel.setMethodCallHandler(_handleNativeCall); + } + + Future _handleNativeCall(MethodCall call) async { + if (call.method != 'command') return null; + + final args = call.arguments; + if (args is! Map) return {'accepted': false, 'reason': 'Malformed command'}; + + final id = args['id'] as String?; + final rawKind = args['kind'] as String?; + if (id == null || rawKind == null) { + return {'accepted': false, 'reason': 'Malformed command'}; + } + + // WatchConnectivity redelivers; a duplicate must not transmit twice. + if (_handledCommandIds.contains(id)) { + return {'id': id, 'accepted': true, 'reason': null}; + } + + final kind = WatchCommandKind.fromWire(rawKind); + if (kind == null) { + return {'id': id, 'accepted': false, 'reason': 'Unsupported command'}; + } + + final handler = _commandHandler; + if (handler == null) { + return {'id': id, 'accepted': false, 'reason': 'App not ready'}; + } + + _rememberCommandId(id); + + try { + final refusal = await handler(kind); + // A refused command may legitimately be retried once conditions change. + if (refusal != null) _handledCommandIds.remove(id); + return {'id': id, 'accepted': refusal == null, 'reason': refusal}; + } catch (error) { + _handledCommandIds.remove(id); + debugError('[WATCH] Command $rawKind failed: $error'); + return {'id': id, 'accepted': false, 'reason': 'Command failed'}; + } + } + + void _rememberCommandId(String id) { + _handledCommandIds.add(id); + // Unbounded growth would leak across a long session. + if (_handledCommandIds.length > 64) { + _handledCommandIds.remove(_handledCommandIds.first); + } + } + + void schedule( + WatchSnapshotBuilder snapshotBuilder, { + bool immediate = false, + }) { + if (_disposed || !isSupportedPlatform) return; + + _pendingSnapshotBuilder = snapshotBuilder; + _scheduledUpdate?.cancel(); + + if (immediate) { + _enqueueFlush(); + return; + } + + _scheduledUpdate = Timer(_debounceDelay, _enqueueFlush); + } + + void _enqueueFlush() { + _operationChain = _operationChain.then((_) => _flush()).catchError( + (Object error) { + debugError('[WATCH] Update queue failed: $error'); + }, + ); + } + + Future _flush() async { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + + if (_disposed || !isSupportedPlatform) return; + + final snapshot = _pendingSnapshotBuilder?.call(); + if (snapshot == null) { + if (_lastPayload == null && _didReconcileNativeState) return; + await _clear(); + return; + } + + final payload = snapshot.toMap(); + + // updatedAt is metadata for staleness, not a visible state change. + // Excluding it stops timer ticks from causing native updates; the watch + // renders countdowns from the absolute phaseEndsAt deadline instead. + final fingerprint = Map.from(payload) + ..remove('updatedAtMs'); + final encoded = jsonEncode(fingerprint); + if (encoded == _lastPayload) return; + + final urgent = snapshot.urgencyKey != _lastUrgencyKey; + final lastSentAt = _lastSentAt; + if (!urgent && lastSentAt != null) { + final elapsed = DateTime.now().difference(lastSentAt); + if (elapsed < _minimumNonUrgentInterval) { + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return; + } + } + + try { + await _channel.invokeMethod('sync', { + 'payload': payload, + 'urgent': urgent, + }); + _didReconcileNativeState = true; + _lastPayload = encoded; + _lastUrgencyKey = snapshot.urgencyKey; + _lastSentAt = DateTime.now(); + } on MissingPluginException { + // Expected on non-iOS hosts and in tests. + } on PlatformException catch (error) { + debugError('[WATCH] Sync failed: ${error.code}: ${error.message}'); + } catch (error) { + debugError('[WATCH] Unexpected sync failure: $error'); + } + } + + Future _clear() async { + try { + await _channel.invokeMethod('clear'); + } on MissingPluginException { + // Expected on non-iOS hosts and in tests. + } on PlatformException catch (error) { + debugError('[WATCH] Clear failed: ${error.code}: ${error.message}'); + } catch (error) { + debugError('[WATCH] Unexpected clear failure: $error'); + } finally { + _didReconcileNativeState = true; + _lastPayload = null; + _lastUrgencyKey = null; + _lastSentAt = null; + } + } + + void dispose() { + _disposed = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _pendingSnapshotBuilder = null; + _commandHandler = null; + } +} diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart new file mode 100644 index 00000000..0314d18e --- /dev/null +++ b/lib/services/watch/watch_geo_builder.dart @@ -0,0 +1,213 @@ +import 'dart:math' as math; + +import '../../models/ping_data.dart'; +import '../../models/repeater.dart'; +import '../../utils/ping_colors.dart'; +import 'watch_models.dart'; + +/// Pure builders for the geographic half of a [WatchSnapshot]. +/// +/// Kept free of provider and platform dependencies so the caps, decimation, +/// and colour resolution can be unit-tested directly — those are exactly the +/// rules that would otherwise only fail on a wrist, in a car, at speed. +class WatchGeoBuilder { + WatchGeoBuilder._(); + + /// Great-circle distance in metres. + /// + /// Local rather than `Geolocator.distanceBetween` to keep this file free of + /// plugin imports; the maths is identical. + static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const earthRadius = 6371000.0; + final dLat = _toRadians(lat2 - lat1); + final dLon = _toRadians(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _toRadians(double degrees) => degrees * math.pi / 180.0; + + /// Colour for a ping marker, matching the iOS map's `_coverageStatusColor`. + static WatchColor pingColor(String kind, bool success) { + switch (kind) { + case 'tx': + return WatchColor.fromColor( + success ? PingColors.txSuccess : PingColors.txFail, + ); + case 'rx': + return WatchColor.fromColor(PingColors.rx); + case 'disc': + return WatchColor.fromColor( + success ? PingColors.discSuccess : PingColors.discFail, + ); + case 'trace': + return WatchColor.fromColor( + success ? PingColors.traceSuccess : PingColors.noResponse, + ); + default: + return WatchColor.fromColor(PingColors.noResponse); + } + } + + /// Colour for a repeater pin, matching the iOS map's `_repeaterStatusColor`. + static WatchColor repeaterColor(Repeater repeater) { + if (repeater.isDead) return WatchColor.fromColor(PingColors.repeaterDead); + if (repeater.isNew) return WatchColor.fromColor(PingColors.repeaterNew); + return WatchColor.fromColor(PingColors.repeaterActive); + } + + /// Most recent pings, newest first, capped at [WatchWire.maxPings]. + /// + /// TX and RX are merged into one time-ordered stream because the watch map + /// shows them together; a TX that nobody answered is drawn as a failure. + static List buildPings({ + required List txPings, + required List rxPings, + int cap = WatchWire.maxPings, + }) { + final pings = []; + + for (var i = 0; i < txPings.length; i++) { + final tx = txPings[i]; + final success = tx.heardRepeaters.isNotEmpty; + pings.add(WatchPing( + id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', + lat: tx.latitude, + lon: tx.longitude, + kind: 'tx', + color: pingColor('tx', success), + at: tx.timestamp, + )); + } + + for (var i = 0; i < rxPings.length; i++) { + final rx = rxPings[i]; + pings.add(WatchPing( + id: 'rx-${rx.timestamp.millisecondsSinceEpoch}-$i', + lat: rx.latitude, + lon: rx.longitude, + kind: 'rx', + color: pingColor('rx', true), + at: rx.timestamp, + )); + } + + pings.sort((a, b) => b.at.compareTo(a.at)); + if (pings.length <= cap) return pings; + return pings.sublist(0, cap); + } + + /// Repeaters nearest [lat]/[lon], capped at [WatchWire.maxRepeaters]. + /// + /// Repeaters at the API's `(0, 0)` "location unknown" sentinel are excluded: + /// plotting them would drop a pin in the Gulf of Guinea. + static List buildRepeaters({ + required List repeaters, + required Set heardThisCycle, + double? lat, + double? lon, + int cap = WatchWire.maxRepeaters, + }) { + final located = repeaters.where((r) => r.hasLocation).toList(); + + if (lat != null && lon != null) { + // Distance is computed once per repeater rather than inside the + // comparator: this runs on every rebuild during an active session, and + // a zone can hold hundreds of repeaters. + final ranked = located + .map((r) => ( + repeater: r, + distance: distanceMeters(lat, lon, r.lat, r.lon), + )) + .toList() + ..sort((a, b) => a.distance.compareTo(b.distance)); + located + ..clear() + ..addAll(ranked.map((e) => e.repeater)); + } + + final limited = located.length > cap ? located.sublist(0, cap) : located; + + return limited + .map((r) => WatchRepeater( + id: r.id, + name: r.name, + lat: r.lat, + lon: r.lon, + color: repeaterColor(r), + heardThisCycle: + heardThisCycle.contains(r.id) || + heardThisCycle.contains(r.hexId), + )) + .toList(); + } + + /// Recently-responded rows, strongest SNR first, capped at + /// [WatchWire.maxHeard]. + /// + /// The cap is what the payload carries, not what the watch displays — the + /// view renders as many as fit legibly at the wearer's text size and + /// scrolls for the rest. + static List buildHeard({ + required List heard, + required Map repeaterById, + required DateTime at, + double? lat, + double? lon, + int cap = WatchWire.maxHeard, + }) { + final sorted = List.from(heard) + ..sort((a, b) => (b.snr ?? -999).compareTo(a.snr ?? -999)); + + final limited = sorted.length > cap ? sorted.sublist(0, cap) : sorted; + + return limited.map((h) { + final repeater = repeaterById[h.repeaterId]; + double? distance; + if (lat != null && + lon != null && + repeater != null && + repeater.hasLocation) { + distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); + } + + return WatchHeardNode( + id: h.repeaterId, + name: repeater?.name ?? h.repeaterId.toUpperCase(), + snr: h.snr, + rssi: h.rssi, + hops: h.pathHops?.length, + seenCount: h.seenCount, + at: at, + distanceM: distance, + snrColor: h.snr == null + ? null + : WatchColor.fromColor(PingColors.snrColor(h.snr!)), + ); + }).toList(); + } + + /// True when the fix moved far enough to be worth an update. + /// + /// A stationary GPS jitters by a few metres indefinitely; without this gate + /// a parked phone would keep the watch radio busy for no visible change. + static bool movedEnough({ + required double? lastLat, + required double? lastLon, + required double lat, + required double lon, + double thresholdMeters = WatchWire.minMoveMeters, + }) { + if (lastLat == null || lastLon == null) return true; + return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; + } +} diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart new file mode 100644 index 00000000..ecc9955c --- /dev/null +++ b/lib/services/watch/watch_models.dart @@ -0,0 +1,320 @@ +import 'dart:ui' show Color; + +import '../live_activity/live_activity_models.dart'; + +/// Wire contract for the watchOS companion. +/// +/// The Swift mirror lives in `ios/Shared/MeshMapperWatchPayload.swift` and is +/// compiled into both Runner and the watch target. This file and that one are +/// a matched pair — change one, change the other, and update the golden +/// fixtures in `test/services/watch/`. +/// +/// [LiveActivitySnapshot] is reused verbatim for the session core rather than +/// re-deriving phase/counter semantics, which is the expensive and bug-prone +/// part. This snapshot composes it with the geography, controls, and haptic +/// cue the Live Activity has no use for. +class WatchWire { + WatchWire._(); + + /// Bump when a field changes meaning or is removed. The watch refuses + /// payloads it doesn't understand rather than rendering something wrong. + static const int version = 1; + + static const int maxPings = 60; + static const int maxRepeaters = 20; + static const int maxHeard = 7; + + /// Skip a geo-only update unless the fix moved at least this far. Phase + /// changes and new pings always go through; this only suppresses the + /// jitter of a stationary GPS. + static const double minMoveMeters = 15.0; +} + +/// An sRGB colour resolved from the active colour-vision palette. +/// +/// Resolving on the phone is deliberate: Dart owns [PingColors], so the watch +/// renders accessibility palettes correctly without duplicating any of them. +class WatchColor { + const WatchColor(this.r, this.g, this.b); + + factory WatchColor.fromColor(Color color) => WatchColor( + (color.r * 255.0).roundToDouble() / 255.0, + (color.g * 255.0).roundToDouble() / 255.0, + (color.b * 255.0).roundToDouble() / 255.0, + ); + + final double r; + final double g; + final double b; + + Map toMap() => {'r': r, 'g': g, 'b': b}; + + @override + bool operator ==(Object other) => + other is WatchColor && other.r == r && other.g == g && other.b == b; + + @override + int get hashCode => Object.hash(r, g, b); +} + +class WatchPosition { + const WatchPosition({ + required this.lat, + required this.lon, + required this.fixedAt, + this.headingDeg, + this.accuracyM, + }); + + final double lat; + final double lon; + final double? headingDeg; + final double? accuracyM; + final DateTime fixedAt; + + Map toMap() => { + 'lat': lat, + 'lon': lon, + 'headingDeg': headingDeg, + 'accuracyM': accuracyM, + 'fixedAtMs': fixedAt.millisecondsSinceEpoch.toDouble(), + }; +} + +class WatchPing { + const WatchPing({ + required this.id, + required this.lat, + required this.lon, + required this.kind, + required this.color, + required this.at, + }); + + final String id; + final double lat; + final double lon; + + /// 'tx' | 'rx' | 'disc' | 'trace' — drives glyph choice, not colour. + final String kind; + final WatchColor color; + final DateTime at; + + Map toMap() => { + 'id': id, + 'lat': lat, + 'lon': lon, + 'kind': kind, + 'color': color.toMap(), + 'atMs': at.millisecondsSinceEpoch.toDouble(), + }; +} + +class WatchRepeater { + const WatchRepeater({ + required this.id, + required this.name, + required this.lat, + required this.lon, + required this.color, + required this.heardThisCycle, + }); + + final String id; + final String name; + final double lat; + final double lon; + final WatchColor color; + final bool heardThisCycle; + + Map toMap() => { + 'id': id, + 'name': name, + 'lat': lat, + 'lon': lon, + 'color': color.toMap(), + 'heardThisCycle': heardThisCycle, + }; +} + +class WatchHeardNode { + const WatchHeardNode({ + required this.id, + required this.name, + required this.seenCount, + required this.at, + this.snr, + this.rssi, + this.hops, + this.distanceM, + this.snrColor, + }); + + final String id; + final String name; + final double? snr; + final int? rssi; + + /// null = direct echo; otherwise the hop count. + final int? hops; + final int seenCount; + final DateTime at; + final double? distanceM; + final WatchColor? snrColor; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr, + 'rssi': rssi, + 'hops': hops, + 'seenCount': seenCount, + 'atMs': at.millisecondsSinceEpoch.toDouble(), + 'distanceM': distanceM, + 'snrColor': snrColor?.toMap(), + }; +} + +class WatchGeo { + const WatchGeo({ + required this.pings, + required this.repeaters, + required this.heard, + required this.linkedRepeaterIds, + this.you, + }); + + final WatchPosition? you; + final List pings; + final List repeaters; + final List heard; + final List linkedRepeaterIds; + + Map toMap() => { + 'you': you?.toMap(), + 'pings': pings.map((p) => p.toMap()).toList(), + 'repeaters': repeaters.map((r) => r.toMap()).toList(), + 'heard': heard.map((h) => h.toMap()).toList(), + 'linkedRepeaterIds': linkedRepeaterIds, + }; +} + +/// What the wrist may do right now. +/// +/// Drives button enablement only. The phone revalidates every command, so a +/// stale payload can never talk it into an illegal transmit. +class WatchControls { + const WatchControls({ + required this.canStartStop, + required this.canManualPing, + required this.isSessionActive, + this.manualCooldownEndsAt, + this.blockedReason, + }); + + final bool canStartStop; + final bool canManualPing; + final bool isSessionActive; + final DateTime? manualCooldownEndsAt; + final String? blockedReason; + + Map toMap() => { + 'canStartStop': canStartStop, + 'canManualPing': canManualPing, + 'isSessionActive': isSessionActive, + 'manualCooldownEndsAtMs': + manualCooldownEndsAt?.millisecondsSinceEpoch.toDouble(), + 'blockedReason': blockedReason, + }; +} + +/// A one-shot event the watch should feel. +/// +/// Carries an [id] so the watch fires exactly once: diffing state would +/// double-fire on redelivery, which WatchConnectivity does routinely. +class WatchHapticCue { + const WatchHapticCue({required this.id, required this.kind}); + + final String id; + + /// 'success' | 'failure' | 'notification' + final String kind; + + Map toMap() => {'id': id, 'kind': kind}; +} + +/// The complete state the watch renders. +class WatchSnapshot { + const WatchSnapshot({ + required this.core, + required this.geo, + required this.controls, + required this.updatedAt, + this.pingColor, + this.cue, + }); + + /// Session core, reused from the Live Activity so both surfaces agree. + final LiveActivitySnapshot core; + final WatchGeo geo; + final WatchControls controls; + final WatchColor? pingColor; + final WatchHapticCue? cue; + final DateTime updatedAt; + + Map toMap() => { + 'wireVersion': WatchWire.version, + 'sessionId': core.sessionId, + 'mode': core.mode, + 'phase': core.phase.wireValue, + 'phaseTitle': core.phaseTitle, + 'phaseDetail': core.phaseDetail, + 'phaseEndsAtMs': + core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'isConnected': core.isConnected, + 'zoneCode': core.zoneCode, + 'txCount': core.txCount, + 'rxCount': core.rxCount, + 'discoveryCount': core.discoveryCount, + 'traceCount': core.traceCount, + 'queueSize': core.queueSize, + 'pingColor': pingColor?.toMap(), + 'geo': geo.toMap(), + 'controls': controls.toMap(), + 'cue': cue?.toMap(), + 'updatedAtMs': updatedAt.millisecondsSinceEpoch.toDouble(), + }; + + /// Fields that must bypass the update throttle. + /// + /// Deliberately excludes geo: a moving GPS would otherwise mark every + /// update urgent and defeat the throttle entirely. + String get urgencyKey => [ + core.sessionId, + core.mode, + core.phase.wireValue, + core.phaseTitle, + core.phaseDetail ?? '', + core.phaseEndsAt?.millisecondsSinceEpoch ?? 0, + core.isConnected, + controls.canStartStop, + controls.canManualPing, + controls.isSessionActive, + cue?.id ?? '', + ].join('|'); +} + +/// An intent from the wrist. Never state — the phone decides what happens. +enum WatchCommandKind { + startSession, + stopSession, + manualPing, + requestSnapshot; + + static WatchCommandKind? fromWire(String value) { + for (final kind in WatchCommandKind.values) { + if (kind.name == value) return kind; + } + return null; + } +} diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart new file mode 100644 index 00000000..1d24224c --- /dev/null +++ b/test/services/watch/watch_geo_builder_test.dart @@ -0,0 +1,279 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/ping_data.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; +import 'package:mesh_mapper/utils/ping_colors.dart'; + +TxPing _tx(DateTime at, {List heard = const []}) => TxPing( + latitude: 47.6, + longitude: -122.3, + power: 22, + timestamp: at, + deviceId: 'dev', + heardRepeaters: List.of(heard), + ); + +RxPing _rx(DateTime at) => RxPing( + latitude: 47.61, + longitude: -122.31, + repeaterId: '4e', + timestamp: at, + snr: 5.0, + rssi: -90, + ); + +Repeater _repeater({ + required String id, + required double lat, + required double lon, + String name = 'Rep', + String hexId = '', + int? createdAt, + int? staleTime, +}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: lat, + lon: lon, + lastHeard: DateTime.now().millisecondsSinceEpoch ~/ 1000, + enabled: 1, + createdAt: createdAt, + staleTime: staleTime, + ); + +void main() { + setUp(() => PingColors.setColorVisionType(ColorVisionType.none)); + + group('buildPings', () { + test('merges TX and RX newest-first and caps the list', () { + final base = DateTime(2026, 8, 12, 10); + final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); + final rx = List.generate(40, (i) => _rx(base.add(Duration(seconds: i)))); + + final pings = WatchGeoBuilder.buildPings(txPings: tx, rxPings: rx); + + expect(pings.length, WatchWire.maxPings); + for (var i = 1; i < pings.length; i++) { + expect( + pings[i - 1].at.isAfter(pings[i].at) || + pings[i - 1].at.isAtSameMomentAs(pings[i].at), + isTrue, + reason: 'pings must be newest-first', + ); + } + }); + + test('an unanswered TX is coloured as a failure', () { + final answered = _tx( + DateTime(2026, 8, 12, 10, 1), + heard: const [HeardRepeater(repeaterId: '4e', snr: 6)], + ); + final ignored = _tx(DateTime(2026, 8, 12, 10)); + + final pings = WatchGeoBuilder.buildPings( + txPings: [answered, ignored], + rxPings: const [], + ); + + final byTime = {for (final p in pings) p.at: p}; + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.txSuccess), + ); + expect( + byTime[ignored.timestamp]!.color, + WatchColor.fromColor(PingColors.txFail), + ); + }); + }); + + group('buildRepeaters', () { + test('excludes the (0,0) "location unknown" sentinel', () { + final repeaters = [ + _repeater(id: 'a', lat: 0, lon: 0), + _repeater(id: 'b', lat: 47.6, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: const {}, + ); + + expect(built.map((r) => r.id), ['b']); + }); + + test('orders by distance from the fix and caps the list', () { + final repeaters = [ + _repeater(id: 'far', lat: 48.6, lon: -122.3), + _repeater(id: 'near', lat: 47.601, lon: -122.3), + _repeater(id: 'mid', lat: 47.7, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: const {}, + lat: 47.6, + lon: -122.3, + cap: 2, + ); + + expect(built.map((r) => r.id), ['near', 'mid']); + }); + + test('marks heard-this-cycle by either short id or hex id', () { + final repeaters = [ + _repeater(id: '01', hexId: 'AA11', lat: 47.6, lon: -122.3), + _repeater(id: '02', hexId: 'BB22', lat: 47.6, lon: -122.3), + _repeater(id: '03', hexId: 'CC33', lat: 47.6, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: {'01', 'BB22'}, + ); + + expect( + {for (final r in built) r.id: r.heardThisCycle}, + {'01': true, '02': true, '03': false}, + ); + }); + }); + + group('buildHeard', () { + test('sorts by SNR descending and caps at the wire limit', () { + final heard = List.generate( + 12, + (i) => HeardRepeater(repeaterId: 'r$i', snr: i.toDouble()), + ); + + final built = WatchGeoBuilder.buildHeard( + heard: heard, + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built.length, WatchWire.maxHeard); + expect(built.first.snr, 11.0); + expect(built.last.snr, 5.0); + }); + + test('a null SNR sorts last rather than crashing', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [ + HeardRepeater(repeaterId: 'quiet'), + HeardRepeater(repeaterId: 'loud', snr: 3), + ], + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built.map((h) => h.id), ['loud', 'quiet']); + expect(built.last.snrColor, isNull); + }); + + test('resolves name and distance from the repeater directory', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [HeardRepeater(repeaterId: '4e', snr: 6, rssi: -80)], + repeaterById: { + '4e': _repeater(id: '4e', name: 'Capitol Hill', lat: 47.61, lon: -122.3), + }, + at: DateTime(2026, 8, 12), + lat: 47.6, + lon: -122.3, + ); + + expect(built.single.name, 'Capitol Hill'); + expect(built.single.distanceM, closeTo(1112, 50)); + }); + + test('falls back to the uppercased id when the repeater is unknown', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [HeardRepeater(repeaterId: 'ab')], + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built.single.name, 'AB'); + expect(built.single.distanceM, isNull); + }); + + test('direct echoes report no hop count', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [ + HeardRepeater(repeaterId: 'direct', snr: 9), + HeardRepeater(repeaterId: 'relayed', snr: 8, pathHops: ['aa', 'bb']), + ], + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built[0].hops, isNull); + expect(built[1].hops, 2); + }); + }); + + group('movedEnough', () { + test('always sends the first fix', () { + expect( + WatchGeoBuilder.movedEnough( + lastLat: null, + lastLon: null, + lat: 47.6, + lon: -122.3, + ), + isTrue, + ); + }); + + test('suppresses stationary GPS jitter', () { + expect( + WatchGeoBuilder.movedEnough( + lastLat: 47.6, + lastLon: -122.3, + lat: 47.60002, + lon: -122.30002, + ), + isFalse, + ); + }); + + test('passes once the fix moves past the threshold', () { + expect( + WatchGeoBuilder.movedEnough( + lastLat: 47.6, + lastLon: -122.3, + lat: 47.6005, + lon: -122.3, + ), + isTrue, + ); + }); + }); + + group('colour vision', () { + test('ping colours follow the active palette', () { + PingColors.setColorVisionType(ColorVisionType.none); + final standard = WatchGeoBuilder.pingColor('tx', true); + + PingColors.setColorVisionType(ColorVisionType.protanopia); + final protan = WatchGeoBuilder.pingColor('tx', true); + + expect(protan, isNot(standard)); + expect(protan, WatchColor.fromColor(PingColors.txSuccess)); + }); + + test('every palette resolves all ping kinds without throwing', () { + for (final type in ColorVisionType.values) { + PingColors.setColorVisionType(type); + for (final kind in ['tx', 'rx', 'disc', 'trace', 'bogus']) { + for (final success in [true, false]) { + expect(WatchGeoBuilder.pingColor(kind, success), isA()); + } + } + } + }); + }); +} diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart new file mode 100644 index 00000000..16cab7d2 --- /dev/null +++ b/test/services/watch/watch_wire_contract_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_bridge_service.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +/// These tests guard the Dart↔Swift contract. +/// +/// `ios/Shared/MeshMapperWatchPayload.swift` decodes this JSON with a synthesised +/// `Codable`, which matches on exact key names. A rename on either side compiles +/// fine and fails silently at runtime on the wrist — so the key set is asserted +/// here rather than trusted. +WatchSnapshot _snapshot({ + WatchHapticCue? cue, + String phaseTitle = 'Listening', + bool isConnected = true, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Active', + phase: LiveActivityPhase.listening, + phaseTitle: phaseTitle, + phaseDetail: 'Waiting for echoes', + phaseEndsAt: DateTime.fromMillisecondsSinceEpoch(1760000000000), + isConnected: isConnected, + zoneCode: 'SEA', + txCount: 3, + rxCount: 2, + discoveryCount: 1, + traceCount: 0, + queueSize: 4, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + ), + geo: const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: const WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + ), + pingColor: const WatchColor(1, 0, 0), + cue: cue, + updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + ); + +void main() { + group('wire contract', () { + test('top-level keys match the Swift WatchSnapshot', () { + final map = _snapshot().toMap(); + + expect( + map.keys.toSet(), + { + 'wireVersion', + 'sessionId', + 'mode', + 'phase', + 'phaseTitle', + 'phaseDetail', + 'phaseEndsAtMs', + 'isConnected', + 'zoneCode', + 'txCount', + 'rxCount', + 'discoveryCount', + 'traceCount', + 'queueSize', + 'pingColor', + 'geo', + 'controls', + 'cue', + 'updatedAtMs', + }, + ); + }); + + test('nested keys match the Swift structs', () { + final map = _snapshot().toMap(); + + expect( + (map['geo']! as Map).keys.toSet(), + {'you', 'pings', 'repeaters', 'heard', 'linkedRepeaterIds'}, + ); + expect( + (map['controls']! as Map).keys.toSet(), + { + 'canStartStop', + 'canManualPing', + 'isSessionActive', + 'manualCooldownEndsAtMs', + 'blockedReason', + }, + ); + expect((map['pingColor']! as Map).keys.toSet(), {'r', 'g', 'b'}); + }); + + test('timestamps are epoch milliseconds as doubles', () { + final map = _snapshot().toMap(); + expect(map['updatedAtMs'], isA()); + expect(map['phaseEndsAtMs'], 1760000000000.0); + }); + + test('wire version is stamped so the watch can refuse unknown payloads', () { + expect(_snapshot().toMap()['wireVersion'], WatchWire.version); + }); + + test('urgency key ignores geo but tracks phase, controls, and cues', () { + final base = _snapshot(); + expect(_snapshot().urgencyKey, base.urgencyKey); + + expect( + _snapshot(phaseTitle: 'Sending').urgencyKey, + isNot(base.urgencyKey), + ); + expect( + _snapshot(isConnected: false).urgencyKey, + isNot(base.urgencyKey), + ); + expect( + _snapshot(cue: const WatchHapticCue(id: 'c1', kind: 'success')) + .urgencyKey, + isNot(base.urgencyKey), + ); + }); + }); + + group('command kinds', () { + test('round-trip through the wire names Swift sends', () { + for (final kind in WatchCommandKind.values) { + expect(WatchCommandKind.fromWire(kind.name), kind); + } + }); + + test('an unknown command is rejected rather than guessed at', () { + expect(WatchCommandKind.fromWire('selfDestruct'), isNull); + }); + }); + + group('bridge command handling', () { + late WatchBridgeService bridge; + late MethodChannel channel; + late List handled; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/watch_test'); + bridge = WatchBridgeService(channel: channel); + handled = []; + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + bridge.dispose(); + }); + + Future?> sendCommand(String id, String kind) async { + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + MethodCall('command', {'id': id, 'kind': kind}), + ), + null, + ); + if (result == null) return null; + return channel.codec.decodeEnvelope(result) as Map?; + } + + test('accepted commands reach the handler', () async { + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return null; + }); + + final reply = await sendCommand('cmd-1', 'manualPing'); + + expect(handled, [WatchCommandKind.manualPing]); + expect(reply?['accepted'], isTrue); + }); + + test('a redelivered command does not transmit twice', () async { + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return null; + }); + + await sendCommand('cmd-1', 'manualPing'); + await sendCommand('cmd-1', 'manualPing'); + + expect(handled, hasLength(1), + reason: 'WatchConnectivity redelivers; a second ping must not fire'); + }); + + test('a refused command may be retried once conditions change', () async { + var refuse = true; + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return refuse ? 'Not connected' : null; + }); + + final first = await sendCommand('cmd-2', 'startSession'); + expect(first?['accepted'], isFalse); + expect(first?['reason'], 'Not connected'); + + refuse = false; + final second = await sendCommand('cmd-2', 'startSession'); + expect(second?['accepted'], isTrue); + expect(handled, hasLength(2)); + }); + + test('an unknown command is refused without reaching the handler', () async { + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return null; + }); + + final reply = await sendCommand('cmd-3', 'selfDestruct'); + + expect(handled, isEmpty); + expect(reply?['accepted'], isFalse); + }); + + test('a handler that throws refuses rather than crashing the bridge', + () async { + bridge.attachCommandHandler((kind) async => throw StateError('boom')); + + final reply = await sendCommand('cmd-4', 'manualPing'); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Command failed'); + }); + }); +} From b2bedbed70aaa98bb381323c10506d6b8bf4b5d4 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 17:48:12 -0700 Subject: [PATCH 174/360] Add watch map page with pings, repeaters, and follow mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Apple Watch companion. The map is Apple's basemap with MeshMapper's data drawn on top: ping squares in the phone-resolved ping colours, repeater pins with a highlight ring for those heard this cycle, optional lines from the fix to each responding repeater, and a fix puck with heading. A countdown pill renders the phase deadline via Text(timerInterval:). MeshMapper's own basemap cannot come along — MKTileOverlay is API_UNAVAILABLE(watchos), so the OpenFreeMap styles, ArcGIS satellite raster, and coverage vector tiles have no route onto the wrist. Only the data layer is ours; a satellite toggle uses Apple imagery instead. The fix is drawn as a custom annotation rather than UserAnnotation, so the watch renders the *phone's* position and needs no location permission of its own. Follow mode recentres on the fix, yields when the wearer pans, and drifts back after 8s. Two bugs fixed while verifying it: the initial `.automatic` camera settle was misread as a pan (suspending follow before the first fix arrived), and the resume deadline was only ever read during a render, so a stationary phone — which sends no updates — would stay unfollowed indefinitely. Initial span is ~3 km rather than ~1 km: wardriving is about what is around you, and the tighter default opened with every nearby repeater off-screen. SampleSnapshot is DEBUG-only behind a launch argument (-MeshMapperSampleData YES). The simulator has no Bluetooth and so can never produce pings or repeaters, which would leave the map permanently empty there. Verified excluded from Release by building the watch target in Release. Known: Apple Maps basemap tiles do not load in the watch simulator — watchOS proxies tile requests through the paired phone and the simulator's companion proxy returns GEOErrorDomain -11. Annotations, geometry, and camera are all verified; the basemap itself needs real hardware. --- ios/MeshMapperWatch/ContentView.swift | 158 +--------- ios/MeshMapperWatch/DebugPage.swift | 145 ++++++++++ ios/MeshMapperWatch/MapPage.swift | 286 +++++++++++++++++++ ios/MeshMapperWatch/MeshMapperWatchApp.swift | 2 + ios/MeshMapperWatch/SampleSnapshot.swift | 125 ++++++++ ios/MeshMapperWatch/SettingsPage.swift | 30 ++ ios/MeshMapperWatch/WatchSessionClient.swift | 8 + ios/MeshMapperWatch/WatchSettings.swift | 77 +++++ ios/Runner.xcodeproj/project.pbxproj | 20 ++ 9 files changed, 702 insertions(+), 149 deletions(-) create mode 100644 ios/MeshMapperWatch/DebugPage.swift create mode 100644 ios/MeshMapperWatch/MapPage.swift create mode 100644 ios/MeshMapperWatch/SampleSnapshot.swift create mode 100644 ios/MeshMapperWatch/SettingsPage.swift create mode 100644 ios/MeshMapperWatch/WatchSettings.swift diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index aed783be..1a0552a9 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -1,159 +1,19 @@ import SwiftUI -/// Phase 2 debug dump. +/// Root shell. /// -/// Deliberately ugly: this exists to prove the transport carries real state -/// and that commands round-trip with an ack. Phase 3 replaces it with the map. +/// The map is always page one and full-bleed. Phase 4 adds the node list — +/// either as a sheet over this map or as its own page, per +/// `WatchSettings.nodeListPlacement` — and replaces the debug page. struct ContentView: View { @Environment(WatchSessionClient.self) private var client var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - header - - if client.versionMismatch { - Text("Update MeshMapper on iPhone — wire version mismatch") - .font(.caption2) - .foregroundStyle(.orange) - } - - if let snapshot = client.snapshot { - session(snapshot) - counters(snapshot) - geo(snapshot) - controls(snapshot) - } else { - Text("No snapshot yet") - .font(.caption2) - .foregroundStyle(.secondary) - } - - commandButtons - - if let refusal = client.lastRefusal { - Text(refusal) - .font(.caption2) - .foregroundStyle(.orange) - } - } - .padding(.horizontal, 4) - // The whole dump dims when the phone has gone quiet, so stale data is - // never mistaken for live data. - .opacity(client.isStale ? 0.45 : 1.0) - } - } - - private var header: some View { - HStack { - Circle() - .fill(client.isReachable ? .green : .gray) - .frame(width: 6, height: 6) - Text(client.isReachable ? "Reachable" : "Unreachable") - .font(.caption2) - Spacer() - if let receivedAt = client.receivedAt { - Text(receivedAt, style: .relative) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - } - } - } - - private func session(_ s: WatchSnapshot) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(s.phaseTitle) - .font(.headline) - .lineLimit(2) - if let detail = s.phaseDetail { - Text(detail) - .font(.caption2) - .foregroundStyle(.secondary) - } - HStack(spacing: 4) { - if let color = s.pingColor { - Circle() - .fill(Color(red: color.r, green: color.g, blue: color.b)) - .frame(width: 8, height: 8) - } - Text(s.mode).font(.caption2) - // Absolute deadline rendered locally — no per-second traffic. - if let endsAt = s.phaseEndsAt, endsAt > Date() { - Text(timerInterval: Date()...endsAt, countsDown: true) - .font(.caption.monospacedDigit()) - } - } - } - } - - private func counters(_ s: WatchSnapshot) -> some View { - Text("TX \(s.txCount) · RX \(s.rxCount) · DISC \(s.discoveryCount) · Q \(s.queueSize)") - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(.secondary) - } - - private func geo(_ s: WatchSnapshot) -> some View { - VStack(alignment: .leading, spacing: 2) { - if let you = s.geo.you { - Text(String(format: "%.5f, %.5f", you.lat, you.lon)) - .font(.system(size: 10).monospacedDigit()) - } else { - Text("No GPS fix").font(.system(size: 10)).foregroundStyle(.secondary) - } - Text("pings \(s.geo.pings.count) · rptrs \(s.geo.repeaters.count) · heard \(s.geo.heard.count)") - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(.secondary) - - ForEach(s.geo.heard) { node in - HStack(spacing: 4) { - if let c = node.snrColor { - Circle() - .fill(Color(red: c.r, green: c.g, blue: c.b)) - .frame(width: 5, height: 5) - } - Text(node.name).font(.system(size: 10)).lineLimit(1) - Spacer() - if let snr = node.snr { - Text(String(format: "%.1f", snr)) - .font(.system(size: 10).monospacedDigit()) - } - } - } + TabView { + MapPage() + DebugPage() + SettingsPage() } - } - - private func controls(_ s: WatchSnapshot) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text("start/stop \(s.controls.canStartStop ? "✓" : "✗") · ping \(s.controls.canManualPing ? "✓" : "✗")") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - if let reason = s.controls.blockedReason { - Text(reason).font(.system(size: 10)).foregroundStyle(.secondary) - } - } - } - - private var commandButtons: some View { - VStack(spacing: 4) { - Button(sessionActive ? "Stop" : "Start") { - client.send(sessionActive ? .stopSession : .startSession) - } - .disabled(!(client.snapshot?.controls.canStartStop ?? false)) - - Button("Manual ping") { - client.send(.manualPing) - } - .disabled(!(client.snapshot?.controls.canManualPing ?? false)) - - Button("Refresh") { - client.send(.requestSnapshot) - } - } - .font(.caption2) - .buttonStyle(.bordered) - } - - private var sessionActive: Bool { - client.snapshot?.controls.isSessionActive ?? false + .tabViewStyle(.verticalPage) } } diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift new file mode 100644 index 00000000..e2be8fe0 --- /dev/null +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -0,0 +1,145 @@ +import SwiftUI + +/// Raw state dump and command buttons. +/// +/// Kept from Phase 2 as a development surface while the real UI is built out. +/// Phase 5 replaces the buttons with the proper controls page; this page goes +/// away once the node list and controls carry their own verification. +struct DebugPage: View { + @Environment(WatchSessionClient.self) private var client + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + header + + if client.versionMismatch { + Text("Update MeshMapper on iPhone — wire version mismatch") + .font(.caption2) + .foregroundStyle(.orange) + } + + if let snapshot = client.snapshot { + session(snapshot) + counters(snapshot) + geo(snapshot) + controls(snapshot) + } else { + Text("No snapshot yet") + .font(.caption2) + .foregroundStyle(.secondary) + } + + commandButtons + + if let refusal = client.lastRefusal { + Text(refusal) + .font(.caption2) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 4) + .opacity(client.isStale ? 0.45 : 1.0) + } + } + + private var header: some View { + HStack { + Circle() + .fill(client.isReachable ? .green : .gray) + .frame(width: 6, height: 6) + Text(client.isReachable ? "Reachable" : "Unreachable") + .font(.caption2) + Spacer() + if let receivedAt = client.receivedAt { + Text(receivedAt, style: .relative) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } + + private func session(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(s.phaseTitle).font(.headline).lineLimit(2) + if let detail = s.phaseDetail { + Text(detail).font(.caption2).foregroundStyle(.secondary) + } + HStack(spacing: 4) { + if let color = s.pingColor { + Circle().fill(Color(color)).frame(width: 8, height: 8) + } + Text(s.mode).font(.caption2) + if let endsAt = s.phaseEndsAt, endsAt > Date() { + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.caption.monospacedDigit()) + } + } + } + } + + private func counters(_ s: WatchSnapshot) -> some View { + Text("TX \(s.txCount) · RX \(s.rxCount) · DISC \(s.discoveryCount) · Q \(s.queueSize)") + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + } + + private func geo(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + if let you = s.geo.you { + Text(String(format: "%.5f, %.5f", you.lat, you.lon)) + .font(.system(size: 10).monospacedDigit()) + } else { + Text("No GPS fix").font(.system(size: 10)).foregroundStyle(.secondary) + } + Text("pings \(s.geo.pings.count) · rptrs \(s.geo.repeaters.count) · heard \(s.geo.heard.count)") + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + + ForEach(s.geo.heard) { node in + HStack(spacing: 4) { + if let c = node.snrColor { + Circle().fill(Color(c)).frame(width: 5, height: 5) + } + Text(node.name).font(.system(size: 10)).lineLimit(1) + Spacer() + if let snr = node.snr { + Text(String(format: "%.1f", snr)) + .font(.system(size: 10).monospacedDigit()) + } + } + } + } + } + + private func controls(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text("start/stop \(s.controls.canStartStop ? "✓" : "✗") · ping \(s.controls.canManualPing ? "✓" : "✗")") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + if let reason = s.controls.blockedReason { + Text(reason).font(.system(size: 10)).foregroundStyle(.secondary) + } + } + } + + private var commandButtons: some View { + VStack(spacing: 4) { + Button(sessionActive ? "Stop" : "Start") { + client.send(sessionActive ? .stopSession : .startSession) + } + .disabled(!(client.snapshot?.controls.canStartStop ?? false)) + + Button("Manual ping") { client.send(.manualPing) } + .disabled(!(client.snapshot?.controls.canManualPing ?? false)) + + Button("Refresh") { client.send(.requestSnapshot) } + } + .font(.caption2) + .buttonStyle(.bordered) + } + + private var sessionActive: Bool { + client.snapshot?.controls.isSessionActive ?? false + } +} diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift new file mode 100644 index 00000000..e3529382 --- /dev/null +++ b/ios/MeshMapperWatch/MapPage.swift @@ -0,0 +1,286 @@ +import CoreLocation +import MapKit +import SwiftUI + +/// The map, drawn on Apple's basemap. +/// +/// MeshMapper's own basemap cannot come along: `MKTileOverlay` is +/// `API_UNAVAILABLE(watchos)`, so the OpenFreeMap styles, the ArcGIS satellite +/// raster, and the coverage vector tiles have no route onto the wrist. Only +/// the data layer — ping colours, repeater pins, the fix — is MeshMapper's. +struct MapPage: View { + @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var camera: MapCameraPosition = .automatic + + /// Centre we last drove the camera to, so a camera change can be attributed + /// to the wearer rather than to our own follow updates. + @State private var programmaticCenter: CLLocationCoordinate2D? + @State private var followSuspendedUntil: Date? + + /// Metres of disagreement before a camera change counts as a real pan. + private static let panTolerance: CLLocationDistance = 40 + + /// How long a pan pauses following before the map drifts back to the fix. + private static let resumeFollowAfter: TimeInterval = 8 + + private var snapshot: WatchSnapshot? { client.snapshot } + + private var fix: CLLocationCoordinate2D? { + guard let you = snapshot?.geo.you else { return nil } + return CLLocationCoordinate2D(latitude: you.lat, longitude: you.lon) + } + + private var isFollowing: Bool { + guard settings.follow else { return false } + if let until = followSuspendedUntil, until > Date() { return false } + return true + } + + var body: some View { + ZStack(alignment: .top) { + map + chrome + } + .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in + recenterIfFollowing() + } + .onAppear { recenterIfFollowing() } + } + + // MARK: - Map + + private var map: some View { + Map(position: $camera, interactionModes: [.pan, .zoom]) { + linkLines + pingMarkers + repeaterPins + fixMarker + } + .mapStyle(settings.satellite ? .imagery : .standard) + .onMapCameraChange(frequency: .onEnd) { context in + noteCameraChange(context.region.center) + } + .ignoresSafeArea(edges: .bottom) + } + + @MapContentBuilder + private var linkLines: some MapContent { + if settings.showLinks, let fix, let snapshot { + let linked = Set(snapshot.geo.linkedRepeaterIds) + ForEach(snapshot.geo.repeaters.filter { linked.contains($0.id) }) { repeater in + MapPolyline(coordinates: [ + fix, + CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon), + ]) + .stroke(Color(repeater.color).opacity(0.7), lineWidth: 1.5) + } + } + } + + @MapContentBuilder + private var pingMarkers: some MapContent { + if let snapshot { + ForEach(snapshot.geo.pings) { ping in + Annotation("", coordinate: CLLocationCoordinate2D(latitude: ping.lat, longitude: ping.lon)) { + // Squares, matching the iOS map's ping markers. + Rectangle() + .fill(Color(ping.color)) + .frame(width: 5, height: 5) + } + .annotationTitles(.hidden) + } + } + } + + @MapContentBuilder + private var repeaterPins: some MapContent { + if let snapshot { + ForEach(snapshot.geo.repeaters) { repeater in + Annotation("", coordinate: CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon)) { + RepeaterPin(color: Color(repeater.color), highlighted: repeater.heardThisCycle) + } + .annotationTitles(.hidden) + } + } + } + + @MapContentBuilder + private var fixMarker: some MapContent { + if let fix, let you = snapshot?.geo.you { + Annotation("", coordinate: fix) { + // The phone's fix, not the watch's. Rendering it ourselves keeps the + // watch free of any location permission. + FixPuck(headingDeg: you.headingDeg) + } + .annotationTitles(.hidden) + } + } + + // MARK: - Chrome + + private var chrome: some View { + HStack(alignment: .top) { + if let snapshot { + CountdownPill(snapshot: snapshot) + } + Spacer() + if !isFollowing, fix != nil { + Button { + followSuspendedUntil = nil + recenterIfFollowing(force: true) + } label: { + Image(systemName: "location.fill") + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + .padding(4) + .background(.black.opacity(0.5), in: Circle()) + } + } + .padding(.horizontal, 6) + .opacity(client.isStale ? 0.5 : 1.0) + } + + // MARK: - Camera + + private func recenterIfFollowing(force: Bool = false) { + guard force || isFollowing, let fix else { return } + programmaticCenter = fix + withAnimation(.easeInOut(duration: 0.25)) { + camera = .region( + MKCoordinateRegion( + center: fix, + span: currentSpan + ) + ) + } + } + + /// Preserve whatever zoom the wearer picked with the Digital Crown. + /// + /// The initial span is deliberately wide (~3 km): wardriving is about what + /// is around you, and a tighter default opens with every nearby repeater + /// off-screen. + @State private var currentSpan = MKCoordinateSpan( + latitudeDelta: 0.03, + longitudeDelta: 0.03 + ) + + private func noteCameraChange(_ center: CLLocationCoordinate2D) { + guard let expected = programmaticCenter else { + // We have never driven the camera, so this is `.automatic` settling on + // launch rather than a pan. Treating it as one would suspend following + // before the first fix even arrives. + return + } + guard distance(center, expected) > Self.panTolerance else { + // Our own follow update landing. + return + } + + // The wearer moved the map. Stop fighting them, and drift back shortly. + programmaticCenter = center + let deadline = Date().addingTimeInterval(Self.resumeFollowAfter) + followSuspendedUntil = deadline + scheduleFollowResume(at: deadline) + } + + /// Re-evaluate when the suspension lapses. + /// + /// `followSuspendedUntil` is only read during a render, and a stationary + /// phone sends no updates to trigger one — without this the map would stay + /// unfollowed indefinitely after a single pan. + private func scheduleFollowResume(at deadline: Date) { + resumeTask?.cancel() + resumeTask = Task { @MainActor in + let seconds = deadline.timeIntervalSinceNow + if seconds > 0 { + try? await Task.sleep(for: .seconds(seconds)) + } + guard !Task.isCancelled, followSuspendedUntil == deadline else { return } + followSuspendedUntil = nil + recenterIfFollowing() + } + } + + @State private var resumeTask: Task? + + private func distance( + _ a: CLLocationCoordinate2D, + _ b: CLLocationCoordinate2D + ) -> CLLocationDistance { + CLLocation(latitude: a.latitude, longitude: a.longitude) + .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude)) + } +} + +// MARK: - Markers + +private struct FixPuck: View { + let headingDeg: Double? + + var body: some View { + ZStack { + Circle() + .fill(.blue) + .frame(width: 10, height: 10) + Circle() + .stroke(.white, lineWidth: 1.5) + .frame(width: 10, height: 10) + if let headingDeg { + Image(systemName: "location.north.fill") + .font(.system(size: 7)) + .foregroundStyle(.white) + .offset(y: -9) + .rotationEffect(.degrees(headingDeg)) + } + } + } +} + +private struct RepeaterPin: View { + let color: Color + let highlighted: Bool + + var body: some View { + ZStack { + if highlighted { + Circle() + .stroke(color, lineWidth: 1.5) + .frame(width: 14, height: 14) + } + Circle() + .fill(color) + .frame(width: 7, height: 7) + .overlay(Circle().stroke(.black.opacity(0.6), lineWidth: 0.5)) + } + } +} + +private struct CountdownPill: View { + let snapshot: WatchSnapshot + + var body: some View { + HStack(spacing: 3) { + if let color = snapshot.pingColor { + Circle() + .fill(Color(color)) + .frame(width: 6, height: 6) + } + // Absolute deadline rendered by the system — no per-second traffic. + if let endsAt = snapshot.phaseEndsAt, endsAt > Date() { + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.system(size: 12, weight: .medium).monospacedDigit()) + } else { + Text(snapshot.phaseTitle) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.black.opacity(0.55), in: Capsule()) + } +} diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 9fbce36e..56bf5395 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -11,11 +11,13 @@ import SwiftUI @main struct MeshMapperWatchApp: App { @State private var client = WatchSessionClient() + @State private var settings = WatchSettings() var body: some Scene { WindowGroup { ContentView() .environment(client) + .environment(settings) .onAppear { client.refresh() } } } diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift new file mode 100644 index 00000000..711dc32c --- /dev/null +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -0,0 +1,125 @@ +#if DEBUG +import Foundation + +/// Synthetic state for design work and headless verification. +/// +/// The simulator has no Bluetooth, so a paired simulator pair can never +/// produce pings or repeaters — the map would always be empty there. Enable +/// with a launch argument: +/// +/// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES +/// +/// DEBUG-only, and never reached unless that argument is passed, so it cannot +/// leak into a shipping build or mask a real transport failure. +enum SampleSnapshot { + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: "MeshMapperSampleData") + } + + /// Downtown Seattle, roughly — somewhere with enough spread to see marker + /// density and line geometry at a realistic zoom. + private static let originLat = 47.6062 + private static let originLon = -122.3321 + + static func make() -> WatchSnapshot { + let now = Date().timeIntervalSince1970 * 1000 + + let green = WatchColor(r: 0.30, g: 0.69, b: 0.31) + let red = WatchColor(r: 0.96, g: 0.26, b: 0.21) + let purple = WatchColor(r: 0.49, g: 0.33, b: 0.78) + let pink = WatchColor(r: 0.84, g: 0.20, b: 0.52) + let orange = WatchColor(r: 0.99, g: 0.49, b: 0.08) + + // A drive west-to-east with a mix of answered and unanswered pings. + let pings: [WatchPing] = (0..<40).map { i in + let answered = i % 3 != 0 + return WatchPing( + id: "p\(i)", + lat: originLat + Double(i) * 0.00035 + (i % 2 == 0 ? 0.0002 : -0.0002), + lon: originLon + Double(i) * 0.00085, + kind: i % 5 == 0 ? "rx" : "tx", + color: i % 5 == 0 ? purple : (answered ? green : red), + atMs: now - Double(40 - i) * 20_000 + ) + } + + let repeaters: [WatchRepeater] = [ + ("4E", "Capitol Hill", 0.010, 0.004, pink, true), + ("77", "Queen Anne", -0.006, 0.012, pink, true), + ("A2", "Beacon Hill", -0.012, -0.008, orange, false), + ("B9", "Magnolia", 0.004, -0.015, pink, false), + ].map { id, name, dLat, dLon, color, heard in + WatchRepeater( + id: id, + name: name, + lat: originLat + dLat, + lon: originLon + dLon, + color: color, + heardThisCycle: heard + ) + } + + let heard: [WatchHeardNode] = [ + ("4E", "Capitol Hill", 8.5, -71, nil, 3, 1420.0), + ("77", "Queen Anne", 2.25, -94, 1, 2, 2310.0), + ("A2", "Beacon Hill", -4.0, -112, 2, 1, 3050.0), + ].map { id, name, snr, rssi, hops, seen, distance in + WatchHeardNode( + id: id, + name: name, + snr: snr, + rssi: rssi, + hops: hops, + seenCount: seen, + atMs: now - 30_000, + distanceM: distance, + snrColor: snr > 5 + ? WatchColor(r: 0.30, g: 0.69, b: 0.31) + : (snr > -1 + ? WatchColor(r: 1.0, g: 0.60, b: 0.0) + : WatchColor(r: 0.96, g: 0.26, b: 0.21)) + ) + } + + return WatchSnapshot( + wireVersion: MeshMapperWatchWire.version, + sessionId: "sample", + mode: "Active", + phase: "listening", + phaseTitle: "Listening", + phaseDetail: "Waiting for echoes", + phaseEndsAtMs: now + 42_000, + isConnected: true, + zoneCode: "SEA", + txCount: 27, + rxCount: 14, + discoveryCount: 6, + traceCount: 0, + queueSize: 2, + pingColor: green, + geo: WatchGeo( + you: WatchPosition( + lat: originLat + 0.006, + lon: originLon + 0.014, + headingDeg: 72, + accuracyM: 8, + fixedAtMs: now + ), + pings: pings, + repeaters: repeaters, + heard: heard, + linkedRepeaterIds: ["4E", "77"] + ), + controls: WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ), + cue: nil, + updatedAtMs: now + ) + } +} +#endif diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift new file mode 100644 index 00000000..99b0d235 --- /dev/null +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Wrist-side layout and map preferences. +/// +/// Deliberately small. Anything about the session itself belongs on the phone, +/// which owns the radio and the guards. +struct SettingsPage: View { + @Environment(WatchSettings.self) private var settings + + var body: some View { + @Bindable var settings = settings + + List { + Section("Map") { + Toggle("Satellite", isOn: $settings.satellite) + Toggle("Follow position", isOn: $settings.follow) + Toggle("Lines to repeaters", isOn: $settings.showLinks) + } + + Section("Layout") { + Picker("Node list", selection: $settings.nodeListPlacement) { + ForEach(WatchSettings.NodeListPlacement.allCases) { placement in + Text(placement.label).tag(placement) + } + } + } + } + .font(.caption) + } +} diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 82a7a9f1..03268bca 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -42,6 +42,14 @@ final class WatchSessionClient: NSObject { /// Activation is asynchronous, so a refresh requested before it completes is /// deferred to the activation callback rather than failing as "unreachable". func refresh() { + #if DEBUG + if SampleSnapshot.isEnabled { + snapshot = SampleSnapshot.make() + receivedAt = Date() + return + } + #endif + guard let session else { return } session.delegate = self diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift new file mode 100644 index 00000000..6c66a1a5 --- /dev/null +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -0,0 +1,77 @@ +import Foundation +import SwiftUI + +/// Wrist-side preferences. +/// +/// These are watch-local on purpose: they describe how this small screen is +/// laid out, not anything about the mapping session, so syncing them from the +/// phone would add a round-trip for no benefit. +@Observable +final class WatchSettings { + private enum Key { + static let satellite = "map.satellite" + static let showLinks = "map.showLinks" + static let follow = "map.follow" + static let nodeListPlacement = "layout.nodeListPlacement" + } + + /// Where the recently-responded list lives. + /// + /// Both layouts are built from one view model, so this is a presentation + /// toggle rather than two code paths. The default is unsettled until it has + /// been worn — see the plan. + enum NodeListPlacement: String, CaseIterable, Identifiable { + case sheet + case page + + var id: String { rawValue } + + var label: String { + switch self { + case .sheet: return "Sheet over map" + case .page: return "Its own page" + } + } + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + satellite = defaults.bool(forKey: Key.satellite) + showLinks = defaults.bool(forKey: Key.showLinks) + // Following the fix is the useful default while driving; absent any + // stored value `bool(forKey:)` returns false, so invert an explicit flag. + follow = defaults.object(forKey: Key.follow) as? Bool ?? true + nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) + .flatMap(NodeListPlacement.init(rawValue:)) ?? .sheet + } + + /// Apple imagery rather than the standard basemap. Mirrors the iOS app's + /// satellite option, though the imagery is Apple's, not ArcGIS. + var satellite: Bool { + didSet { defaults.set(satellite, forKey: Key.satellite) } + } + + /// Draw a line from the fix to each repeater that answered the last ping. + var showLinks: Bool { + didSet { defaults.set(showLinks, forKey: Key.showLinks) } + } + + /// Keep the camera on the phone's position. + var follow: Bool { + didSet { defaults.set(follow, forKey: Key.follow) } + } + + var nodeListPlacement: NodeListPlacement { + didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } + } +} + +extension Color { + /// Colours arrive already resolved from the phone's colour-vision palette, + /// so the watch never needs to know which palette is active. + init(_ watchColor: WatchColor) { + self.init(red: watchColor.r, green: watchColor.g, blue: watchColor.b) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e0ec83f3..6e4fa6b6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,12 +7,17 @@ objects = { /* Begin PBXBuildFile section */ + 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84205AF3825B4E2EF987B27E /* SettingsPage.swift */; }; 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */; }; + 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8523E9AE78A9549CE601F697 /* WatchSettings.swift */; }; + 57C05889BAC05567849ED416 /* MapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA64785988022009D0587B1 /* MapPage.swift */; }; 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */; }; + 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A44B241BC20153F36C053D /* SampleSnapshot.swift */; }; 73F9D344DD4B7EC7AC29CD86 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */; }; @@ -94,6 +99,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 10A44B241BC20153F36C053D /* SampleSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleSnapshot.swift; sourceTree = ""; }; 111B35B32FAB66FA2E78E0BE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; @@ -101,6 +107,7 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3EA64785988022009D0587B1 /* MapPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapPage.swift; sourceTree = ""; }; 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; }; 6316620B3FF7A48DF8F886CA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -111,6 +118,8 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 84205AF3825B4E2EF987B27E /* SettingsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; }; + 8523E9AE78A9549CE601F697 /* WatchSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSettings.swift; sourceTree = ""; }; 86A4D8E4F5F13D005DA717B4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchApp.swift; sourceTree = ""; }; 8BA04B67488852DCDA49C863 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; @@ -123,6 +132,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DebugPage.swift; sourceTree = ""; }; A20000000000000000000001 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; A20000000000000000000002 /* MeshMapperActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperActivityAttributes.swift; sourceTree = ""; }; A20000000000000000000003 /* MeshMapperLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivity.swift; sourceTree = ""; }; @@ -190,6 +200,11 @@ 111B35B32FAB66FA2E78E0BE /* Info.plist */, 6CC8647C002484845F02D0CE /* Assets.xcassets */, 178C77B846A00943CD881203 /* WatchSessionClient.swift */, + 3EA64785988022009D0587B1 /* MapPage.swift */, + 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */, + 84205AF3825B4E2EF987B27E /* SettingsPage.swift */, + 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, + 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -620,6 +635,11 @@ 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */, B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */, A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */, + 57C05889BAC05567849ED416 /* MapPage.swift in Sources */, + 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */, + 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */, + 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */, + 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 51332e2b386280c9fec30f99724f474ca4c2a58b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 18:10:27 -0700 Subject: [PATCH 175/360] Add heard-node list in both placements, plus stale badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Apple Watch companion. NodeListView shows the repeaters that answered the most recent ping — SNR-coloured dot, name, SNR, and a context line of hop count and distance — with a detail view carrying RSSI, seen count and last-heard time. Both placements read the same view, so this is a presentation toggle rather than two implementations: - sheet: the map keeps a tappable summary bar showing the strongest node inline, opening the full list over the map. - page: the map stays clean full-bleed and the list gets its own tab. The bar is a tap, not the swipe-up originally planned: on watchOS a swipe from the bottom edge is the Control Center gesture, so it would fight the system. Surfacing the strongest node inline also answers the common question — "what just answered?" — with no interaction at all. Rows use standard text styles and never shrink type to hit a row count. The payload carries up to 7; the list renders what fits at the wearer's text size and scrolls for the rest. The map chrome gains a stale badge, so data that has stopped updating can never read as live. Two DEBUG-only launch arguments (-MeshMapperShowNodeSheet, -MeshMapperInitialPage) make specific screens capturable headlessly; the simulator offers no way to tap or swipe. Verified excluded from Release. --- ios/MeshMapperWatch/ContentView.swift | 36 ++++-- ios/MeshMapperWatch/MapPage.swift | 92 ++++++++++++++- ios/MeshMapperWatch/NodeListView.swift | 151 +++++++++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 4 + 4 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 ios/MeshMapperWatch/NodeListView.swift diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 1a0552a9..20b9e5a5 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -2,18 +2,38 @@ import SwiftUI /// Root shell. /// -/// The map is always page one and full-bleed. Phase 4 adds the node list — -/// either as a sheet over this map or as its own page, per -/// `WatchSettings.nodeListPlacement` — and replaces the debug page. +/// The map is always page one and full-bleed. Where the heard-node list lives +/// is a presentation choice — a sheet over the map, or its own page — driven by +/// `WatchSettings.nodeListPlacement`. Both read the same `NodeListView`, so +/// this is a toggle rather than two implementations. struct ContentView: View { - @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var selection = 0 var body: some View { - TabView { - MapPage() - DebugPage() - SettingsPage() + TabView(selection: $selection) { + MapPage().tag(0) + + if settings.nodeListPlacement == .page { + NavigationStack { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + } + .tag(1) + } + + DebugPage().tag(2) + SettingsPage().tag(3) } .tabViewStyle(.verticalPage) + .onAppear { + #if DEBUG + // Lets a specific page be captured headlessly for design review. + let requested = UserDefaults.standard.integer(forKey: "MeshMapperInitialPage") + if requested > 0 { selection = requested } + #endif + } } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index e3529382..0b0a2c07 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -38,15 +38,88 @@ struct MapPage: View { return true } + @State private var showingNodes = false + var body: some View { ZStack(alignment: .top) { map chrome + if settings.nodeListPlacement == .sheet { + VStack { + Spacer() + nodeSummaryBar + } + } + } + .sheet(isPresented: $showingNodes) { + NavigationStack { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + } } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in recenterIfFollowing() } - .onAppear { recenterIfFollowing() } + .onAppear { + recenterIfFollowing() + #if DEBUG + // Lets the sheet layout be captured and iterated on headlessly; the + // simulator has no way to tap the bar. + if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { + showingNodes = true + } + #endif + } + } + + /// Tap target for the node sheet. + /// + /// A tap rather than the swipe-up the plan first assumed: on watchOS a swipe + /// from the bottom edge is the Control Center gesture, so it would fight the + /// system. Surfacing the strongest node inline also means the common case — + /// "what just answered?" — needs no interaction at all. + private var nodeSummaryBar: some View { + Button { + showingNodes = true + } label: { + HStack(spacing: 5) { + if let top = snapshot?.geo.heard.first { + if let color = top.snrColor { + Circle().fill(Color(color)).frame(width: 6, height: 6) + } + Text(top.name) + .font(.caption2) + .lineLimit(1) + if let snr = top.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.caption2.monospacedDigit()) + } + Spacer(minLength: 2) + let extra = (snapshot?.geo.heard.count ?? 0) - 1 + if extra > 0 { + Text("+\(extra)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else { + Text("Nothing heard") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 2) + } + Image(systemName: "chevron.up") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.black.opacity(0.6), in: Capsule()) + } + .buttonStyle(.plain) + .padding(.horizontal, 6) + .padding(.bottom, 2) + .opacity(client.isStale ? 0.5 : 1.0) } // MARK: - Map @@ -123,7 +196,22 @@ struct MapPage: View { private var chrome: some View { HStack(alignment: .top) { if let snapshot { - CountdownPill(snapshot: snapshot) + VStack(alignment: .leading, spacing: 2) { + CountdownPill(snapshot: snapshot) + if client.isStale, let receivedAt = client.receivedAt { + // Stale data must never read as live data. + HStack(spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 7)) + Text(receivedAt, style: .relative) + .font(.system(size: 9)) + } + .foregroundStyle(.orange) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.black.opacity(0.6), in: Capsule()) + } + } } Spacer() if !isFollowing, fix != nil { diff --git a/ios/MeshMapperWatch/NodeListView.swift b/ios/MeshMapperWatch/NodeListView.swift new file mode 100644 index 00000000..7700fbc3 --- /dev/null +++ b/ios/MeshMapperWatch/NodeListView.swift @@ -0,0 +1,151 @@ +import SwiftUI + +/// Repeaters that answered the most recent ping, strongest first. +/// +/// The payload carries up to `MeshMapperWatchWire.maxHeard` rows, but this view +/// never shrinks type to fit them. It uses standard text styles so the wearer's +/// watch text-size setting is honoured and scrolls for whatever doesn't fit — +/// at default size that lands around 5–7 rows on a 45 mm, and at large +/// accessibility sizes it may be 3. That is the correct outcome, not a bug. +struct NodeListView: View { + @Environment(WatchSessionClient.self) private var client + + private var nodes: [WatchHeardNode] { client.snapshot?.geo.heard ?? [] } + + var body: some View { + Group { + if nodes.isEmpty { + emptyState + } else { + List { + ForEach(nodes) { node in + NavigationLink { + NodeDetailView(node: node) + } label: { + NodeRow(node: node) + } + } + } + .listStyle(.carousel) + } + } + .opacity(client.isStale ? 0.5 : 1.0) + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.title3) + .foregroundStyle(.secondary) + Text("Nothing heard yet") + .font(.footnote) + .foregroundStyle(.secondary) + } + } +} + +/// One repeater. +/// +/// Two lines: identity and signal on top, context underneath. The SNR dot +/// repeats the colour information as a number so the row still reads under a +/// colour-vision palette or in bright sun. +struct NodeRow: View { + let node: WatchHeardNode + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + if let color = node.snrColor { + Circle() + .fill(Color(color)) + .frame(width: 7, height: 7) + } + Text(node.name) + .font(.body) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.body.monospacedDigit()) + } + } + + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 1) + } + + private var subtitle: String { + var parts: [String] = [node.hopLabel] + if let distance = node.distanceLabel { parts.append(distance) } + return parts.joined(separator: " · ") + } +} + +struct NodeDetailView: View { + let node: WatchHeardNode + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + if let color = node.snrColor { + Circle().fill(Color(color)).frame(width: 9, height: 9) + } + Text(node.name) + .font(.headline) + .lineLimit(2) + } + + detail("ID", node.id) + if let snr = node.snr { + detail("SNR", snr.formatted(.number.precision(.fractionLength(1))) + " dB") + } + if let rssi = node.rssi { + detail("RSSI", "\(rssi) dBm") + } + detail("Path", node.hopLabel) + detail("Seen", node.seenCount == 1 ? "once" : "\(node.seenCount)×") + if let distance = node.distanceLabel { + detail("Distance", distance) + } + detail("Heard", node.heardAt.formatted(date: .omitted, time: .shortened)) + } + .padding(.horizontal, 4) + } + } + + private func detail(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + Text(value) + .font(.caption) + .multilineTextAlignment(.trailing) + } + } +} + +extension WatchHeardNode { + var heardAt: Date { Date(timeIntervalSince1970: atMs / 1000) } + + /// `nil` hops means a direct echo — the distinction the iOS map draws too. + var hopLabel: String { + guard let hops else { return "direct" } + return hops == 1 ? "1 hop" : "\(hops) hops" + } + + var distanceLabel: String? { + guard let distanceM else { return nil } + if distanceM < 1000 { + return "\(Int(distanceM.rounded())) m" + } + return (distanceM / 1000).formatted(.number.precision(.fractionLength(1))) + " km" + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6e4fa6b6..5aa3494a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B3F6C0618B4BD5644B40B /* NodeListView.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84205AF3825B4E2EF987B27E /* SettingsPage.swift */; }; @@ -116,6 +117,7 @@ 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeshMapperWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 775B3F6C0618B4BD5644B40B /* NodeListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NodeListView.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 84205AF3825B4E2EF987B27E /* SettingsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; }; @@ -205,6 +207,7 @@ 84205AF3825B4E2EF987B27E /* SettingsPage.swift */, 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, + 775B3F6C0618B4BD5644B40B /* NodeListView.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -640,6 +643,7 @@ 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */, 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */, 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */, + 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 73c6c030c67c07d755732bf0da3f261215a9f5e9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 18:31:31 -0700 Subject: [PATCH 176/360] Mirror the app's Top Heard overlay on the watch map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the heard-node model to match what the phone's map actually shows. Wire version 2. The watch was inventing its own idea of "recently responded" from TxPing.heardRepeaters — names, hop counts, RSSI, seen counts. The app's map overlay (_buildTopRepeatersOverlay) shows something different and simpler: up to three rows from the latest ping plus the RX slot, each a dot coloured by which ping type was answered, the hex path hash, and the SNR. Three corrections: - Hex ID is the identity, not the name. Path hashes are 1-3 bytes, so a short ID often maps to several repeaters. Names are resolved only through indexByHexPrefix, which drops any prefix owned by more than one repeater -- a confidently wrong name is worse than none. The watch always shows the hex and treats a name as a secondary hint. - No hop counts anywhere. _updateTopRepeaters is fed directRepeaters and multiHopEvents are explicitly excluded, so multi-hop was never part of this surface. - The RX slot trails the top three rather than competing on SNR, matching the overlay's distinct trailing row. Layout follows the phone: Top Heard hard against the upper-left corner (drawing into the top safe area, which is free because the system clock is right-aligned), countdown pill bottom-right. The bottom summary bar is gone, superseded by the box. Colours come from the same OverlayPingType mapping the map uses, resolved on the phone, so colour-vision palettes carry across unchanged. --- ios/MeshMapperWatch/DebugPage.swift | 11 +- ios/MeshMapperWatch/MapPage.swift | 167 +++++++++--------- ios/MeshMapperWatch/NodeListView.swift | 67 +++---- ios/MeshMapperWatch/SampleSnapshot.swift | 19 +- ios/Shared/MeshMapperWatchPayload.swift | 31 +++- lib/providers/app_state_provider.dart | 47 +++-- lib/services/watch/watch_geo_builder.dart | 134 ++++++++++---- lib/services/watch/watch_models.dart | 48 +++-- .../watch/watch_geo_builder_test.dart | 111 ++++++++---- 9 files changed, 393 insertions(+), 242 deletions(-) diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift index e2be8fe0..d48957aa 100644 --- a/ios/MeshMapperWatch/DebugPage.swift +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -98,10 +98,15 @@ struct DebugPage: View { ForEach(s.geo.heard) { node in HStack(spacing: 4) { - if let c = node.snrColor { - Circle().fill(Color(c)).frame(width: 5, height: 5) + Circle().fill(Color(node.typeColor)).frame(width: 5, height: 5) + Text(node.id) + .font(.system(size: 10, design: .monospaced)) + if let name = node.name { + Text(name) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) } - Text(node.name).font(.system(size: 10)).lineLimit(1) Spacer() if let snr = node.snr { Text(String(format: "%.1f", snr)) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 0b0a2c07..df466dbe 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -41,15 +41,33 @@ struct MapPage: View { @State private var showingNodes = false var body: some View { - ZStack(alignment: .top) { + ZStack { map - chrome - if settings.nodeListPlacement == .sheet { - VStack { - Spacer() - nodeSummaryBar + + // Top Heard sits hard against the upper-left and the countdown against + // the lower-right, mirroring the phone's map so the two read the same + // way at a glance. + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + topHeardBox + Spacer(minLength: 0) + recenterButton + } + Spacer(minLength: 0) + HStack(alignment: .bottom) { + staleBadge + Spacer(minLength: 0) + if let snapshot { + CountdownPill(snapshot: snapshot) + } } } + .padding(.horizontal, 5) + .padding(.bottom, 3) + // Draw into the top safe area so Top Heard sits hard against the corner. + // Safe because the box is left-aligned and the system clock is right- + // aligned, so they occupy different halves of that strip. + .ignoresSafeArea(edges: .top) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -73,55 +91,69 @@ struct MapPage: View { } } - /// Tap target for the node sheet. + /// "Top Heard" — the phone's map overlay, reproduced on the wrist. /// - /// A tap rather than the swipe-up the plan first assumed: on watchOS a swipe - /// from the bottom edge is the Control Center gesture, so it would fight the - /// system. Surfacing the strongest node inline also means the common case — - /// "what just answered?" — needs no interaction at all. - private var nodeSummaryBar: some View { + /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity, + /// because a 1-byte hash frequently cannot be resolved to a single repeater; + /// a name is appended only when the phone could resolve it unambiguously. + private var topHeardBox: some View { Button { showingNodes = true } label: { - HStack(spacing: 5) { - if let top = snapshot?.geo.heard.first { - if let color = top.snrColor { - Circle().fill(Color(color)).frame(width: 6, height: 6) - } - Text(top.name) - .font(.caption2) - .lineLimit(1) - if let snr = top.snr { - Text(snr, format: .number.precision(.fractionLength(1))) - .font(.caption2.monospacedDigit()) - } - Spacer(minLength: 2) - let extra = (snapshot?.geo.heard.count ?? 0) - 1 - if extra > 0 { - Text("+\(extra)") - .font(.caption2) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 2) { + Text("TOP HEARD") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(.white.opacity(0.55)) + .kerning(0.5) + + if heard.isEmpty { + Text("---") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.white.opacity(0.4)) } else { - Text("Nothing heard") - .font(.caption2) - .foregroundStyle(.secondary) - Spacer(minLength: 2) + ForEach(heard) { node in + HStack(spacing: 4) { + Circle() + .fill(Color(node.typeColor)) + .frame(width: 6, height: 6) + Text(node.id) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(node.snrColor.map(Color.init) ?? .white) + } + } + } } - Image(systemName: "chevron.up") - .font(.system(size: 8)) - .foregroundStyle(.secondary) } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.black.opacity(0.6), in: Capsule()) + .padding(.horizontal, 7) + .padding(.vertical, 5) + .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - .padding(.horizontal, 6) - .padding(.bottom, 2) .opacity(client.isStale ? 0.5 : 1.0) } + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } + + @ViewBuilder + private var recenterButton: some View { + if !isFollowing, fix != nil { + Button { + followSuspendedUntil = nil + recenterIfFollowing(force: true) + } label: { + Image(systemName: "location.fill") + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + .padding(4) + .background(.black.opacity(0.5), in: Circle()) + } + } + // MARK: - Map private var map: some View { @@ -191,44 +223,21 @@ struct MapPage: View { } } - // MARK: - Chrome - - private var chrome: some View { - HStack(alignment: .top) { - if let snapshot { - VStack(alignment: .leading, spacing: 2) { - CountdownPill(snapshot: snapshot) - if client.isStale, let receivedAt = client.receivedAt { - // Stale data must never read as live data. - HStack(spacing: 2) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 7)) - Text(receivedAt, style: .relative) - .font(.system(size: 9)) - } - .foregroundStyle(.orange) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(.black.opacity(0.6), in: Capsule()) - } - } - } - Spacer() - if !isFollowing, fix != nil { - Button { - followSuspendedUntil = nil - recenterIfFollowing(force: true) - } label: { - Image(systemName: "location.fill") - .font(.system(size: 10)) - } - .buttonStyle(.borderless) - .padding(4) - .background(.black.opacity(0.5), in: Circle()) + /// Stale data must never read as live data. + @ViewBuilder + private var staleBadge: some View { + if client.isStale, let receivedAt = client.receivedAt { + HStack(spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 7)) + Text(receivedAt, style: .relative) + .font(.system(size: 9)) } + .foregroundStyle(.orange) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.black.opacity(0.65), in: Capsule()) } - .padding(.horizontal, 6) - .opacity(client.isStale ? 0.5 : 1.0) } // MARK: - Camera diff --git a/ios/MeshMapperWatch/NodeListView.swift b/ios/MeshMapperWatch/NodeListView.swift index 7700fbc3..d1529806 100644 --- a/ios/MeshMapperWatch/NodeListView.swift +++ b/ios/MeshMapperWatch/NodeListView.swift @@ -55,34 +55,39 @@ struct NodeRow: View { var body: some View { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 5) { - if let color = node.snrColor { - Circle() - .fill(Color(color)) - .frame(width: 7, height: 7) - } - Text(node.name) - .font(.body) - .lineLimit(1) - .truncationMode(.tail) + // Dot carries the ping type, matching the map overlay. + Circle() + .fill(Color(node.typeColor)) + .frame(width: 7, height: 7) + // The hex path hash is the identity — it is always available and + // always unambiguous, which a resolved name is not. + Text(node.id) + .font(.body.monospaced()) Spacer(minLength: 4) if let snr = node.snr { Text(snr, format: .number.precision(.fractionLength(1))) .font(.body.monospacedDigit()) + .foregroundStyle(node.snrColor.map(Color.init) ?? .primary) } } - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } } .padding(.vertical, 1) } - private var subtitle: String { - var parts: [String] = [node.hopLabel] + /// Name and distance are both optional: a short path hash may match several + /// repeaters, and a matched repeater may not have published a location. + private var subtitle: String? { + var parts: [String] = [] + if let name = node.name { parts.append(name) } if let distance = node.distanceLabel { parts.append(distance) } - return parts.joined(separator: " · ") + return parts.isEmpty ? nil : parts.joined(separator: " · ") } } @@ -93,23 +98,25 @@ struct NodeDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 5) { - if let color = node.snrColor { - Circle().fill(Color(color)).frame(width: 9, height: 9) - } - Text(node.name) - .font(.headline) + Circle().fill(Color(node.typeColor)).frame(width: 9, height: 9) + Text(node.id) + .font(.headline.monospaced()) + } + + if let name = node.name { + Text(name) + .font(.caption) + .foregroundStyle(.secondary) .lineLimit(2) + } else { + Text("Name unresolved — short path hash") + .font(.caption2) + .foregroundStyle(.secondary) } - detail("ID", node.id) if let snr = node.snr { detail("SNR", snr.formatted(.number.precision(.fractionLength(1))) + " dB") } - if let rssi = node.rssi { - detail("RSSI", "\(rssi) dBm") - } - detail("Path", node.hopLabel) - detail("Seen", node.seenCount == 1 ? "once" : "\(node.seenCount)×") if let distance = node.distanceLabel { detail("Distance", distance) } @@ -135,12 +142,6 @@ struct NodeDetailView: View { extension WatchHeardNode { var heardAt: Date { Date(timeIntervalSince1970: atMs / 1000) } - /// `nil` hops means a direct echo — the distinction the iOS map draws too. - var hopLabel: String { - guard let hops else { return "direct" } - return hops == 1 ? "1 hop" : "\(hops) hops" - } - var distanceLabel: String? { guard let distanceM else { return nil } if distanceM < 1000 { diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 711dc32c..c6fa2aa3 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -59,25 +59,28 @@ enum SampleSnapshot { ) } + // Mirrors the phone's "Top Heard": three rows from the latest ping, then + // the RX slot in purple. Names are deliberately mixed — a 4-char hash + // resolves, a 2-char one often cannot. + let teal = WatchColor(r: 0.32, g: 0.83, b: 0.91) let heard: [WatchHeardNode] = [ - ("4E", "Capitol Hill", 8.5, -71, nil, 3, 1420.0), - ("77", "Queen Anne", 2.25, -94, 1, 2, 2310.0), - ("A2", "Beacon Hill", -4.0, -112, 2, 1, 3050.0), - ].map { id, name, snr, rssi, hops, seen, distance in + ("4E5D", "Capitol Hill", 8.5, 1420.0, green), + ("77A1", nil, 2.25, 2310.0, teal), + ("A2", nil, -4.0, nil, green), + ("B914", "Magnolia", 5.75, 2870.0, purple), + ].map { id, name, snr, distance, typeColor in WatchHeardNode( id: id, name: name, snr: snr, - rssi: rssi, - hops: hops, - seenCount: seen, atMs: now - 30_000, distanceM: distance, snrColor: snr > 5 ? WatchColor(r: 0.30, g: 0.69, b: 0.31) : (snr > -1 ? WatchColor(r: 1.0, g: 0.60, b: 0.0) - : WatchColor(r: 0.96, g: 0.26, b: 0.21)) + : WatchColor(r: 0.96, g: 0.26, b: 0.21)), + typeColor: typeColor ) } diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 850b3cfb..6ae7d4d2 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -20,12 +20,18 @@ import Foundation enum MeshMapperWatchWire { /// Bump when a field changes meaning or is removed. The receiver refuses /// payloads it doesn't understand rather than rendering something wrong. - static let version = 1 + /// + /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and + /// ping-type colour — instead of richer per-echo data. Hop counts are gone: + /// the overlay is fed direct repeaters only. + static let version = 2 /// Caps, mirrored in Dart. Enforced on send *and* validated on receive. static let maxPings = 60 static let maxRepeaters = 20 - static let maxHeard = 7 + + /// Three top-SNR rows plus the RX slot. + static let maxHeard = 4 } // MARK: - Colour @@ -70,19 +76,28 @@ struct WatchRepeater: Codable, Hashable, Identifiable { let heardThisCycle: Bool } -/// A row in the "recently responded" panel. +/// One row of the "Top Heard" overlay. +/// +/// Mirrors `_buildTopRepeatersOverlay` on the phone's map: a dot coloured by +/// the kind of ping answered, the hex path-hash ID, and the SNR. +/// +/// The **hex ID is the identity**. Path hashes are 1–3 bytes, so a short ID +/// often maps to more than one repeater; `name` arrives only when the match is +/// unambiguous and is shown as a secondary hint, never in place of the ID. +/// +/// No hop count by design — the overlay is fed direct repeaters only. struct WatchHeardNode: Codable, Hashable, Identifiable { + /// Uppercase hex, 2/4/6 characters depending on the zone's hop bytes. let id: String - let name: String + let name: String? let snr: Double? - let rssi: Int? - /// nil = direct echo; otherwise the number of hops. - let hops: Int? - let seenCount: Int let atMs: Double let distanceM: Double? /// SNR traffic-light colour, resolved by Dart. let snrColor: WatchColor? + /// Ping type answered: green flood/active, teal discovery, cyan trace, + /// purple most-recent RX. + let typeColor: WatchColor } struct WatchGeo: Codable, Hashable { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d08e8a78..d81e7ded 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1253,28 +1253,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final position = _resolveWatchPosition(); // Repeaters heard during the current cycle get the highlight ring. - final heardIds = _liveActivityRepeaters + final heardIds = _topRepeatersOverlay .map((r) => r.repeaterId.toUpperCase()) .toSet(); - // "Recently responded" is the newest TX ping that actually got answers. - final answered = _txPings.lastWhere( - (p) => p.heardRepeaters.isNotEmpty, - orElse: () => TxPing( - latitude: 0, - longitude: 0, - power: 0, - timestamp: now, - deviceId: '', - ), - ); - - final repeaterById = { - for (final repeater in _repeaters) ...{ - repeater.id: repeater, - if (repeater.hexId.isNotEmpty) repeater.hexId.toUpperCase(): repeater, - } - }; + // The wrist mirrors the map's "Top Heard" overlay: the latest ping's top + // three by SNR plus the current RX slot. Same source, so the two surfaces + // can never disagree. + final top = _topRepeatersOverlay; + final rxSlot = _rxOverlaySlot; + + // Overlay IDs are hex path hashes, so resolve names by prefix at whatever + // length this zone actually uses. + final hexLength = top.isNotEmpty + ? top.first.repeaterId.length + : (rxSlot?.repeaterId.length ?? 0); + final repeaterByHex = hexLength > 0 + ? WatchGeoBuilder.indexByHexPrefix(_repeaters, hexLength) + : const {}; return WatchGeo( you: position, @@ -1286,14 +1282,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: position?.lon, ), heard: WatchGeoBuilder.buildHeard( - heard: answered.heardRepeaters, - repeaterById: repeaterById, - at: answered.timestamp, + top: top, + rxSlot: rxSlot, + repeaterByHex: repeaterByHex, + at: now, lat: position?.lat, lon: position?.lon, ), - linkedRepeaterIds: - answered.heardRepeaters.map((r) => r.repeaterId).toList(), + linkedRepeaterIds: [ + for (final entry in top) entry.repeaterId, + if (rxSlot != null) rxSlot.repeaterId, + ], ); } diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 0314d18e..a3ab903e 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import '../../models/ping_data.dart'; import '../../models/repeater.dart'; +import '../../providers/app_state_provider.dart' show OverlayPingType; import '../../utils/ping_colors.dart'; import 'watch_models.dart'; @@ -151,49 +152,110 @@ class WatchGeoBuilder { .toList(); } - /// Recently-responded rows, strongest SNR first, capped at - /// [WatchWire.maxHeard]. + /// Dot colour for an overlay row, mirroring `_overlayTypeColor` on the map. + static WatchColor overlayTypeColor(OverlayPingType type) => switch (type) { + OverlayPingType.tx => WatchColor.fromColor(PingColors.txSuccess), + OverlayPingType.disc => WatchColor.fromColor(PingColors.discSuccess), + OverlayPingType.trace => WatchColor.fromColor(PingColors.traceSuccess), + OverlayPingType.rx => WatchColor.fromColor(PingColors.rx), + }; + + /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the + /// latest ping, then the current RX slot. /// - /// The cap is what the payload carries, not what the watch displays — the - /// view renders as many as fit legibly at the wearer's text size and - /// scrolls for the rest. + /// Order is deliberate rather than a global SNR sort — it matches the map + /// overlay, where the RX slot is a distinct trailing row rather than a + /// competitor for the top three. static List buildHeard({ - required List heard, - required Map repeaterById, + required List<({String repeaterId, double snr, OverlayPingType type})> top, + ({String repeaterId, double snr})? rxSlot, + required Map repeaterByHex, required DateTime at, double? lat, double? lon, - int cap = WatchWire.maxHeard, }) { - final sorted = List.from(heard) - ..sort((a, b) => (b.snr ?? -999).compareTo(a.snr ?? -999)); - - final limited = sorted.length > cap ? sorted.sublist(0, cap) : sorted; - - return limited.map((h) { - final repeater = repeaterById[h.repeaterId]; - double? distance; - if (lat != null && - lon != null && - repeater != null && - repeater.hasLocation) { - distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); - } - - return WatchHeardNode( - id: h.repeaterId, - name: repeater?.name ?? h.repeaterId.toUpperCase(), - snr: h.snr, - rssi: h.rssi, - hops: h.pathHops?.length, - seenCount: h.seenCount, + final rows = []; + + for (final entry in top) { + rows.add(_row( + id: entry.repeaterId, + snr: entry.snr, + type: entry.type, + repeaterByHex: repeaterByHex, at: at, - distanceM: distance, - snrColor: h.snr == null - ? null - : WatchColor.fromColor(PingColors.snrColor(h.snr!)), - ); - }).toList(); + lat: lat, + lon: lon, + )); + } + + if (rxSlot != null) { + rows.add(_row( + id: rxSlot.repeaterId, + snr: rxSlot.snr, + type: OverlayPingType.rx, + repeaterByHex: repeaterByHex, + at: at, + lat: lat, + lon: lon, + )); + } + + return rows.length > WatchWire.maxHeard + ? rows.sublist(0, WatchWire.maxHeard) + : rows; + } + + static WatchHeardNode _row({ + required String id, + required double snr, + required OverlayPingType type, + required Map repeaterByHex, + required DateTime at, + double? lat, + double? lon, + }) { + final hex = id.toUpperCase(); + final repeater = repeaterByHex[hex]; + + double? distance; + if (lat != null && lon != null && repeater != null && repeater.hasLocation) { + distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); + } + + return WatchHeardNode( + id: hex, + // Null rather than a guess: a short path hash can match several + // repeaters, and a confidently wrong name is worse than none. + name: repeater?.name, + snr: snr, + at: at, + distanceM: distance, + snrColor: WatchColor.fromColor(PingColors.snrColor(snr)), + typeColor: overlayTypeColor(type), + ); + } + + /// Index repeaters by the hex prefix an overlay row would carry. + /// + /// Only unambiguous prefixes are kept: if two repeaters share the leading + /// hex at that length, neither is resolvable, which is exactly the condition + /// the app flags as ambiguous rather than papering over. + static Map indexByHexPrefix( + List repeaters, + int length, + ) { + final counts = {}; + final index = {}; + + for (final repeater in repeaters) { + if (repeater.hexId.length < length) continue; + final prefix = repeater.hexId.substring(0, length).toUpperCase(); + counts[prefix] = (counts[prefix] ?? 0) + 1; + index[prefix] = repeater; + } + + index.removeWhere((prefix, _) => (counts[prefix] ?? 0) > 1); + return index; } /// True when the fix moved far enough to be worth an update. diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index ecc9955c..b40f80e4 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -18,11 +18,17 @@ class WatchWire { /// Bump when a field changes meaning or is removed. The watch refuses /// payloads it doesn't understand rather than rendering something wrong. - static const int version = 1; + /// + /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and + /// ping-type colour — instead of the richer per-echo data. Hop counts are + /// gone: the overlay is fed `directRepeaters` only. + static const int version = 2; static const int maxPings = 60; static const int maxRepeaters = 20; - static const int maxHeard = 7; + + /// Three top-SNR rows plus the RX slot, matching `_buildTopRepeatersOverlay`. + static const int maxHeard = 4; /// Skip a geo-only update unless the fix moved at least this far. Phase /// changes and new pings always go through; this only suppresses the @@ -137,41 +143,53 @@ class WatchRepeater { }; } +/// One row of the "Top Heard" overlay. +/// +/// Mirrors `_buildTopRepeatersOverlay` in `map_widget.dart`: a dot coloured by +/// which kind of ping the repeater answered, the hex path-hash ID, and the SNR. +/// +/// The **ID is the identity**, not the name. Path hashes are 1–3 bytes, so a +/// 2-character ID frequently cannot be resolved to a single repeater — [name] +/// is sent only when the match is unambiguous, and the watch always shows the +/// hex. +/// +/// There is no hop count here by design: the overlay is fed `directRepeaters`, +/// with multi-hop events deliberately excluded. class WatchHeardNode { const WatchHeardNode({ required this.id, - required this.name, - required this.seenCount, + required this.typeColor, required this.at, + this.name, this.snr, - this.rssi, - this.hops, this.distanceM, this.snrColor, }); + /// Uppercase hex path hash, 2/4/6 chars depending on the zone's hop bytes. final String id; - final String name; - final double? snr; - final int? rssi; - /// null = direct echo; otherwise the hop count. - final int? hops; - final int seenCount; + /// Resolved repeater name, when the hex maps to exactly one repeater. + final String? name; + final double? snr; final DateTime at; final double? distanceM; + + /// SNR traffic-light colour. final WatchColor? snrColor; + /// Ping type the repeater answered — green flood/active, teal discovery, + /// cyan trace, purple most-recent RX. + final WatchColor typeColor; + Map toMap() => { 'id': id, 'name': name, 'snr': snr, - 'rssi': rssi, - 'hops': hops, - 'seenCount': seenCount, 'atMs': at.millisecondsSinceEpoch.toDouble(), 'distanceM': distanceM, 'snrColor': snrColor?.toMap(), + 'typeColor': typeColor.toMap(), }; } diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 1d24224c..c6980c4f 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/models/ping_data.dart'; import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/providers/app_state_provider.dart' show OverlayPingType; import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; import 'package:mesh_mapper/services/watch/watch_models.dart'; import 'package:mesh_mapper/utils/ping_colors.dart'; @@ -142,76 +143,114 @@ void main() { }); }); - group('buildHeard', () { - test('sorts by SNR descending and caps at the wire limit', () { - final heard = List.generate( - 12, - (i) => HeardRepeater(repeaterId: 'r$i', snr: i.toDouble()), + group('buildHeard — mirrors the map\'s Top Heard overlay', () { + test('keeps the overlay order, with the RX slot trailing', () { + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: '4E5D', snr: 8.5, type: OverlayPingType.tx), + (repeaterId: '77A1', snr: 2.25, type: OverlayPingType.disc), + ], + rxSlot: (repeaterId: 'B914', snr: 9.9), + repeaterByHex: const {}, + at: DateTime(2026, 8, 12), ); + // The RX slot trails even though its SNR is highest — it is a distinct + // row on the map, not a competitor for the top three. + expect(built.map((h) => h.id), ['4E5D', '77A1', 'B914']); + }); + + test('the RX row is purple and ping rows carry their own type colour', () { final built = WatchGeoBuilder.buildHeard( - heard: heard, - repeaterById: const {}, + top: const [ + (repeaterId: 'AA', snr: 1, type: OverlayPingType.tx), + (repeaterId: 'BB', snr: 1, type: OverlayPingType.disc), + ], + rxSlot: (repeaterId: 'CC', snr: 1), + repeaterByHex: const {}, at: DateTime(2026, 8, 12), ); - expect(built.length, WatchWire.maxHeard); - expect(built.first.snr, 11.0); - expect(built.last.snr, 5.0); + expect(built[0].typeColor, WatchColor.fromColor(PingColors.txSuccess)); + expect(built[1].typeColor, WatchColor.fromColor(PingColors.discSuccess)); + expect(built[2].typeColor, WatchColor.fromColor(PingColors.rx)); }); - test('a null SNR sorts last rather than crashing', () { + test('never exceeds the wire cap of three rows plus the RX slot', () { final built = WatchGeoBuilder.buildHeard( - heard: const [ - HeardRepeater(repeaterId: 'quiet'), - HeardRepeater(repeaterId: 'loud', snr: 3), + top: const [ + (repeaterId: 'A', snr: 4, type: OverlayPingType.tx), + (repeaterId: 'B', snr: 3, type: OverlayPingType.tx), + (repeaterId: 'C', snr: 2, type: OverlayPingType.tx), + (repeaterId: 'D', snr: 1, type: OverlayPingType.tx), ], - repeaterById: const {}, + rxSlot: (repeaterId: 'E', snr: 0), + repeaterByHex: const {}, at: DateTime(2026, 8, 12), ); - expect(built.map((h) => h.id), ['loud', 'quiet']); - expect(built.last.snrColor, isNull); + expect(built.length, WatchWire.maxHeard); }); - test('resolves name and distance from the repeater directory', () { + test('uppercases the hex id and resolves name plus distance', () { final built = WatchGeoBuilder.buildHeard( - heard: const [HeardRepeater(repeaterId: '4e', snr: 6, rssi: -80)], - repeaterById: { - '4e': _repeater(id: '4e', name: 'Capitol Hill', lat: 47.61, lon: -122.3), + top: const [(repeaterId: '4e5d', snr: 6, type: OverlayPingType.tx)], + repeaterByHex: { + '4E5D': _repeater( + id: '01', hexId: '4E5D82', name: 'Capitol Hill', lat: 47.61, lon: -122.3), }, at: DateTime(2026, 8, 12), lat: 47.6, lon: -122.3, ); + expect(built.single.id, '4E5D'); expect(built.single.name, 'Capitol Hill'); expect(built.single.distanceM, closeTo(1112, 50)); }); - test('falls back to the uppercased id when the repeater is unknown', () { + test('leaves the name null when the hash resolves to nothing', () { final built = WatchGeoBuilder.buildHeard( - heard: const [HeardRepeater(repeaterId: 'ab')], - repeaterById: const {}, + top: const [(repeaterId: 'AB', snr: 1, type: OverlayPingType.tx)], + repeaterByHex: const {}, at: DateTime(2026, 8, 12), ); - expect(built.single.name, 'AB'); + expect(built.single.id, 'AB'); + expect(built.single.name, isNull, + reason: 'a confidently wrong name is worse than none'); expect(built.single.distanceM, isNull); }); + }); - test('direct echoes report no hop count', () { - final built = WatchGeoBuilder.buildHeard( - heard: const [ - HeardRepeater(repeaterId: 'direct', snr: 9), - HeardRepeater(repeaterId: 'relayed', snr: 8, pathHops: ['aa', 'bb']), - ], - repeaterById: const {}, - at: DateTime(2026, 8, 12), - ); + group('indexByHexPrefix', () { + test('resolves a prefix owned by exactly one repeater', () { + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), + _repeater(id: '02', hexId: '77A1B0', lat: 47.6, lon: -122.3, name: 'B'), + ], 4); + + expect(index['4E5D']?.name, 'A'); + expect(index['77A1']?.name, 'B'); + }); + + test('drops prefixes shared by more than one repeater', () { + // The exact collision a 1-byte path hash produces. + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), + _repeater(id: '02', hexId: '4E99F1', lat: 47.6, lon: -122.3, name: 'B'), + ], 2); + + expect(index['4E'], isNull, + reason: 'ambiguous prefixes must resolve to no name at all'); + }); + + test('ignores repeaters whose hex is shorter than the prefix', () { + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E', lat: 47.6, lon: -122.3, name: 'A'), + ], 4); - expect(built[0].hops, isNull); - expect(built[1].hops, 2); + expect(index, isEmpty); }); }); From cef0e6b07c620bbb0f7aaa259db205bcf6bb5a35 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 18:44:47 -0700 Subject: [PATCH 177/360] Fit Top Heard on the smallest watch and drop the countdown into the corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two placement and legibility fixes on the watch map. The countdown sat well clear of the bottom edge because the overlay still honoured the bottom safe area while the map beneath it ignored it. The overlay now ignores both edges, so the two corners are actually corners. Top Heard is sized for the worst case rather than the sample case. A 3-byte zone yields six-character path hashes, and four of those at full size ran the box across a 40 mm screen. Row type now shrinks with ID length exactly as RepeaterIdChip does on the phone (11/10/9 pt for 2/4/6 characters), rows are pinned to a single line, and the box is held to a fixed type size: it is a HUD, and at large accessibility sizes it would otherwise swallow the map. The scrollable detail list is where the wearer's text-size setting is honoured. Both overlays move from flat 70% black to a blurred material. The flat panel let bright basemap labels bleed through and fight the SNR digits — visible as a road label crossing the fourth row. Blurring removes the competing detail and matches the platform's own overlay treatment. Verified at the worst case — four six-character IDs on a 40 mm SE — via a new DEBUG launch argument, -MeshMapperLongIds YES. --- ios/MeshMapperWatch/MapPage.swift | 47 ++++++++++++++++++------ ios/MeshMapperWatch/SampleSnapshot.swift | 11 ++++-- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index df466dbe..41c1fd51 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -63,11 +63,12 @@ struct MapPage: View { } } .padding(.horizontal, 5) - .padding(.bottom, 3) - // Draw into the top safe area so Top Heard sits hard against the corner. - // Safe because the box is left-aligned and the system clock is right- - // aligned, so they occupy different halves of that strip. - .ignoresSafeArea(edges: .top) + .padding(.bottom, 4) + // Draw into both safe areas so the two corners are actually corners. + // The top strip is free because the box is left-aligned and the system + // clock is right-aligned; the bottom strip is otherwise dead space that + // was pushing the countdown well clear of the edge. + .ignoresSafeArea(edges: [.top, .bottom]) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -108,7 +109,7 @@ struct MapPage: View { if heard.isEmpty { Text("---") - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: rowFontSize, design: .monospaced)) .foregroundStyle(.white.opacity(0.4)) } else { ForEach(heard) { node in @@ -117,25 +118,48 @@ struct MapPage: View { .fill(Color(node.typeColor)) .frame(width: 6, height: 6) Text(node.id) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) .foregroundStyle(.white) if let snr = node.snr { Text(snr, format: .number.precision(.fractionLength(1))) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) .foregroundStyle(node.snrColor.map(Color.init) ?? .white) } } + .lineLimit(1) + .fixedSize() } } } .padding(.horizontal, 7) .padding(.vertical, 5) - .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 8)) + // Blurred material rather than flat translucency: a 70% black panel + // lets bright basemap labels bleed through and fight the SNR digits. + // Blurring the map behind the box removes the competing detail entirely. + .background(.ultraThinMaterial, in: .rect(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } .buttonStyle(.plain) + // A HUD, not body copy: fixed sizes keep it from swallowing the map at + // large accessibility text sizes. The scrollable detail list is where the + // wearer's text-size setting is honoured. + .dynamicTypeSize(.small ... .large) .opacity(client.isStale ? 0.5 : 1.0) } + /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` + /// does on the phone. A 3-byte zone yields 6-character IDs, which at full + /// size would run the box across a 40 mm screen. + private var rowFontSize: CGFloat { + let widest = heard.map(\.id.count).max() ?? 2 + if widest > 4 { return 9 } + if widest > 2 { return 10 } + return 11 + } + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder @@ -376,8 +400,9 @@ private struct CountdownPill: View { .lineLimit(1) } } - .padding(.horizontal, 6) + .padding(.horizontal, 7) .padding(.vertical, 3) - .background(.black.opacity(0.55), in: Capsule()) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) } } diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index c6fa2aa3..5e1d794f 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -63,11 +63,14 @@ enum SampleSnapshot { // the RX slot in purple. Names are deliberately mixed — a 4-char hash // resolves, a 2-char one often cannot. let teal = WatchColor(r: 0.32, g: 0.83, b: 0.91) + // Pass -MeshMapperLongIds YES to render the 3-byte-zone worst case: six + // hex characters per row, which is what the box has to survive on 40 mm. + let long = UserDefaults.standard.bool(forKey: "MeshMapperLongIds") let heard: [WatchHeardNode] = [ - ("4E5D", "Capitol Hill", 8.5, 1420.0, green), - ("77A1", nil, 2.25, 2310.0, teal), - ("A2", nil, -4.0, nil, green), - ("B914", "Magnolia", 5.75, 2870.0, purple), + (long ? "4E5D82" : "4E5D", "Capitol Hill", 8.5, 1420.0, green), + (long ? "77A1B0" : "77A1", nil, 2.25, 2310.0, teal), + (long ? "A2FF31" : "A2", nil, -14.5, nil, green), + (long ? "B914C2" : "B914", "Magnolia", 5.75, 2870.0, purple), ].map { id, name, snr, distance, typeColor in WatchHeardNode( id: id, From ce6af9abff42f010bf42933b5f77e229d184e7b6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 19:08:09 -0700 Subject: [PATCH 178/360] Move the map overlay to a full-width bottom panel with a countdown bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both overlays were clipped by the display curvature on a real watch. The simulator renders a flat rectangle and never showed it — .ignoresSafeArea was reaching for corners that physically do not exist. Rather than nudging insets, the two overlays become one panel across the bottom of the map, inside the safe area, with no corner to lose: - A depleting bar across the top, draining right to left, with the remaining time beside it. Fed by phaseEndsAt and a new phaseDurationMs, both absolute, so the bar is correct between updates and correct when the app opens midway through a phase. CountdownTimerService gains a durationMs getter and the provider identifies the owning timer by matching end times. - Heard rows in two columns when they fit, one when they do not. ViewThatFits decides by measurement: a 3-byte zone's six-character hashes plus SNR cannot fit two columns on 40 mm, and the hex ID must never truncate — it is the repeater's identity. Two layout traps worth recording. A Rectangle rule is a greedy child and stretched the panel to its cap; spacing replaces it. And .frame(maxWidth:) is expansive rather than merely limiting, so capping the panel made it that wide always — only the phase title, which can run long, carries a ceiling now. The translucent material is kept, and the whole panel remains the tap target for the detail list. --- ios/MeshMapperWatch/MapPage.swift | 221 ++++++++++-------- ios/MeshMapperWatch/SampleSnapshot.swift | 1 + ios/Shared/MeshMapperWatchPayload.swift | 15 ++ lib/providers/app_state_provider.dart | 23 ++ lib/services/countdown_timer_service.dart | 6 + lib/services/watch/watch_models.dart | 9 + .../watch/watch_wire_contract_test.dart | 10 + 7 files changed, 191 insertions(+), 94 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 41c1fd51..fdd3c1dc 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -44,31 +44,23 @@ struct MapPage: View { ZStack { map - // Top Heard sits hard against the upper-left and the countdown against - // the lower-right, mirroring the phone's map so the two read the same - // way at a glance. - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top) { - topHeardBox + // One panel in the top-leading corner carrying phase and Top Heard. + // + // Earlier versions floated the countdown in the opposite corner and drew + // into the safe areas to reach the edges. On real hardware both got + // clipped by the display curvature — the simulator renders a flat + // rectangle and never shows it. The safe area is honoured now, and + // merging the two overlays means there is no second corner to lose. + VStack(spacing: 0) { + HStack { Spacer(minLength: 0) recenterButton } Spacer(minLength: 0) - HStack(alignment: .bottom) { - staleBadge - Spacer(minLength: 0) - if let snapshot { - CountdownPill(snapshot: snapshot) - } - } + statusPanel } - .padding(.horizontal, 5) - .padding(.bottom, 4) - // Draw into both safe areas so the two corners are actually corners. - // The top strip is free because the box is left-aligned and the system - // clock is right-aligned; the bottom strip is otherwise dead space that - // was pushing the countdown well clear of the edge. - .ignoresSafeArea(edges: [.top, .bottom]) + .padding(.horizontal, 4) + .padding(.top, 2) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -92,53 +84,50 @@ struct MapPage: View { } } - /// "Top Heard" — the phone's map overlay, reproduced on the wrist. + /// Phase and Top Heard in one panel. /// /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity, /// because a 1-byte hash frequently cannot be resolved to a single repeater; /// a name is appended only when the phone could resolve it unambiguously. - private var topHeardBox: some View { + private var statusPanel: some View { Button { showingNodes = true } label: { - VStack(alignment: .leading, spacing: 2) { - Text("TOP HEARD") - .font(.system(size: 8, weight: .medium)) - .foregroundStyle(.white.opacity(0.55)) - .kerning(0.5) + VStack(alignment: .leading, spacing: 4) { + timerBar if heard.isEmpty { - Text("---") - .font(.system(size: rowFontSize, design: .monospaced)) - .foregroundStyle(.white.opacity(0.4)) + Text("Nothing heard") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.45)) + .frame(maxWidth: .infinity, alignment: .leading) } else { - ForEach(heard) { node in - HStack(spacing: 4) { - Circle() - .fill(Color(node.typeColor)) - .frame(width: 6, height: 6) - Text(node.id) - .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) - .foregroundStyle(.white) - if let snr = node.snr { - Text(snr, format: .number.precision(.fractionLength(1))) - .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) - .foregroundStyle(node.snrColor.map(Color.init) ?? .white) - } + // Two columns when they fit, one when they don't. A 3-byte zone's + // six-character hashes plus SNR will not fit two columns on a 40 mm + // screen, and truncating the ID is not an option — it is the + // repeater's identity. ViewThatFits picks by measurement rather than + // by a guess about screen size. + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 10) { + heardColumn(Array(heard.prefix(2))) + heardColumn(Array(heard.dropFirst(2))) + } + VStack(alignment: .leading, spacing: 2) { + ForEach(heard) { heardRow($0) } } - .lineLimit(1) - .fixedSize() } + .frame(maxWidth: .infinity, alignment: .leading) } } - .padding(.horizontal, 7) - .padding(.vertical, 5) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) // Blurred material rather than flat translucency: a 70% black panel // lets bright basemap labels bleed through and fight the SNR digits. - // Blurring the map behind the box removes the competing detail entirely. - .background(.ultraThinMaterial, in: .rect(cornerRadius: 10, style: .continuous)) + // Blurring the map behind the panel removes the competing detail. + .background(.ultraThinMaterial, in: .rect(cornerRadius: 12, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) ) } @@ -150,6 +139,94 @@ struct MapPage: View { .opacity(client.isStale ? 0.5 : 1.0) } + /// One column of heard rows. + private func heardColumn(_ nodes: [WatchHeardNode]) -> some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(nodes) { heardRow($0) } + } + } + + /// `[type dot] [hex ID] [SNR]`, sized to its content. + /// + /// Content-sized on purpose: a greedy row always "fits", which would stop + /// `ViewThatFits` from ever rejecting the two-column layout. + private func heardRow(_ node: WatchHeardNode) -> some View { + HStack(spacing: 3) { + Circle() + .fill(Color(node.typeColor)) + .frame(width: 6, height: 6) + Text(node.id) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(node.snrColor.map(Color.init) ?? .white) + // Fixed width so SNRs line up down a column despite varying digits. + .frame(width: rowFontSize * 3.1, alignment: .trailing) + } + } + .lineLimit(1) + .fixedSize() + } + + /// The phase as a depleting bar with its remaining time. + /// + /// The bar drains right to left from `phaseEndsAt` and `phaseDurationMs`, + /// both absolute, so it is correct without per-second updates and correct + /// when the app opens midway through a phase. A stale payload replaces the + /// whole row: a bar that keeps draining against a deadline the phone has + /// stopped confirming is worse than no bar. + @ViewBuilder + private var timerBar: some View { + if client.isStale, let receivedAt = client.receivedAt { + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 8)) + Text(receivedAt, style: .relative) + .font(.system(size: 10, weight: .medium)) + Text("old") + .font(.system(size: 10)) + Spacer(minLength: 0) + } + .foregroundStyle(.orange) + .lineLimit(1) + } else if let snapshot { + TimelineView(.periodic(from: .now, by: 1)) { context in + HStack(spacing: 6) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.white.opacity(0.18)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame( + width: geo.size.width + * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) + ) + } + } + .frame(height: 4) + + if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { + Text(timerInterval: context.date...endsAt, countsDown: true) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 42, alignment: .trailing) + } else { + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + } + } + } + .frame(height: 14) + } + } + /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` /// does on the phone. A 3-byte zone yields 6-character IDs, which at full /// size would run the box across a 40 mm screen. @@ -247,23 +324,6 @@ struct MapPage: View { } } - /// Stale data must never read as live data. - @ViewBuilder - private var staleBadge: some View { - if client.isStale, let receivedAt = client.receivedAt { - HStack(spacing: 2) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 7)) - Text(receivedAt, style: .relative) - .font(.system(size: 9)) - } - .foregroundStyle(.orange) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(.black.opacity(0.65), in: Capsule()) - } - } - // MARK: - Camera private func recenterIfFollowing(force: Bool = false) { @@ -379,30 +439,3 @@ private struct RepeaterPin: View { } } } - -private struct CountdownPill: View { - let snapshot: WatchSnapshot - - var body: some View { - HStack(spacing: 3) { - if let color = snapshot.pingColor { - Circle() - .fill(Color(color)) - .frame(width: 6, height: 6) - } - // Absolute deadline rendered by the system — no per-second traffic. - if let endsAt = snapshot.phaseEndsAt, endsAt > Date() { - Text(timerInterval: Date()...endsAt, countsDown: true) - .font(.system(size: 12, weight: .medium).monospacedDigit()) - } else { - Text(snapshot.phaseTitle) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - } - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) - } -} diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 5e1d794f..01f42a68 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -95,6 +95,7 @@ enum SampleSnapshot { phaseTitle: "Listening", phaseDetail: "Waiting for echoes", phaseEndsAtMs: now + 42_000, + phaseDurationMs: 60_000, isConnected: true, zoneCode: "SEA", txCount: 27, diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 6ae7d4d2..5b6ed379 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -151,6 +151,9 @@ struct WatchSnapshot: Codable, Hashable { let phaseTitle: String let phaseDetail: String? let phaseEndsAtMs: Double? + /// Total length of the current phase, so the watch can draw a depleting bar + /// locally. Absent when no countdown owns the deadline. + let phaseDurationMs: Int? let isConnected: Bool let zoneCode: String? let txCount: Int @@ -177,6 +180,18 @@ struct WatchSnapshot: Codable, Hashable { var phaseEndsAt: Date? { phaseEndsAtMs.map { Date(timeIntervalSince1970: $0 / 1000) } } + + /// Fraction of the current phase still to run, 0...1. + /// + /// Computed from absolute values at render time, so it stays correct between + /// updates and when the app opens midway through a phase. Nil when no + /// countdown owns the deadline, in which case no bar is drawn. + func phaseRemainingFraction(at now: Date = Date()) -> Double? { + guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { return nil } + let remaining = phaseEndsAt.timeIntervalSince(now) + guard remaining > 0 else { return 0 } + return min(1, remaining / (Double(phaseDurationMs) / 1000)) + } } // MARK: - Commands (watch → phone) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d81e7ded..f1cefacb 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1245,10 +1245,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { geo: _buildWatchGeo(now), controls: _buildWatchControls(), pingColor: _resolveWatchPingColor(), + phaseDurationMs: _phaseDurationMsFor(phase.endsAt), updatedAt: now, ); } + /// Total length of the countdown that owns [endsAt]. + /// + /// The phase resolver returns a deadline without saying which timer produced + /// it, so the owner is identified by matching end times. Returns null for + /// deadlines no countdown owns (the zone grace period), in which case the + /// watch shows the remaining time without a progress bar. + int? _phaseDurationMsFor(DateTime? endsAt) { + if (endsAt == null) return null; + for (final timer in [ + _autoPingTimer, + _rxWindowTimer, + _discoveryWindowTimer, + _manualPingCooldownTimer, + _cooldownTimer, + ]) { + if (timer.isRunning && timer.endTime == endsAt) { + return timer.durationMs; + } + } + return null; + } + WatchGeo _buildWatchGeo(DateTime now) { final position = _resolveWatchPosition(); diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index ccb6d580..cdac81b6 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -19,6 +19,12 @@ class CountdownTimerService extends ChangeNotifier { DateTime? get endTime => _endTime; int? _durationMs; + /// Total length of the current countdown. + /// + /// Paired with [endTime] this lets a remote surface — the watch — draw a + /// progress bar locally from absolute values, with no per-second updates. + int? get durationMs => _durationMs; + /// Check if timer is running bool get isRunning => _timer != null; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index b40f80e4..d5115bfe 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -270,6 +270,7 @@ class WatchSnapshot { required this.updatedAt, this.pingColor, this.cue, + this.phaseDurationMs, }); /// Session core, reused from the Live Activity so both surfaces agree. @@ -280,6 +281,13 @@ class WatchSnapshot { final WatchHapticCue? cue; final DateTime updatedAt; + /// Total length of the current phase. + /// + /// With [LiveActivitySnapshot.phaseEndsAt] this is everything the watch needs + /// to draw a depleting progress bar locally — no per-second traffic, and the + /// bar stays correct even if the app opens midway through a phase. + final int? phaseDurationMs; + Map toMap() => { 'wireVersion': WatchWire.version, 'sessionId': core.sessionId, @@ -289,6 +297,7 @@ class WatchSnapshot { 'phaseDetail': core.phaseDetail, 'phaseEndsAtMs': core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'phaseDurationMs': phaseDurationMs, 'isConnected': core.isConnected, 'zoneCode': core.zoneCode, 'txCount': core.txCount, diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 16cab7d2..b3dc8a1c 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -15,6 +15,7 @@ WatchSnapshot _snapshot({ WatchHapticCue? cue, String phaseTitle = 'Listening', bool isConnected = true, + int? phaseDurationMs, }) => WatchSnapshot( core: LiveActivitySnapshot( @@ -48,6 +49,7 @@ WatchSnapshot _snapshot({ isSessionActive: true, ), pingColor: const WatchColor(1, 0, 0), + phaseDurationMs: phaseDurationMs, cue: cue, updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), ); @@ -67,6 +69,7 @@ void main() { 'phaseTitle', 'phaseDetail', 'phaseEndsAtMs', + 'phaseDurationMs', 'isConnected', 'zoneCode', 'txCount', @@ -109,6 +112,13 @@ void main() { expect(map['phaseEndsAtMs'], 1760000000000.0); }); + test('phase duration rides along so the watch can draw its own bar', () { + // Deadline plus duration is everything needed to compute the remaining + // fraction locally, which is why the bar needs no per-second updates. + expect(_snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); + expect(_snapshot().toMap()['phaseDurationMs'], isNull); + }); + test('wire version is stamped so the watch can refuse unknown payloads', () { expect(_snapshot().toMap()['wireVersion'], WatchWire.version); }); From 68660359d8200c1512597db788d1708ce36ead9f Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 19:24:48 -0700 Subject: [PATCH 179/360] Overlay the countdown label on the bar and offset the camera for the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label rides on the timer bar instead of sitting beside it, with a shadow so it stays readable over both the filled and empty parts of the track. A separate column cost width permanently and left the phase title cramped. The camera now places the fix in the middle of the band between the top of the display and the top of the panel, rather than the middle of the display, so the puck is no longer pushed down behind the panel. The panel measures itself through a preference key, so this holds however tall the panel gets. Also tracks the rendered region from onMapCameraChange. Without it a Digital Crown zoom was discarded on the next follow update, since every recenter reused the originally requested span. Known incomplete: the offset under-shifts. Instrumentation on a 46 mm simulator showed panel=54pt, view=159pt, frac=0.34, latSpan=0.03 — the fraction is right, but two denominators are wrong. viewHeight measures the ZStack (159pt) while the map draws into the safe area it excludes (~242pt), and context.region.span reports the requested span rather than the visible one. Deriving both from context.rect (MKMapRect) is the fix. --- ios/MeshMapperWatch/MapPage.swift | 145 ++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 36 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index fdd3c1dc..f0f192ae 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -40,6 +40,26 @@ struct MapPage: View { @State private var showingNodes = false + /// Height of the bottom panel and of the whole view, so the camera can put + /// the fix in the middle of the *visible* map rather than the middle of the + /// display — otherwise the puck sits behind the panel. + @State private var panelHeight: CGFloat = 0 + @State private var viewHeight: CGFloat = 0 + + private struct PanelHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } + } + + /// Fraction of the view the panel covers, clamped so a pathological layout + /// can never push the camera somewhere absurd. + private var obscuredFraction: CGFloat { + guard viewHeight > 0 else { return 0 } + return min(0.55, max(0, panelHeight / viewHeight)) + } + var body: some View { ZStack { map @@ -58,10 +78,27 @@ struct MapPage: View { } Spacer(minLength: 0) statusPanel + .background( + GeometryReader { geo in + Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height) + } + ) } .padding(.horizontal, 4) .padding(.top, 2) } + .background( + GeometryReader { geo in + Color.clear + .onAppear { viewHeight = geo.size.height } + .onChange(of: geo.size.height) { _, height in viewHeight = height } + } + ) + .onPreferenceChange(PanelHeightKey.self) { height in + guard abs(height - panelHeight) > 0.5 else { return } + panelHeight = height + recenterIfFollowing() + } .sheet(isPresented: $showingNodes) { NavigationStack { NodeListView() @@ -93,7 +130,7 @@ struct MapPage: View { Button { showingNodes = true } label: { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { timerBar if heard.isEmpty { @@ -120,7 +157,7 @@ struct MapPage: View { } } .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) // Blurred material rather than flat translucency: a 70% black panel // lets bright basemap labels bleed through and fight the SNR digits. @@ -193,37 +230,40 @@ struct MapPage: View { .lineLimit(1) } else if let snapshot { TimelineView(.periodic(from: .now, by: 1)) { context in - HStack(spacing: 6) { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(.white.opacity(0.18)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame( - width: geo.size.width - * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) - ) - } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame( + width: geo.size.width + * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) + ) } - .frame(height: 4) - - if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { - Text(timerInterval: context.date...endsAt, countsDown: true) - .font(.system(size: 12, weight: .semibold).monospacedDigit()) - .foregroundStyle(.white) - .frame(width: 42, alignment: .trailing) - } else { - Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) + // Label rides on the bar rather than beside it: a separate column + // costs width permanently, and the phase title needs the room. + .overlay(alignment: .trailing) { + Group { + if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { + Text(timerInterval: context.date...endsAt, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .frame(width: 38, alignment: .trailing) + } else { + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .foregroundStyle(.white) + // The fill slides under the label, so a shadow keeps it readable + // against both the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.trailing, 6) } } } - .frame(height: 14) + .frame(height: 15) } } @@ -266,6 +306,7 @@ struct MapPage: View { } .mapStyle(settings.satellite ? .imagery : .standard) .onMapCameraChange(frequency: .onEnd) { context in + noteRenderedRegion(context.region) noteCameraChange(context.region.center) } .ignoresSafeArea(edges: .bottom) @@ -328,17 +369,28 @@ struct MapPage: View { private func recenterIfFollowing(force: Bool = false) { guard force || isFollowing, let fix else { return } - programmaticCenter = fix + let center = centerPlacing(fix) + programmaticCenter = center withAnimation(.easeInOut(duration: 0.25)) { - camera = .region( - MKCoordinateRegion( - center: fix, - span: currentSpan - ) - ) + camera = .region(MKCoordinateRegion(center: center, span: currentSpan)) } } + /// Region centre that puts [fix] in the middle of the band between the top + /// of the display and the top of the panel. + /// + /// MapKit centres the region in the whole view, so with a panel covering the + /// lower third the fix would sit low and partly behind it. Shifting the + /// region centre south by half the obscured height lifts the fix by the same + /// amount on screen. + private func centerPlacing(_ fix: CLLocationCoordinate2D) -> CLLocationCoordinate2D { + let shift = currentSpan.latitudeDelta * Double(obscuredFraction) / 2 + return CLLocationCoordinate2D( + latitude: fix.latitude - shift, + longitude: fix.longitude + ) + } + /// Preserve whatever zoom the wearer picked with the Digital Crown. /// /// The initial span is deliberately wide (~3 km): wardriving is about what @@ -349,6 +401,27 @@ struct MapPage: View { longitudeDelta: 0.03 ) + /// Track the region MapKit actually rendered. + /// + /// Two reasons this matters. MapKit fits a requested span to the view's + /// aspect ratio, so the rendered latitude delta differs from the requested + /// one — computing the panel offset against the request under-shifts the + /// camera. And without this, a Digital Crown zoom would be thrown away on + /// the next follow update. + private func noteRenderedRegion(_ region: MKCoordinateRegion) { + let previous = currentSpan.latitudeDelta + currentSpan = region.span + + // The first render is what reveals the aspect-corrected span, so re-place + // the fix once against it. This converges: the follow-up request carries + // the rendered span, so the next change reports no material difference. + guard previous > 0 else { return } + let drift = abs(region.span.latitudeDelta - previous) / previous + if drift > 0.05 { + recenterIfFollowing() + } + } + private func noteCameraChange(_ center: CLLocationCoordinate2D) { guard let expected = programmaticCenter else { // We have never driven the camera, so this is `.automatic` settling on From 5ed21aba3793875a3dc67c9aa3d4abdfa96f4449 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 19:58:33 -0700 Subject: [PATCH 180/360] Size the countdown bar to the watch, and place the fix via the map proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the map page, both driven by what the panel actually measures rather than by a guess. Bar placement now differs by screen. A 46 mm watch has 184 pt of panel width and a 40 mm one has 138, so the large screens set the phase title and the countdown either side of the track and leave the bar as a pure gauge, while the small ones keep the single label riding on the bar. The column gutter widens on the roomy sizes too, which ViewThatFits will still veto down to one column if six-character hashes need the space. Placing the fix is now a question for the map, not a calculation. The previous offset under-shot because it compared the panel's height to the enclosing stack's, and that stack is not the map: SwiftUI reports the map's frame as 159 pt tall on a 248 pt display, yet it draws to every edge. No measurement of the view hierarchy predicts where a coordinate lands. MapReader's proxy answers directly, so the camera translates by the difference between the fix and whatever sits at the target point, then a deadbanded correction on each camera change absorbs the first render and any Digital Crown zoom. Verified: the puck settles at 78.75 pt against a panel top of 158 on 46 mm, and 47.75 against 96 on 40 mm — centred in the band, as asked, and stable across frames. Worth remembering: the CGRect preference keys must ignore empty values. Every sibling subtree contributes the default, so taking nextValue() unconditionally let a later .zero overwrite the real frame — the measurements read as zero until reduce learned to skip them. --- ios/MeshMapperWatch/MapPage.swift | 270 ++++++++++++++++++++---------- 1 file changed, 183 insertions(+), 87 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index f0f192ae..4394e4f1 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -40,29 +40,57 @@ struct MapPage: View { @State private var showingNodes = false - /// Height of the bottom panel and of the whole view, so the camera can put - /// the fix in the middle of the *visible* map rather than the middle of the - /// display — otherwise the puck sits behind the panel. - @State private var panelHeight: CGFloat = 0 - @State private var viewHeight: CGFloat = 0 - - private struct PanelHeightKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) + /// The panel's frame in global coordinates, so the camera can keep the fix + /// out from behind it. + @State private var panelFrame: CGRect = .zero + @State private var viewWidth: CGFloat = 0 + + /// Only one subtree sets this, but every *other* subtree still contributes + /// the default. Taking `nextValue()` unconditionally would let a later + /// sibling's `.zero` overwrite the real measurement, so empties are ignored. + private struct PanelFrameKey: PreferenceKey { + static let defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + let next = nextValue() + if next != .zero { value = next } } } - /// Fraction of the view the panel covers, clamped so a pathological layout - /// can never push the camera somewhere absurd. - private var obscuredFraction: CGFloat { - guard viewHeight > 0 else { return 0 } - return min(0.55, max(0, panelHeight / viewHeight)) + /// Where the fix belongs on screen: midway between the top of the display and + /// the top of the panel. + private var targetPoint: CGPoint? { + guard panelFrame.height > 0 else { return nil } + return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } + /// Width the panel's content actually gets: the display minus the overlay's + /// horizontal padding (4 a side) and the panel's own (8 a side). + private var panelContentWidth: CGFloat { max(0, viewWidth - 24) } + + /// Whether there is room to set the phase title and the countdown *beside* + /// the bar rather than on top of it. + /// + /// Budget: ~52 pt for the longest phase title, ~38 for the countdown, two + /// 6 pt gaps, and ~64 left for the bar so it still reads as a gauge. A 46 mm + /// screen has 184 pt to spend and a 40 mm one has 138, so the two sizes get + /// genuinely different treatments instead of the small one being squeezed. + private var labelsFitBesideBar: Bool { panelContentWidth >= 160 } + var body: some View { + // The proxy is the only reliable way to relate a coordinate to a point on + // screen. The map draws outside its own layout frame — it ignores the + // bottom safe area, and on a 46 mm watch the frame SwiftUI reports is + // 159 pt tall against a 248 pt display — so no measurement of the view + // hierarchy predicts where a coordinate will actually land. Asking the map + // sidesteps the whole question. + MapReader { proxy in + content(proxy) + } + } + + private func content(_ proxy: MapProxy) -> some View { ZStack { - map + map(proxy) // One panel in the top-leading corner carrying phase and Top Heard. // @@ -74,13 +102,13 @@ struct MapPage: View { VStack(spacing: 0) { HStack { Spacer(minLength: 0) - recenterButton + recenterButton(proxy) } Spacer(minLength: 0) statusPanel .background( GeometryReader { geo in - Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height) + Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) } ) } @@ -90,14 +118,14 @@ struct MapPage: View { .background( GeometryReader { geo in Color.clear - .onAppear { viewHeight = geo.size.height } - .onChange(of: geo.size.height) { _, height in viewHeight = height } + .onAppear { viewWidth = geo.size.width } + .onChange(of: geo.size.width) { _, width in viewWidth = width } } ) - .onPreferenceChange(PanelHeightKey.self) { height in - guard abs(height - panelHeight) > 0.5 else { return } - panelHeight = height - recenterIfFollowing() + .onPreferenceChange(PanelFrameKey.self) { frame in + guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } + panelFrame = frame + recenterIfFollowing(proxy) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -107,10 +135,15 @@ struct MapPage: View { } } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in - recenterIfFollowing() + recenterIfFollowing(proxy) + } + // The delayed resume in `scheduleFollowResume` only clears the suspension; + // recentring happens here, where a live proxy is in scope. + .onChange(of: followSuspendedUntil) { _, until in + if until == nil { recenterIfFollowing(proxy) } } .onAppear { - recenterIfFollowing() + recenterIfFollowing(proxy) #if DEBUG // Lets the sheet layout be captured and iterated on headlessly; the // simulator has no way to tap the bar. @@ -145,7 +178,7 @@ struct MapPage: View { // repeater's identity. ViewThatFits picks by measurement rather than // by a guess about screen size. ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 10) { + HStack(alignment: .top, spacing: columnGap) { heardColumn(Array(heard.prefix(2))) heardColumn(Array(heard.dropFirst(2))) } @@ -176,6 +209,12 @@ struct MapPage: View { .opacity(client.isStale ? 0.5 : 1.0) } + /// Gutter between the two heard columns. Wider where there is room, so they + /// read as columns rather than one run-on block hugging the left edge. + /// Widening it also feeds `ViewThatFits`, which will drop to one column if + /// the roomier pair no longer fits. + private var columnGap: CGFloat { labelsFitBesideBar ? 18 : 10 } + /// One column of heard rows. private func heardColumn(_ nodes: [WatchHeardNode]) -> some View { VStack(alignment: .leading, spacing: 2) { @@ -230,43 +269,72 @@ struct MapPage: View { .lineLimit(1) } else if let snapshot { TimelineView(.periodic(from: .now, by: 1)) { context in - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame( - width: geo.size.width - * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) - ) + if labelsFitBesideBar { + // Big screens have width to spare, so spend it on legibility: phase + // name and remaining time both stand clear of the track, and the bar + // is left as a pure gauge. + HStack(spacing: 6) { + phaseTitle(snapshot) + track(snapshot, at: context.date) + countdown(snapshot, at: context.date) } - // Label rides on the bar rather than beside it: a separate column - // costs width permanently, and the phase title needs the room. - .overlay(alignment: .trailing) { - Group { - if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { - Text(timerInterval: context.date...endsAt, countsDown: true) - .font(.system(size: 11, weight: .bold).monospacedDigit()) - .frame(width: 38, alignment: .trailing) - } else { - Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .semibold)) - .lineLimit(1) - .truncationMode(.tail) + } else { + // Small screens cannot afford two label columns, so the one label + // that matters rides on the bar. Countdown when a phase is running, + // otherwise its name. + track(snapshot, at: context.date) + .overlay(alignment: .trailing) { + Group { + if snapshot.phaseEndsAt.map({ $0 > context.date }) == true { + countdown(snapshot, at: context.date) + } else { + phaseTitle(snapshot) + } } + // The fill slides under the label, so a shadow keeps it readable + // against both the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.trailing, 6) } - .foregroundStyle(.white) - // The fill slides under the label, so a shadow keeps it readable - // against both the filled and empty parts of the track. - .shadow(color: .black.opacity(0.7), radius: 1.5) - .padding(.trailing, 6) - } } } .frame(height: 15) } } + /// The depleting track. Greedy on purpose — it takes whatever width the + /// labels beside it leave. + private func track(_ snapshot: WatchSnapshot, at date: Date) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame(width: geo.size.width * (snapshot.phaseRemainingFraction(at: date) ?? 0)) + } + } + } + + private func phaseTitle(_ snapshot: WatchSnapshot) -> some View { + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + } + + /// Fixed width because `Text(timerInterval:)` otherwise reserves room for the + /// widest value it might ever show, which would starve the track. + @ViewBuilder + private func countdown(_ snapshot: WatchSnapshot, at date: Date) -> some View { + if let endsAt = snapshot.phaseEndsAt, endsAt > date { + Text(timerInterval: date...endsAt, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 38, alignment: .trailing) + } + } + /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` /// does on the phone. A 3-byte zone yields 6-character IDs, which at full /// size would run the box across a 40 mm screen. @@ -280,11 +348,11 @@ struct MapPage: View { private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder - private var recenterButton: some View { + private func recenterButton(_ proxy: MapProxy) -> some View { if !isFollowing, fix != nil { Button { followSuspendedUntil = nil - recenterIfFollowing(force: true) + recenterIfFollowing(proxy, force: true) } label: { Image(systemName: "location.fill") .font(.system(size: 10)) @@ -297,7 +365,7 @@ struct MapPage: View { // MARK: - Map - private var map: some View { + private func map(_ proxy: MapProxy) -> some View { Map(position: $camera, interactionModes: [.pan, .zoom]) { linkLines pingMarkers @@ -308,6 +376,7 @@ struct MapPage: View { .onMapCameraChange(frequency: .onEnd) { context in noteRenderedRegion(context.region) noteCameraChange(context.region.center) + correctPlacement(proxy) } .ignoresSafeArea(edges: .bottom) } @@ -367,30 +436,59 @@ struct MapPage: View { // MARK: - Camera - private func recenterIfFollowing(force: Bool = false) { + private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { guard force || isFollowing, let fix else { return } - let center = centerPlacing(fix) + let center = centerPlacing(fix, proxy: proxy) programmaticCenter = center withAnimation(.easeInOut(duration: 0.25)) { camera = .region(MKCoordinateRegion(center: center, span: currentSpan)) } } - /// Region centre that puts [fix] in the middle of the band between the top - /// of the display and the top of the panel. + /// Region centre that puts [fix] midway between the top of the display and + /// the top of the panel. /// - /// MapKit centres the region in the whole view, so with a panel covering the - /// lower third the fix would sit low and partly behind it. Shifting the - /// region centre south by half the obscured height lifts the fix by the same - /// amount on screen. - private func centerPlacing(_ fix: CLLocationCoordinate2D) -> CLLocationCoordinate2D { - let shift = currentSpan.latitudeDelta * Double(obscuredFraction) / 2 + /// MapKit centres the region in the map, so with a panel over the lower third + /// the fix would sit low and partly behind it. Rather than predict how far to + /// shift, this asks the map which coordinate is at the target point today and + /// translates the camera by the difference — exact whatever the projection, + /// the zoom, or the latitude. + private func centerPlacing( + _ fix: CLLocationCoordinate2D, + proxy: MapProxy + ) -> CLLocationCoordinate2D { + // Before the first render there is nothing to translate against; centring + // on the fix is the right opening move, and `correctPlacement` lifts it as + // soon as the map reports back. + guard let targetPoint, + let renderedCenter, + let atTarget = proxy.convert(targetPoint, from: .global) + else { return fix } + + // Clamped so a bad conversion — an off-map point, a mid-animation read — + // can never fling the camera somewhere the wearer has to chase. + let lift = (fix.latitude - atTarget.latitude) + .clamped(to: -currentSpan.latitudeDelta...currentSpan.latitudeDelta) return CLLocationCoordinate2D( - latitude: fix.latitude - shift, + latitude: renderedCenter.latitude + lift, longitude: fix.longitude ) } + /// Nudge the camera once the map reports where things really landed. + /// + /// The first placement runs before any render, and a zoom changes the scale + /// underneath us, so placement is a feedback loop rather than a calculation. + /// The deadband is what stops it: each pass lands within a couple of points, + /// the next sees no error worth fixing, and it settles. + private func correctPlacement(_ proxy: MapProxy) { + guard isFollowing, let fix, let targetPoint, + let point = proxy.convert(fix, to: .global) + else { return } + guard abs(point.y - targetPoint.y) > 6 else { return } + recenterIfFollowing(proxy) + } + /// Preserve whatever zoom the wearer picked with the Digital Crown. /// /// The initial span is deliberately wide (~3 km): wardriving is about what @@ -401,25 +499,16 @@ struct MapPage: View { longitudeDelta: 0.03 ) - /// Track the region MapKit actually rendered. - /// - /// Two reasons this matters. MapKit fits a requested span to the view's - /// aspect ratio, so the rendered latitude delta differs from the requested - /// one — computing the panel offset against the request under-shifts the - /// camera. And without this, a Digital Crown zoom would be thrown away on - /// the next follow update. + /// Centre of the region MapKit last rendered — the fixed point every + /// placement is measured against. + @State private var renderedCenter: CLLocationCoordinate2D? + + /// Track the region MapKit actually rendered, so a Digital Crown zoom is not + /// thrown away on the next follow update and so placement has a known + /// starting point. private func noteRenderedRegion(_ region: MKCoordinateRegion) { - let previous = currentSpan.latitudeDelta currentSpan = region.span - - // The first render is what reveals the aspect-corrected span, so re-place - // the fix once against it. This converges: the follow-up request carries - // the rendered span, so the next change reports no material difference. - guard previous > 0 else { return } - let drift = abs(region.span.latitudeDelta - previous) / previous - if drift > 0.05 { - recenterIfFollowing() - } + renderedCenter = region.center } private func noteCameraChange(_ center: CLLocationCoordinate2D) { @@ -454,8 +543,9 @@ struct MapPage: View { try? await Task.sleep(for: .seconds(seconds)) } guard !Task.isCancelled, followSuspendedUntil == deadline else { return } + // Clearing this drives the recentre, via `onChange` where a proxy is in + // scope. followSuspendedUntil = nil - recenterIfFollowing() } } @@ -470,6 +560,12 @@ struct MapPage: View { } } +extension Comparable { + fileprivate func clamped(to limits: ClosedRange) -> Self { + min(max(self, limits.lowerBound), limits.upperBound) + } +} + // MARK: - Markers private struct FixPuck: View { From 25af775b0222e5645eda2f461a506d66600e0588 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 20:32:45 -0700 Subject: [PATCH 181/360] Put both bar labels back on the track and fit the panel to the display curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, implemented by Codex against a spec and verified here on both simulator sizes. The phase title and the countdown both ride the bar again, at opposite ends of one overlay. Splitting them by screen size was wrong: it moved "Listening" permanently to the left of the track on large watches and left the right end of the bar empty, which read as the clock having disappeared. One treatment now serves every size. The panel is narrower and sits 4 pt from the bottom edge. It may descend into the bottom safe area only in exchange for horizontal clearance, because that safe area is what the display's curvature costs: modelling the corner as a circle of radius R, a panel whose bottom edge is g from the edge needs R - sqrt(2Rg - g^2) of inset, plus 4 pt because the reported inset is a lower bound on the real glass. watchOS exposes no corner radius, but its bottom safe-area inset is the clearance a full-width element demands, which is that radius. The 46 mm reports 36 and the 40 mm 19, so the large watch narrows nearly twice as much — the asymmetry that was asked for, arrived at rather than assumed. Top Heard is two columns everywhere now, including 40 mm, with the type size solved from the width actually available rather than picked from a hash-length ladder. Only a six-character zone on the smallest screen falls back to one column, and it now does so at the 9 pt cap instead of inheriting the two-column floor. Measured, in points, with the panel top and the fix both verified stable across frames: 46 mm safeBottom 36 inset 23.5 panel 157.5 wide at y=190 font 9.8 40 mm safeBottom 19 inset 11.3 panel 135.5 wide at y=144 font 7.8 40 mm six-char inset 11.3 panel 135.3 wide at y=115 font 9.0 Three measurement traps cost a build each and are worth remembering. Reading the container's width to compute padding applied to that same container is a feedback loop — the 40 mm reported itself 169 then 173 pt wide on a 162 pt display, and the panel landed 2.3 pt from the edge instead of the 9.3 it had just computed; WKInterfaceDevice.screenBounds is static and cannot feed back. A GeometryReader in the map's background reports no safe area, because the map ignores it. And `.ignoresSafeArea()` on the reader itself reports none either, since it measures its own expanded region — plain is correct here, and the first value is latched so the panel can never perturb the number that positioned it. The corner model still needs confirming on hardware: the simulator renders a flat rectangle and cannot show a clip. --- ios/MeshMapperWatch/MapPage.swift | 175 +++++++++++++++++++----------- 1 file changed, 109 insertions(+), 66 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 4394e4f1..064d037b 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1,6 +1,7 @@ import CoreLocation import MapKit import SwiftUI +import WatchKit /// The map, drawn on Apple's basemap. /// @@ -43,7 +44,7 @@ struct MapPage: View { /// The panel's frame in global coordinates, so the camera can keep the fix /// out from behind it. @State private var panelFrame: CGRect = .zero - @State private var viewWidth: CGFloat = 0 + @State private var bottomSafeAreaInset: CGFloat = 0 /// Only one subtree sets this, but every *other* subtree still contributes /// the default. Taking `nextValue()` unconditionally would let a later @@ -63,18 +64,44 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - /// Width the panel's content actually gets: the display minus the overlay's - /// horizontal padding (4 a side) and the panel's own (8 a side). - private var panelContentWidth: CGFloat { max(0, viewWidth - 24) } + private static let panelBottomGap: CGFloat = 4 - /// Whether there is room to set the phase title and the countdown *beside* - /// the bar rather than on top of it. + /// Panel geometry is about the physical display, not this view's proposal. + /// Reading the device avoids a feedback loop where panel padding changes the + /// container width that is then used to recompute that same padding. + private var screenWidth: CGFloat { WKInterfaceDevice.current().screenBounds.width } + + /// Horizontal clearance that lets the panel descend into the bottom safe + /// area without putting its corners outside the curved display. /// - /// Budget: ~52 pt for the longest phase title, ~38 for the countdown, two - /// 6 pt gaps, and ~64 left for the bar so it still reads as a gauge. A 46 mm - /// screen has 184 pt to spend and a 40 mm one has 138, so the two sizes get - /// genuinely different treatments instead of the small one being squeezed. - private var labelsFitBesideBar: Bool { panelContentWidth >= 160 } + /// watchOS exposes no screen corner radius, but its bottom safe-area inset is + /// the clearance a full-width element needs at zero horizontal inset, making + /// it a useful estimate of that radius. The circle/chord intersection gives + /// the inset at our chosen bottom gap. That estimate is a lower bound on the + /// glass curvature, so four extra points are cheap insurance against another + /// hardware clip. The rectangle test is conservative in the other direction: + /// the panel's own 12 pt radius pulls its visible corners inward from the + /// square corners protected by this equation. + private var curvedPanelHorizontalInset: CGFloat? { + let radius = bottomSafeAreaInset + let gap = Self.panelBottomGap + guard radius > 0, gap < radius else { return nil } + let inset = radius - sqrt(max(0, 2 * radius * gap - gap * gap)) + 4 + guard inset.isFinite else { return nil } + return inset + } + + /// A missing safe-area measurement is not permission to draw to the edge: + /// retaining the old four-point inset and safe-area placement is the only + /// fallback that is known not to clip on hardware. + private var panelHorizontalInset: CGFloat { curvedPanelHorizontalInset ?? 4 } + + /// Width left after the curvature clearance and the panel's own 8 pt inset + /// on each side. Row sizing uses this same budget as the rendered content, + /// so its two-column decision does not depend on a nominal watch size. + private var panelContentWidth: CGFloat { + max(0, screenWidth - 2 * panelHorizontalInset - 16) + } var body: some View { // The proxy is the only reliable way to relate a coordinate to a point on @@ -92,13 +119,13 @@ struct MapPage: View { ZStack { map(proxy) - // One panel in the top-leading corner carrying phase and Top Heard. + // One panel carrying phase and Top Heard. // // Earlier versions floated the countdown in the opposite corner and drew - // into the safe areas to reach the edges. On real hardware both got - // clipped by the display curvature — the simulator renders a flat - // rectangle and never shows it. The safe area is honoured now, and - // merging the two overlays means there is no second corner to lose. + // full-width into the safe areas. On real hardware both got clipped by + // the display curvature — the simulator renders a flat rectangle and + // never shows it. This panel enters only the bottom safe area, and only + // after the measured curvature has bought enough horizontal clearance. VStack(spacing: 0) { HStack { Spacer(minLength: 0) @@ -112,15 +139,24 @@ struct MapPage: View { } ) } - .padding(.horizontal, 4) + .padding(.horizontal, panelHorizontalInset) .padding(.top, 2) + .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : Self.panelBottomGap) + .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } .background( GeometryReader { geo in Color.clear - .onAppear { viewWidth = geo.size.width } - .onChange(of: geo.size.width) { _, width in viewWidth = width } + .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } + .onChange(of: geo.safeAreaInsets.bottom) { _, inset in + latchBottomSafeAreaInset(inset) + } } + // Plain is intentional: `.ignoresSafeArea()` makes this reader report + // the insets of its own expanded region, which are zero. The first + // nonzero value is latched while the panel is still in its safe fallback + // placement; later panel geometry depends on it, while the display's + // actual safe area is a device constant that cannot legitimately change. ) .onPreferenceChange(PanelFrameKey.self) { frame in guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } @@ -154,6 +190,11 @@ struct MapPage: View { } } + private func latchBottomSafeAreaInset(_ inset: CGFloat) { + guard bottomSafeAreaInset == 0, inset > 0 else { return } + bottomSafeAreaInset = inset + } + /// Phase and Top Heard in one panel. /// /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity, @@ -172,21 +213,21 @@ struct MapPage: View { .foregroundStyle(.white.opacity(0.45)) .frame(maxWidth: .infinity, alignment: .leading) } else { - // Two columns when they fit, one when they don't. A 3-byte zone's - // six-character hashes plus SNR will not fit two columns on a 40 mm - // screen, and truncating the ID is not an option — it is the - // repeater's identity. ViewThatFits picks by measurement rather than - // by a guess about screen size. - ViewThatFits(in: .horizontal) { + // Two columns are the useful glance layout even on 40 mm, so type + // shrinks against the same width model as the actual row. Only a + // six-character zone that would cross the 7 pt legibility floor gets + // one column; truncating the ID is not an option because it is the + // repeater's identity. + if twoHeardColumnsFit { HStack(alignment: .top, spacing: columnGap) { heardColumn(Array(heard.prefix(2))) heardColumn(Array(heard.dropFirst(2))) } + } else { VStack(alignment: .leading, spacing: 2) { ForEach(heard) { heardRow($0) } } } - .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.horizontal, 8) @@ -209,11 +250,10 @@ struct MapPage: View { .opacity(client.isStale ? 0.5 : 1.0) } - /// Gutter between the two heard columns. Wider where there is room, so they - /// read as columns rather than one run-on block hugging the left edge. - /// Widening it also feeds `ViewThatFits`, which will drop to one column if - /// the roomier pair no longer fits. - private var columnGap: CGFloat { labelsFitBesideBar ? 18 : 10 } + /// A stable gutter keeps the width equation and the rendered columns in + /// agreement; changing it by device size would silently spend the room the + /// font calculation just recovered on the smallest watch. + private var columnGap: CGFloat { 8 } /// One column of heard rows. private func heardColumn(_ nodes: [WatchHeardNode]) -> some View { @@ -224,8 +264,8 @@ struct MapPage: View { /// `[type dot] [hex ID] [SNR]`, sized to its content. /// - /// Content-sized on purpose: a greedy row always "fits", which would stop - /// `ViewThatFits` from ever rejecting the two-column layout. + /// Content-sized on purpose so the row's intrinsic measurement stays equal + /// to the width model that chooses the font and column count. private func heardRow(_ node: WatchHeardNode) -> some View { HStack(spacing: 3) { Circle() @@ -269,41 +309,25 @@ struct MapPage: View { .lineLimit(1) } else if let snapshot { TimelineView(.periodic(from: .now, by: 1)) { context in - if labelsFitBesideBar { - // Big screens have width to spare, so spend it on legibility: phase - // name and remaining time both stand clear of the track, and the bar - // is left as a pure gauge. - HStack(spacing: 6) { - phaseTitle(snapshot) - track(snapshot, at: context.date) - countdown(snapshot, at: context.date) - } - } else { - // Small screens cannot afford two label columns, so the one label - // that matters rides on the bar. Countdown when a phase is running, - // otherwise its name. - track(snapshot, at: context.date) - .overlay(alignment: .trailing) { - Group { - if snapshot.phaseEndsAt.map({ $0 > context.date }) == true { - countdown(snapshot, at: context.date) - } else { - phaseTitle(snapshot) - } - } - // The fill slides under the label, so a shadow keeps it readable - // against both the filled and empty parts of the track. - .shadow(color: .black.opacity(0.7), radius: 1.5) - .padding(.trailing, 6) + track(snapshot, at: context.date) + .overlay { + HStack { + phaseTitle(snapshot) + Spacer(minLength: 4) + countdown(snapshot, at: context.date) } - } + // The fill slides under both labels, so a shadow keeps them + // readable against the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.horizontal, 6) + } } .frame(height: 15) } } - /// The depleting track. Greedy on purpose — it takes whatever width the - /// labels beside it leave. + /// The depleting track. Greedy on purpose: the labels overlay it, leaving the + /// whole panel width available to show phase progress. private func track(_ snapshot: WatchSnapshot, at date: Date) -> some View { GeometryReader { geo in ZStack(alignment: .leading) { @@ -335,16 +359,35 @@ struct MapPage: View { } } - /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` - /// does on the phone. A 3-byte zone yields 6-character IDs, which at full - /// size would run the box across a 40 mm screen. - private var rowFontSize: CGFloat { + /// Largest size allowed by the existing hash-length ladder. Width fitting + /// may make it smaller, but never larger than the familiar phone treatment. + private var rowFontSizeCap: CGFloat { let widest = heard.map(\.id.count).max() ?? 2 if widest > 4 { return 9 } if widest > 2 { return 10 } return 11 } + /// Font size that leaves two intrinsic rows safely inside their columns. + /// The constants mirror `heardRow`: dot and gaps consume 12 pt, semibold + /// monospaced IDs advance closer to 0.62 em than the nominal 0.6, and the + /// aligned SNR owns 3.1 em. Two points of slack make measurement error land + /// on smaller type rather than a wider panel that defeats its edge clearance. + private var unconstrainedRowFontSize: CGFloat { + let idChars = CGFloat(heard.map(\.id.count).max() ?? 2) + let columnWidth = (panelContentWidth - columnGap) / 2 - 2 + return (columnWidth - 12) / (0.62 * idChars + 3.1) + } + + /// Seven points is the floor at which two columns remain readable. Falling + /// below it is the one reason to spend the extra height on a single column. + private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 7 } + + private var rowFontSize: CGFloat { + guard twoHeardColumnsFit else { return rowFontSizeCap } + return min(unconstrainedRowFontSize.clamped(to: 7...11), rowFontSizeCap) + } + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder From 2fa0aef6d3f11753b7c8520939ddc8cfc393af8b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 20:38:01 -0700 Subject: [PATCH 182/360] Raise the panel 4 pt without spending the curvature clearance on width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement and clearance gaps are now separate constants. The panel sits 8 pt from the bottom edge, but its horizontal inset is still evaluated as if it sat at 4 — otherwise the geometry would hand back roughly 12 pt of width on the 46 mm, undoing the narrowing that was the point. Keeping the lower position as a clearance floor is also the conservative direction for curvature the simulator cannot show. Measured: both insets unchanged at 23.51 and 11.34, both panels 4 pt higher, and the fix recentred with them — 92.75 pt against a 93.0 target on the 46 mm, 69.75 against 70.0 on the 40 mm. Also verified the six-character worst case on the 46 mm, which had only been checked on the 40 mm: two columns at ~8 pt, no overflow. The 40 mm remains the sole size that falls back to one column, at the 9 pt cap. --- ios/MeshMapperWatch/MapPage.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 064d037b..08d7e016 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -64,7 +64,13 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - private static let panelBottomGap: CGFloat = 4 + /// Placement and clearance gaps deliberately differ. The panel sits higher, + /// but its inset is evaluated as if it still sat four points from the edge; + /// otherwise raising it would spend the recovered curvature room on width. + /// Keeping the lower position as a clearance floor is the conservative + /// direction for hardware curvature the simulator cannot reveal. + private static let panelBottomGap: CGFloat = 8 + private static let curveClearanceGap: CGFloat = 4 /// Panel geometry is about the physical display, not this view's proposal. /// Reading the device avoids a feedback loop where panel padding changes the @@ -77,14 +83,15 @@ struct MapPage: View { /// watchOS exposes no screen corner radius, but its bottom safe-area inset is /// the clearance a full-width element needs at zero horizontal inset, making /// it a useful estimate of that radius. The circle/chord intersection gives - /// the inset at our chosen bottom gap. That estimate is a lower bound on the - /// glass curvature, so four extra points are cheap insurance against another - /// hardware clip. The rectangle test is conservative in the other direction: - /// the panel's own 12 pt radius pulls its visible corners inward from the - /// square corners protected by this equation. + /// the inset at the more conservative of the placement and clearance gaps. + /// That radius estimate is a lower bound on the glass curvature, so four + /// extra points are cheap insurance against another hardware clip. The + /// rectangle test is conservative in the other direction: the panel's own + /// 12 pt radius pulls its visible corners inward from the square corners + /// protected by this equation. private var curvedPanelHorizontalInset: CGFloat? { let radius = bottomSafeAreaInset - let gap = Self.panelBottomGap + let gap = min(Self.panelBottomGap, Self.curveClearanceGap) guard radius > 0, gap < radius else { return nil } let inset = radius - sqrt(max(0, 2 * radius * gap - gap * gap)) + 4 guard inset.isFinite else { return nil } From f0961c11a5844951cfdfa693ce98f5a0a8653a4e Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 20:48:21 -0700 Subject: [PATCH 183/360] Scale the panel gaps with the corner radius, and stop asserting lapsed phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both gaps now scale with the measured corner radius rather than being fixed. A panel sitting higher needs less horizontal clearance, so height buys width — the trade this deliberately refused last commit, when the ask was narrower-and-higher and only the height part had arrived. The ask now includes the width, because six-character hashes on a 46 mm were down at 8 pt. The 4/19 and 8/19 ratios are calibrated from the 40 mm, the one size confirmed good on hardware, so it reproduces its numbers exactly by construction — inset 11.338, content 123.324, font 7.825, verified unchanged. The 46 mm gains 11 pt of width and sits 15 pt off the bottom, taking six-character IDs from 8.0 to 8.81 pt. Width still binds there rather than the hash-length ladder's 9 pt ceiling. A lapsed deadline no longer claims its phase. The title was never wrong — the phone sends "Listening…" while the RX window runs and "Next ping" while the auto-ping timer does, matching ping_controls — but the watch rendered whatever it last heard forever, so a passed deadline with no newer snapshot left "Listening" asserted over an empty track. That is the state Adam photographed. Titles with a future deadline are unchanged; titles with no deadline at all stay full strength, since "Device disconnected" and "Waiting for GPS" are states rather than countdowns and remain true until replaced; a title whose deadline has passed now dims to 45%, reading as last-known rather than current. The reason this took until now to surface is that SampleSnapshot hardcoded "Listening", so no screenshot ever showed a wait phase. -MeshMapperSamplePhase listen|wait|lapsed fixes that gap. Verified all three render correctly, and that it and the three existing launch arguments are absent from the Release binary. --- ios/MeshMapperWatch/MapPage.swift | 49 +++++++++++++++--------- ios/MeshMapperWatch/SampleSnapshot.swift | 22 +++++++++-- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 08d7e016..b01787ba 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -64,13 +64,21 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - /// Placement and clearance gaps deliberately differ. The panel sits higher, - /// but its inset is evaluated as if it still sat four points from the edge; - /// otherwise raising it would spend the recovered curvature room on width. - /// Keeping the lower position as a clearance floor is the conservative - /// direction for hardware curvature the simulator cannot reveal. - private static let panelBottomGap: CGFloat = 8 - private static let curveClearanceGap: CGFloat = 4 + /// Both gaps scale with the estimated corner radius, putting every watch at + /// the same relative positions on its curve. The 4/19 and 8/19 ratios are + /// calibrated from the 40 mm watch Adam signed off: at R=19 they reproduce + /// its current clearance and placement by construction. Changing either + /// constant therefore changes the one hardware size already known-good. + private static let curveClearanceGapRatio: CGFloat = 4.0 / 19.0 + private static let panelBottomGapRatio: CGFloat = 8.0 / 19.0 + + private var curveClearanceGap: CGFloat { + bottomSafeAreaInset * Self.curveClearanceGapRatio + } + + private var panelBottomGap: CGFloat { + bottomSafeAreaInset * Self.panelBottomGapRatio + } /// Panel geometry is about the physical display, not this view's proposal. /// Reading the device avoids a feedback loop where panel padding changes the @@ -83,15 +91,14 @@ struct MapPage: View { /// watchOS exposes no screen corner radius, but its bottom safe-area inset is /// the clearance a full-width element needs at zero horizontal inset, making /// it a useful estimate of that radius. The circle/chord intersection gives - /// the inset at the more conservative of the placement and clearance gaps. - /// That radius estimate is a lower bound on the glass curvature, so four - /// extra points are cheap insurance against another hardware clip. The - /// rectangle test is conservative in the other direction: the panel's own - /// 12 pt radius pulls its visible corners inward from the square corners - /// protected by this equation. + /// the inset at the scaled clearance gap. That radius estimate is a lower + /// bound on the glass curvature, so four extra points are cheap insurance + /// against another hardware clip. The rectangle test is conservative in the + /// other direction: the panel's own 12 pt radius pulls its visible corners + /// inward from the square corners protected by this equation. private var curvedPanelHorizontalInset: CGFloat? { let radius = bottomSafeAreaInset - let gap = min(Self.panelBottomGap, Self.curveClearanceGap) + let gap = curveClearanceGap guard radius > 0, gap < radius else { return nil } let inset = radius - sqrt(max(0, 2 * radius * gap - gap * gap)) + 4 guard inset.isFinite else { return nil } @@ -148,7 +155,7 @@ struct MapPage: View { } .padding(.horizontal, panelHorizontalInset) .padding(.top, 2) - .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : Self.panelBottomGap) + .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap) .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } .background( @@ -319,7 +326,7 @@ struct MapPage: View { track(snapshot, at: context.date) .overlay { HStack { - phaseTitle(snapshot) + phaseTitle(snapshot, at: context.date) Spacer(minLength: 4) countdown(snapshot, at: context.date) } @@ -346,10 +353,14 @@ struct MapPage: View { } } - private func phaseTitle(_ snapshot: WatchSnapshot) -> some View { - Text(snapshot.phaseTitle) + private func phaseTitle(_ snapshot: WatchSnapshot, at date: Date) -> some View { + // A missing deadline describes a durable state. A passed one describes + // only what the phone last reported, so dimming avoids presenting it as a + // live claim while still preserving the useful last-known phase. + let deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= date } ?? false + return Text(snapshot.phaseTitle) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) .lineLimit(1) .truncationMode(.tail) } diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 01f42a68..71afec2d 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -9,6 +9,10 @@ import Foundation /// /// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES /// +/// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live +/// countdown, the between-cycle wait, or a deadline the phone did not replace. +/// Listening is the default so existing capture commands keep their behaviour. +/// /// DEBUG-only, and never reached unless that argument is passed, so it cannot /// leak into a shipping build or mask a real transport failure. enum SampleSnapshot { @@ -24,6 +28,16 @@ enum SampleSnapshot { static func make() -> WatchSnapshot { let now = Date().timeIntervalSince1970 * 1000 + let samplePhase: (name: String, title: String, endsAtMs: Double, durationMs: Int) + switch UserDefaults.standard.string(forKey: "MeshMapperSamplePhase") { + case "wait": + samplePhase = ("waiting", "Next ping", now + 25_000, 30_000) + case "lapsed": + samplePhase = ("listening", "Listening…", now - 5_000, 60_000) + default: + samplePhase = ("listening", "Listening…", now + 42_000, 60_000) + } + let green = WatchColor(r: 0.30, g: 0.69, b: 0.31) let red = WatchColor(r: 0.96, g: 0.26, b: 0.21) let purple = WatchColor(r: 0.49, g: 0.33, b: 0.78) @@ -91,11 +105,11 @@ enum SampleSnapshot { wireVersion: MeshMapperWatchWire.version, sessionId: "sample", mode: "Active", - phase: "listening", - phaseTitle: "Listening", + phase: samplePhase.name, + phaseTitle: samplePhase.title, phaseDetail: "Waiting for echoes", - phaseEndsAtMs: now + 42_000, - phaseDurationMs: 60_000, + phaseEndsAtMs: samplePhase.endsAtMs, + phaseDurationMs: samplePhase.durationMs, isConnected: true, zoneCode: "SEA", txCount: 27, From bbd01ca3dc75f2625fedbf57edd308e3741e670d Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 23:01:43 -0700 Subject: [PATCH 184/360] Add wrist controls, and make every unavailable control say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5. The transport and all guards already existed — `_handleWatchCommand` revalidates on arrival and the wire carries `canStartStop`, `canManualPing`, `isSessionActive`, `blockedReason` and the cooldown deadline. What was missing was the surface. Start/stop, and manual ping behind a two-stage confirm: the first tap arms the button for three seconds, the second sends. A wrist bump must not be able to transmit, and disarming needs no round trip. `WatchSessionClient` now tracks the in-flight command so a tap shows work happening, and drops a reply that a newer tap has superseded — otherwise an old answer lands under a fresh action. Silent refreshes stay out of that state; the wearer never asked for them. Three defects found by putting it on both simulator sizes: A cooldown is the only unavailability the phone reports with **no** `blockedReason` — `_buildWatchControls` sets one for "Not connected" and "No GPS fix" only — so the ping button sat dead and unexplained for fifteen seconds, which is precisely what this phase exists to prevent. It now counts down on the button face from the absolute deadline already on the wire, so it stays right without further snapshots. The page title pushed `blockedReason` below the fold on a 40 mm, hiding the one line that explains a disabled button. Dropped: the buttons name themselves. The reason also finished flush against the bottom edge, which is the class of bug that clipped on real hardware twice, so the content now keeps clear of it. Disabled buttons rendered inconsistently — a disabled green `borderedProminent` desaturates to a pale grey that reads as tappable, while the accent-tinted one went dark. Both grey out now. Also fixes a debug affordance that never worked: `MeshMapperInitialPage` was assigned in `onAppear`, and a `.verticalPage` TabView ignores a selection change made that late, so every headless capture silently landed on the map. It is the state's initial value now. Node-list default is its own page, chosen for the room it gives the rows. Sample controls gain `idle|blocked|cooldown` alongside `active`. --- ios/MeshMapperWatch/ContentView.swift | 39 +++-- ios/MeshMapperWatch/ControlsPage.swift | 146 +++++++++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 47 +++++- ios/MeshMapperWatch/WatchSessionClient.swift | 24 ++- ios/MeshMapperWatch/WatchSettings.swift | 7 +- ios/Runner.xcodeproj/project.pbxproj | 4 + 6 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 ios/MeshMapperWatch/ControlsPage.swift diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 20b9e5a5..38ff172e 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -2,18 +2,34 @@ import SwiftUI /// Root shell. /// -/// The map is always page one and full-bleed. Where the heard-node list lives -/// is a presentation choice — a sheet over the map, or its own page — driven by -/// `WatchSettings.nodeListPlacement`. Both read the same `NodeListView`, so -/// this is a toggle rather than two implementations. +/// The map is always page one and full-bleed, with controls immediately after +/// it. Where the heard-node list lives is a presentation choice — a sheet over +/// the map, or its own page — driven by `WatchSettings.nodeListPlacement`. +/// Both read the same `NodeListView`, so this is a toggle rather than two +/// implementations. struct ContentView: View { @Environment(WatchSettings.self) private var settings - @State private var selection = 0 + @State private var selection = Self.requestedInitialPage + + /// Page to open on, so one can be captured headlessly for design review — + /// the simulator offers no way to swipe. + /// + /// Supplied as the state's initial value rather than assigned in `onAppear`. + /// A `.verticalPage` `TabView` ignores a selection change made that late, so + /// the assignment silently did nothing and every capture landed on the map. + private static var requestedInitialPage: Int { + #if DEBUG + return max(0, UserDefaults.standard.integer(forKey: "MeshMapperInitialPage")) + #else + return 0 + #endif + } var body: some View { TabView(selection: $selection) { MapPage().tag(0) + ControlsPage().tag(1) if settings.nodeListPlacement == .page { NavigationStack { @@ -21,19 +37,12 @@ struct ContentView: View { .navigationTitle("Heard") .navigationBarTitleDisplayMode(.inline) } - .tag(1) + .tag(2) } - DebugPage().tag(2) - SettingsPage().tag(3) + DebugPage().tag(3) + SettingsPage().tag(4) } .tabViewStyle(.verticalPage) - .onAppear { - #if DEBUG - // Lets a specific page be captured headlessly for design review. - let requested = UserDefaults.standard.integer(forKey: "MeshMapperInitialPage") - if requested > 0 { selection = requested } - #endif - } } } diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift new file mode 100644 index 00000000..bfc69c65 --- /dev/null +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -0,0 +1,146 @@ +import SwiftUI + +/// Session controls sized for deliberate use while moving. +/// +/// Availability is only the phone's last-reported state. The watch does not +/// duplicate transport, GPS, cooldown, or session guards; every tap still goes +/// to the phone, which revalidates it and returns the reason when refused. +struct ControlsPage: View { + @Environment(WatchSessionClient.self) private var client + + @State private var pingArmed = false + @State private var disarmPingTask: Task? + + private var controls: WatchControls? { client.snapshot?.controls } + + /// Manual-ping cooldown deadline, if one is still ahead of us. + private var cooldownEndsAt: Date? { + guard let ms = controls?.manualCooldownEndsAtMs else { return nil } + let endsAt = Date(timeIntervalSince1970: ms / 1000) + return endsAt > Date() ? endsAt : nil + } + + var body: some View { + ScrollView { + // No page title. The buttons name themselves, and on a 40 mm screen a + // header pushed `blockedReason` — the one thing that explains a dead + // button — below the fold, which is the opposite of what it is for. + VStack(spacing: 10) { + startStopButton + manualPingButton + + if let reason = controls?.blockedReason { + Text(reason) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + if let refusal = client.lastRefusal { + Text(refusal) + .font(.caption2) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 8) + .padding(.top, 4) + // The last line must not finish against the bottom edge: the display + // curves there, and the simulator's flat rectangle has hidden exactly + // this twice before. + .padding(.bottom, 14) + } + // These are fixed pieces of control chrome rather than reading content; + // bounding type preserves the large tap targets on the smallest watch. + .dynamicTypeSize(.small ... .large) + .opacity(client.isStale ? 0.5 : 1.0) + .onChange(of: controls?.canManualPing) { _, canManualPing in + if canManualPing != true { disarmPing() } + } + .onDisappear { disarmPing() } + } + + private var startStopButton: some View { + let isActive = controls?.isSessionActive ?? false + let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession + let isPending = client.pendingCommand == kind + let isEnabled = controls?.canStartStop == true && !isPending + + return Button { + client.send(kind) + } label: { + HStack(spacing: 6) { + if isPending { + ProgressView() + .controlSize(.small) + } + Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) + .font(.headline) + } + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.borderedProminent) + // Grey when unavailable rather than a desaturated tint: a disabled green + // renders pale enough to read as a live button worth tapping. + .tint(isEnabled ? (isActive ? .red : .green) : .gray) + .disabled(!isEnabled) + } + + private var manualPingButton: some View { + let isPending = client.pendingCommand == .manualPing + let isEnabled = controls?.canManualPing == true && !isPending + + return Button { + if pingArmed { + disarmPing() + client.send(.manualPing) + } else { + armPing() + } + } label: { + HStack(spacing: 6) { + if isPending { + ProgressView() + .controlSize(.small) + } + if let endsAt = cooldownEndsAt { + // A cooldown is the one unavailability the phone reports without a + // `blockedReason`, so without this the button would sit dead and + // unexplained. The deadline is absolute, so the countdown is right + // even if no further snapshot arrives. + Text("Ping in") + .font(.headline) + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.headline.monospacedDigit()) + .frame(width: 38, alignment: .leading) + } else { + Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) + .font(.headline) + } + } + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.borderedProminent) + .tint(isEnabled ? (pingArmed ? .orange : .accentColor) : .gray) + .disabled(!isEnabled) + } + + /// The first tap buys a short confirmation window; expiry returns the button + /// to a harmless state without involving the phone or transmitting anything. + private func armPing() { + disarmPingTask?.cancel() + pingArmed = true + disarmPingTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + pingArmed = false + disarmPingTask = nil + } + } + + private func disarmPing() { + disarmPingTask?.cancel() + disarmPingTask = nil + pingArmed = false + } +} diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 71afec2d..230fd940 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -12,6 +12,8 @@ import Foundation /// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live /// countdown, the between-cycle wait, or a deadline the phone did not replace. /// Listening is the default so existing capture commands keep their behaviour. +/// Pass `-MeshMapperSampleControls active|idle|blocked|cooldown` to review each +/// controls page state; active is the default. /// /// DEBUG-only, and never reached unless that argument is passed, so it cannot /// leak into a shipping build or mask a real transport failure. @@ -38,6 +40,43 @@ enum SampleSnapshot { samplePhase = ("listening", "Listening…", now + 42_000, 60_000) } + let sampleControls: WatchControls + switch UserDefaults.standard.string(forKey: "MeshMapperSampleControls") { + case "idle": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: false, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) + case "blocked": + sampleControls = WatchControls( + canStartStop: false, + canManualPing: false, + isSessionActive: false, + manualCooldownEndsAtMs: nil, + blockedReason: "This zone is currently passive-only" + ) + case "cooldown": + // The one unavailability the phone reports with no `blockedReason`. + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualCooldownEndsAtMs: now + 12_000, + blockedReason: nil + ) + default: + sampleControls = WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) + } + let green = WatchColor(r: 0.30, g: 0.69, b: 0.31) let red = WatchColor(r: 0.96, g: 0.26, b: 0.21) let purple = WatchColor(r: 0.49, g: 0.33, b: 0.78) @@ -131,13 +170,7 @@ enum SampleSnapshot { heard: heard, linkedRepeaterIds: ["4E", "77"] ), - controls: WatchControls( - canStartStop: true, - canManualPing: true, - isSessionActive: true, - manualCooldownEndsAtMs: nil, - blockedReason: nil - ), + controls: sampleControls, cue: nil, updatedAtMs: now ) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 03268bca..7eb9820b 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -18,6 +18,10 @@ final class WatchSessionClient: NSObject { /// Set when the phone refuses a command, so the wrist can say why. private(set) var lastRefusal: String? + /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes + /// stay out of this state because they have no corresponding wrist action. + private(set) var pendingCommand: WatchCommand.Kind? + /// Set when a payload arrives from a wire version this build predates. private(set) var versionMismatch = false @@ -89,15 +93,27 @@ final class WatchSessionClient: NSObject { return } + if !silent { + lastRefusal = nil + pendingCommand = kind + } + session.sendMessage( [MeshMapperWatchWire.commandKey: dict], replyHandler: { [weak self] reply in NSLog("[WATCH] reply for \(kind.rawValue): \(reply)") Task { @MainActor in + let isCurrent = self?.pendingCommand == kind + if isCurrent { + self?.pendingCommand = nil + } + // A newer tap owns both the spinner and its feedback. Letting an + // older reply rewrite either would put the wrong answer under it. + guard !silent, isCurrent else { return } let accepted = reply["accepted"] as? Bool ?? false if accepted { self?.lastRefusal = nil - } else if !silent { + } else { self?.lastRefusal = reply["reason"] as? String ?? "Refused" } } @@ -105,7 +121,11 @@ final class WatchSessionClient: NSObject { errorHandler: { [weak self] error in NSLog("[WATCH] sendMessage(\(kind.rawValue)) failed: \(error.localizedDescription)") Task { @MainActor in - if !silent { self?.lastRefusal = error.localizedDescription } + let isCurrent = self?.pendingCommand == kind + if isCurrent { + self?.pendingCommand = nil + } + if !silent, isCurrent { self?.lastRefusal = error.localizedDescription } } } ) diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 6c66a1a5..e3b9db19 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -18,8 +18,9 @@ final class WatchSettings { /// Where the recently-responded list lives. /// /// Both layouts are built from one view model, so this is a presentation - /// toggle rather than two code paths. The default is unsettled until it has - /// been worn — see the plan. + /// toggle rather than two code paths. Its own page is the default — chosen + /// for the room it gives the rows — and the sheet stays for wearers who would + /// rather keep the map behind the list. enum NodeListPlacement: String, CaseIterable, Identifiable { case sheet case page @@ -44,7 +45,7 @@ final class WatchSettings { // stored value `bool(forKey:)` returns false, so invert an explicit flag. follow = defaults.object(forKey: Key.follow) as? Bool ?? true nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) - .flatMap(NodeListPlacement.init(rawValue:)) ?? .sheet + .flatMap(NodeListPlacement.init(rawValue:)) ?? .page } /// Apple imagery rather than the standard basemap. Mirrors the iOS app's diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5aa3494a..170fd02a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -105,6 +106,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 178C77B846A00943CD881203 /* WatchSessionClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionClient.swift; sourceTree = ""; }; + 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ControlsPage.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; @@ -208,6 +210,7 @@ 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, 775B3F6C0618B4BD5644B40B /* NodeListView.swift */, + 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -644,6 +647,7 @@ 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */, 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */, 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */, + 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 0385297540cf9e1b92869b8309b2b44c2405dc4b Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 07:41:23 -0700 Subject: [PATCH 185/360] Open the map at 500 m, and remember the wearer's zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults in one place, reported from the wrist as "the initial zoom level was very high (multiple states)". The opening view was never the default. `camera` starts `.automatic`, which fits every annotation in the snapshot — continental with repeaters spread wide — and `noteRenderedRegion` then adopted that span as though the wearer had chosen it, so one auto-fit poisoned the zoom for the rest of the session. Rendered spans are now ignored until `programmaticCenter` exists, the same signal that already distinguishes our own camera updates from the wearer's. The default is 500 m rather than 3.3 km. The old comment argued the wide span was deliberate for wardriving; that reasoning was mine and the wrist disagreed, so it is gone rather than left contradicting the code. Zoom now survives relaunch. It was `@State`, so every launch discarded it. It lives in `WatchSettings` with the other preferences, clamped to 0.0005...0.5 on both read and write — persistence turns a stray Crown flick into a permanent state, and the clamp is what stops a remembered preference becoming a trap. Absence is distinguished from zero, because `double(forKey:)` returns 0 for a missing key and would have opened every fresh install at the 55 m minimum. Persisting only on a material change (>1%), since every follow update raises a camera change and writing an identical value would invalidate the observable and re-render the map for nothing. Verified on a fresh simulator container: opens at street level and stores 0.0045, which is proof the `.automatic` span was not adopted — that would have stored a value orders of magnitude larger. --- ios/MeshMapperWatch/MapPage.swift | 33 +++++++++++++++++-------- ios/MeshMapperWatch/WatchSettings.swift | 33 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index b01787ba..d0dd821c 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -550,15 +550,15 @@ struct MapPage: View { recenterIfFollowing(proxy) } - /// Preserve whatever zoom the wearer picked with the Digital Crown. - /// - /// The initial span is deliberately wide (~3 km): wardriving is about what - /// is around you, and a tighter default opens with every nearby repeater - /// off-screen. - @State private var currentSpan = MKCoordinateSpan( - latitudeDelta: 0.03, - longitudeDelta: 0.03 - ) + /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one + /// independent zoom value. A fresh install starts at 0.0045 degrees, about + /// 500 m north-south, and later launches reuse the wearer's Crown setting. + private var currentSpan: MKCoordinateSpan { + MKCoordinateSpan( + latitudeDelta: settings.mapLatitudeDelta, + longitudeDelta: settings.mapLatitudeDelta + ) + } /// Centre of the region MapKit last rendered — the fixed point every /// placement is measured against. @@ -568,8 +568,21 @@ struct MapPage: View { /// thrown away on the next follow update and so placement has a known /// starting point. private func noteRenderedRegion(_ region: MKCoordinateRegion) { - currentSpan = region.span renderedCenter = region.center + // Before our first camera update this region belongs to `.automatic`, + // which fits every annotation and can span a continent. It is not a wearer + // choice and must never become the remembered zoom. Once we have driven + // the camera, rendered spans include our requested value and later Digital + // Crown changes, both of which should persist. + guard programmaticCenter != nil else { return } + + // Only on a real change. Every follow update produces a camera change, and + // writing an identical value would persist and invalidate on each one, + // re-rendering the map for nothing. + let rendered = region.span.latitudeDelta + let stored = settings.mapLatitudeDelta + guard stored > 0, abs(rendered - stored) / stored > 0.01 else { return } + settings.mapLatitudeDelta = rendered } private func noteCameraChange(_ center: CLLocationCoordinate2D) { diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index e3b9db19..9a4f52bf 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -12,9 +12,14 @@ final class WatchSettings { static let satellite = "map.satellite" static let showLinks = "map.showLinks" static let follow = "map.follow" + static let mapLatitudeDelta = "map.latitudeDelta" static let nodeListPlacement = "layout.nodeListPlacement" } + /// Roughly 500 m north-south: one degree of latitude is about 111 km. + static let defaultMapLatitudeDelta = 0.0045 + private static let mapLatitudeDeltaLimits = 0.0005...0.5 + /// Where the recently-responded list lives. /// /// Both layouts are built from one view model, so this is a presentation @@ -44,6 +49,13 @@ final class WatchSettings { // Following the fix is the useful default while driving; absent any // stored value `bool(forKey:)` returns false, so invert an explicit flag. follow = defaults.object(forKey: Key.follow) as? Bool ?? true + // `double(forKey:)` turns absence into zero, which would silently select + // the minimum zoom. Preserve the distinction so fresh installs get the + // deliberate ~500 m default instead. + mapLatitudeDelta = Self.clampedMapLatitudeDelta( + defaults.object(forKey: Key.mapLatitudeDelta) as? Double + ?? Self.defaultMapLatitudeDelta + ) nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) .flatMap(NodeListPlacement.init(rawValue:)) ?? .page } @@ -64,9 +76,30 @@ final class WatchSettings { didSet { defaults.set(follow, forKey: Key.follow) } } + /// Only latitude is persisted. MapKit fits longitude to the watch's aspect + /// ratio, so storing both would preserve two values that are not independent. + /// Normalising before persistence keeps a bad Crown result from becoming a + /// sticky launch state. + var mapLatitudeDelta: Double { + didSet { + let clamped = Self.clampedMapLatitudeDelta(mapLatitudeDelta) + if clamped != mapLatitudeDelta { + mapLatitudeDelta = clamped + } + // Persist the normalised value explicitly rather than relying on an + // assignment inside `didSet` to invoke the observer a second time. + defaults.set(clamped, forKey: Key.mapLatitudeDelta) + } + } + var nodeListPlacement: NodeListPlacement { didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } } + + private static func clampedMapLatitudeDelta(_ value: Double) -> Double { + guard value.isFinite else { return defaultMapLatitudeDelta } + return min(max(value, mapLatitudeDeltaLimits.lowerBound), mapLatitudeDeltaLimits.upperBound) + } } extension Color { From 76d8a3bb24929724d92aea4376af1935f6297fb2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 08:05:20 -0700 Subject: [PATCH 186/360] Cut to the fix on launch, mirror the app's ping markers and ping gate, add the icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four wrist reports. **The map flew across the north Pacific on launch.** Two faults stacked. `recenterIfFollowing` always animated, so the first placement was a 0.25 s flight from `.automatic`'s arbitrary opening position to the fix, dragging tile loads the whole way. The first placement is now a cut; later ones still animate, which is right for small follow nudges. Underneath that, `.automatic` settles *after* our first request, centred on the annotation cloud — measured 372 m from the fix — and `noteCameraChange` read that disagreement as the wearer panning. It suspended following for eight seconds and overwrote its own expectation, so our region landing then looked like a *second* pan: [pan] SUSPEND dist=371.9 center=47.611891,-122.323025 expected=47.6122,-122.3181 [pan] SUSPEND dist=371.9 center=47.6122,-122.3181 expected=47.611891,-122.323025 Nothing counts as a pan now until MapKit has confirmed a centre we asked for. Zero suspensions at launch, down from two, and the fix lands 1.9 pt from target on 46 mm and 0.25 pt on 40 mm. This also explains a transient recenter button I dismissed as mid-animation two days ago, and it means the placement regression in `a846153` was mine to catch and I did not — I said the puck looked centred without measuring it. It was 57 pt low. **Manual ping was offered where the phone would refuse it.** The watch gate was `isConnected && hasGpsLock` plus cooldown; the app's own Send Ping button requires twelve conditions. `_buildWatchControls` now mirrors that set exactly, through the same `manualPingValidation` getter the widget uses, rather than a second implementation of the policy. `blockedReason` gains the app's own words for the two states it names, "Offline Mode" and "Passive Only". **Ping markers are circles.** They were squares under a comment claiming they matched the iOS map, which is how it survived — `_CoverageMarkerPainter` draws a filled circle with a white border. Mirrored at wrist scale, minus the shadow, which would cost a blur each for up to sixty markers. **The watch had no icon** because its `AppIcon.appiconset` held a `Contents.json` expecting an image and no image. Copied the 1024 pt iOS icon in; `AppIcon` now compiles into the watch's `Assets.car`, which previously carried only `AccentColor`. --- .../AppIcon.appiconset/Contents.json | 1 + .../Icon-Watch-1024x1024.png | Bin 0 -> 104491 bytes ios/MeshMapperWatch/MapPage.swift | 46 +++++++++++++++--- lib/providers/app_state_provider.dart | 33 ++++++++++++- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd8..88819d5d 100644 --- a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "Icon-Watch-1024x1024.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png new file mode 100644 index 0000000000000000000000000000000000000000..ee5cc0cd157e54d755ef5992f3dc074fd3806e13 GIT binary patch literal 104491 zcmeFYS5%Wv&@lcWDk4opx(J9My%!11LXqA&+hEZ?9A-!?CvB|Pe+aFD$7*>0I1X-D;og7 zMPl_LaODEA*n&2X0wl>h>dFs{{4>^3WM3^!Az1uA*mXleqNqf({K-pCCQ_c)e_A{r zU+J=GGcIQe!`%6N@jAy($fN65gudH^iAkr}vQE7esj`oWoS&PW4SJL{%li2F1#<3- z538s7B!Vh-6kP}7g?c~Qt4lZzq~h`N@(;)Ps?`prg77BCgpFL|ZUG`Iz`hL`gye5! ziTb=Krv1wypvU!>Um!>I%HQIC?6to|C=2J`0+4!mUbwtIFGjEY??9p;{@)5m)J^$L z*S(d}HhC4GEz5p9X@CmH4*+cmoh$6Rp+KmD-5w3lcI`=xlWlJjJiPTP<*mP3$YEf@ z=SBZd)Ixa|M<9;0NxlMfL?V@lLwzIvC*uW~J{^z&07a*Bk%Hes7a19Zep2c}TUh%F zI{*mYKp~Yzl6UFOEd9suiS)l} z9^p9jZ$I##iIKMX<~-p1+h7CO#%MB#OxFbT=7{9`o*O9>r7D5+!QWospJj%&FFbC` z_y@nmgdb!+NBcjs>>QDmXGG>hk0XJhpqMjaRn(If2#h(G)RM%7`yygE;`H0()qoXd zRRWQA3gHUSaz1;2;sQwy855qA$nR$kU|j_6bLHufDG>)C&v!8tfBW`1!Y`rMfCCCS zD6#qS%A{Rf@XBXT8XP|Z(>c2gu(!8xzNKRssm?UGhYZ*MHGWA=m39D|7Odf!o3v=GvJ@JE+5Vv{u<_vW}mAG z7-AGhpKC(&&aJr`GiBbiY#)xfg$%M24 zfM}6_L7sN35Xk@kp2FrH+#0gthd99!#{|4R2b$kQVbA1@AF#)re}3*6M5F&I_&(cc@Sg5p-1j%+PZ+b#5NC&IqG~Qj{A~*jEZHTF{#yg#7MC-2 zCZM~uL}E7Q63~;YWy90`?bS2vzUB4(5#h9gCe1WE#*;rMgF(#{>we&Tcy3BdLm(n+BwumP|VqRUt1neS9iS! z>rMx7zJb_`N8Y`KS2|n5UWU-)RnCDOsPO;f2FQ1N7Q!7Dy@{RFrG8G#njCnG^%v2cNN4xTkB@}jZ`{UXK^wo4RHIq-OYcKfAw>> znK$YISOrWO0N4;6CJ+h{Ez2X!6Klkuukyd*=@XfRb4`U>F|o7HH2TaC`8oW@k1*xp zf4q)T{3G$tFu%B(uU-2_gzNqXS5Afg|KNI`Uk2=A4nzK}^gg)`X5ich+a&9|7)(_Fxf10@8^505it^;gh4nm5@o&F-Y#Zm{f{q@a& z|Ah{Qa|H4v=c4`3G1}1l+j&-~&Rf5b{TB#S_4$H2i4&Ij$M7vd7IuRA@-oqk=9plB zsFtP#5m|~-QpJ;X5zKc>5EoZ~TU`mvo z76`T4(loiby$-H>Tjm@W%jVm2RMC8h2Oo#~QR8v}KzG~~3)OQwt4jB1^4Ks7Z)LEV z;{<>v(ZJs$=4xz8BRA9IyQBqlWf!MJj@p7YgSy$Nag6{F*<>lGvV zdB;AZrRa*lg(ae2{zCCK^E3uC{{7j9ROWhrZ||oh}ql&q+IubB9@au*3i$Q$IRFpPn6hoeo zFerja;)O$Sr+zm9*DXvK_Cz=jO}r4OHfFXpuEveI`@M9J6`r`= zQ`fCZ4H>V_CM+kT3BFmk0nxbT(`YbNbh3_IGBZ5plRfg2W%R{lk45oD|9$d7|Ms^^ z!RVQkcB0#F^DP4JPi=F>p=K)6!%Gd_E(1JF*FV6!8Fj z4;V#pvtqNEt>hQ-q)n=x;B>M=ii&!5dLV>e*Z@A{5&D`S6DPseM?c&Yi9GJkW0Gwf@Tuie#Ua& z=_Z!2@%GGZXo8xOk_6CutFZsa*1PXm6sFCa=|r#6TwHfx{=Lwdi(ZS_kV!OC=dOeS zZ#B1TIp{2((JEj}p`xhqCP3LH(0WwRpJ!*^2j5mfI;Yf-@G47<`SQaGrNuHY_@>Xe z+im32Xxl08xJ|w4mOqh&%B@6`U493IHU@R}vp&P@l%yQsG~b!1U=8uQ1rPNeMEdf( z-=@+~iEKZfO=035tbU)|f4etMcJq>H@NdPxhCAD6(-9#AN9vTpqIQK<*ZY@R?BiIeAXUA0wI`a4)0h#8r{qPVOK#$Q? z#!ZkxB%Rjc>xa(Nh}r_vu|Rl!k2|LmcMmUnVsee2#E2PewJbk|6L!zNdi~u()?spX z@UMDnnU(qrlmH2^?=vTrEs`Xd+P5QVc*EAH=WWh#1#+ckUr9l*E`fJU>D_AAs~Z<< zcn#lavDBWvv(srhTHDs52ujMhg*Yrh@!2R%@Rd|A}55s*^RxOnPc2%ubZ9d1H za9#gZFOjD8_WD*I9W*^aS!+5A9jrqZScJJd33&J#An2N%*pw3o?2XBXx}o#$1sYK* zp;1RQNv_kJD`iiU%kN<+WZ(#=jhn5UHXp74#mSl;jY4IXQ%JL!OOgC0>1PGz^hrhv zjX^ty5llO!zN^k^=AAa!b3I;HsbCw~yBQ?G3;=<@e#f#F@gxnT zbZO1lx05Zuv3>X3 zEo;y_LmD*MW%R+gTWO<#9k&&ZIH_w04hKYot$hw5O6+D;LXy*pk4MMef$7Ci5f-uX z+Jx>D6;?kl^xV(&{5S)1HFhhrK(S$yMk#S5$c^gIee@#HBwj3aD`v3;Pu4Xre!_lz zIr;|~1cwzOoM+;oeZIIWup zWsXW%Qs{P@T!e;KdFN=tsDYsC zc{^f(V;ieX?x10M$n*55NvGHf;?_?y@bl!tanqqTn^*4FFG&@HoI~8->HKi4+)*gbLy9Y&=y||+o3mu z2;l{DmB&v9td@iE*jFn*3UTFBg+%Tqrwd!(EmyBIu>YjA&d=-5|G{04_Sr}k!3?rq zSfU^r>->5;RyYZsQJHJWY{h?-(}dAJRsG-(;wM*WFOMo~aB7rZsevmS_deyp12y|t zU(k{FiWXz;OkQ4T_F2p_0{iF?vF3NOHxAhFS_OBvbXFxMSW9`88bYL#El|QyF!dmp z34@IvTCQJE>ATk2D;Mc|_@!68_cYgBZmDVV*2LU={v$vJ)f%#qiVw7FlfO^AXOLS? z$R0GgwP$BPU7k>6(M-en)n&<1M&_3G>UtI8F7 zNyS}i-Q)7RpA6B2WpMEF%*j4AiOtuHpkDamPNGGk`r+&^iM+Ii&3tZ4D`VHkd3XshqilYp@VBG#u8FUUq(8k`if|3bN7+J(_fFQPqzg&&$LcfYr1V{-#~wf@N5LKP9&rSE45eH)uA0Mkpd%;(?w zR{Wb5Vhp&ZaZJq=s;uzgV7#>uv!5D4=aYWPn`gDFW!n3k?78BY23k89-7}R^SjBT% zOXmG2<3mDTKSr|7!VdAB5bSSx;+@6#8Wvxw&6PJ}v{c3idJ{YR!wnOhI)IB3fv~K4 zf}4FlveavSS}nZkXLi{sIhM`-KsB?q80Y_dtS*;n>{-!Ge!1)lVbF%Y+woPobpfY-?9}FMsCB^XQ0E`m*^oN?VDPA zB_q)kBOf;pdXq(z{P03D*?t3-&0aPhM6K88;J+K=Lc@7tG55075{q61UeRAP%X0B$ zu+Os3w9;i#c(u8_#%oL;RfDs1=cFgckmtraadib{vqTqQ@@=#NLu((oxmNoJpAI=H z=80e#0EVv=*hbfxAO05U5z#`fX$@(D)4z=cJeT;{hN&JDq$X`h;Ka0h9{cr)1c65A zZ#7j*6t7al8%xRcyZN>9g_JAf?cW>@z~H$&)S#m`rqHFiiRP*b&zmVh7XeCmL22A% z?(PSfnz->x>H^I@1snM#Zr+7Hm<(~yX1}bB#0;VIgd-Bw3Y0f}7p&vIl%~<^qm-t@ zw!)$4Vqa?5ftz~fOtWXQWvBp(;Y;;sxoW3l@3eIxrgO(eh6%R@|hjh$%2+|n>} zdEsPYDGj0X(E31$5IV5Q#7|_qD7gaRF7B$5pgnlQTvNCj&+b^i)4k_9+gtTw6-no? z9S?3gZZ|FZ>AO$wT)^S3boF=5yA)^oSm)-zj+Sf?{>{k=4~Y$L^X5TNVNykgI(?#H zLJ~3}Gc|44%xl>U@7=d5wpND{BhX<_r!sR4xEL)UB_=y7?Y5=U?X0e#IooE16#XQVxq(;jAh}Zc}|K*gNi9HDZ~JYrX`Z5(7d4hUNHKLAOD0 zpK~?GZx|iPM}E>yc{VG}*l#_>MXGm07TyOb26aUT9_#>LM6CiRy0U9kOCbsRS7ner z;S{#MxA5I4W5*d&9@UY48vR7fCaRyYr)bf_Gq#dPDgtfknV4p2$B&Sy~xrP-s z+dTtL>2Yf^Wrzj35`t=^`w#FIvJJ^91lu=ann;7 z^f1LUh2L`D+@B#0gY;Q zz}4B{RAH;HN^2GIi)G5_o!IMw?pq^>Xp$vj{P$=vBi&?|_y?I0!O3FVcl=%P>>ebYo#*se4c-gvZd!!Y5HUeh$=EK=9q zzoRq=%GWk~o5+?R=(ITd-98V^XHn3Sd5>NoJ6w9shn)>oq@IBS_wml9I}Bmf` zM6Lo91MZ0J9f}IXi_^C7@6&Z3o!rbot~F8^N>c|z6`f_P>8!l{grkrkz09kIsGl78 z$0G9^>>uwnSGA&DsCs5|LoNIt6;XX^Hc_Bk~!PhLxJ#LTMi-7tJt&J$tI(%$;nDNA1^S!zED2(#y zaJRf`2DDp2X1I@*f`J5}m}xMDx;WVC%Xz9+#1`K!e0gG!*}QK)%)@Sf$E4HG_Icyg z%OC4}E~jQHuhyl+LxQ~JIpMkV-1f+K10DfKN!6I!BQ{WBizKsVEU!YZ-^dd-E{86F|Tyu^zn=V>x<94;r#{$_%c{yUM`hJ&E%@iX1(bm zjdOfGh|&@gw?WZbq6lRdzyI89%DK-CKT(0V8brtflm+R75X|%p)=kjNm}%{y9;Xv> z+=-U%;g-TQ$+J>yp5Kv&&fxf^s+nv`R8l<3q#Y#1`&>Hl3cZN$@K^4)>|^PgBuh7m zlA7$Xdw&uz_bxW#aE||{K?!@FH}3*hf=tPY=VLX9&orL>(*&}}!SX?_iGE*#eeU@&N4?6rnvn9m=Z=Sdl$8ve`S!d77vk89nl|pj zg^j9Do}Tb3c@ru@{3h_r`HWu1Ejfih9j4jfiL?@i4DIG};xCr8>*rbz)^9aG{TqIL z(SzgvERTIOmYDVh**jD-J*q~!B-^J=PZ*35jI0gVI4ZtzC7ghYQ9>NpncHpaOGxkE z_c9+AyQuY@>EA*<30jM5J&q-ubz|q^WUdk~2e$hdTlTWZ)+ha&(uVmmsxfE$+b`%d z?CT7c6qqk!WS-G0`8v+WO74CJILow%TWXdF$$>SH;(Oc<=26oRI zOuz+zA>XliIsEKP2So8!Uat{z)WBoAd_!N`!z?yOPc)`ncp9cw7+u5bZr1};za@cX z0lFcR*%2q#jp`K&R#x@nb=W?&FPllzJse?T3)43U%+}V`A!H-)J?wb>?6d2D%~zk+ zGe6C&kSG?b(S!1!h5qkHqff;K4tAoD~alm44U1>{%-&eW;%{{+P1lmVUL=i@j}pz~O!3RrVJSHvid558K>?EK+AJY|Gjw zk0J{K7uy|n!lHu`!-vvXm1+bdd1G3%i}~84FB(^u(=?%bMu(a(l;{m|4(Ae6+uC(jyDwwx z^vH-Z9~{m?Izug$Oz;lTbI3bdzF1rtE|~H5E|0nX5{Yy$KJ0=Bk{0$i+kU?Oq^){P#ahCFD7{}+tBwzie-DWo`$+f1<-k~?XISbJnQg3sn1DC!+V2H+eCXIP;NXT4UGay(n|<`tO#;0S`5h zDp%-Asr~2J5rkJtfy)_|6kotsfG3ZWZhC!_P6%)ZZRj|}yrYA=?O%JR!Pt)7WFCsH zLZUoB@k6GI%dTPspEQQ8+T5@C%zL*dm2iYZAFd<&~K>o|tWitz;-sM8-G)?x$kp8NW1gDUqjJE4>W%vF*OQj-aYjQ&^_@jZ9>het^i7gaI1_#~ zLGka=Yx|lPfRBjd7C-sJJa#kRVLl6EP2)U*2R?+hx(OoJGK@M-^$h~4chh+9-+X+` z#n1MzY&C(UQZrc*YiBCIHRHLy)(M=%5zWCacs~suJIkn2+PKhUrNSvbV*&t z2{AIUj)@$y67xR|w@d!XS?FGfj5>HzJ)TqudYSYxw6~{ZKsUsHxfI;Mj->&}r;Ge9_wf;3UW7yi{+oARW#=)iJFF5~eDzebp0*;A^Ib@dpB>tX ztH3M3q4wu+nx`I>7#--3g)}O7y8#bz2SN?N!Tkk{bKb6Ai=6i3&r5jYoXN(9c$6m) z3ip?g<85QbO2!urK!pa^jCDmBVO&ooa`UG^c)AHU|BeDk6GILe(LR6>44%D;Ou>FS z&FIhFmBQK)*kja_Mo4gJ=CQHNVQX^OxL9I{fO6PXb1nTwF}id^i;3)@Km_rbY^c^_ zV~PVoW65aYvC0Xa?=@4%g9!cKo{ zD=%PkcLH&AslxwPX?nDvOkIM`V#EiHy}m{6@2f^nTr_??8Zu+S*MA4m_r}LdDEck1 zRosILS6rSHxCr$5i8TIBcxtO|VGs*t%M~xAth9(h7r{=ajRmSPKRmP52=r`uE(PQq zKR*fXD4zX7-}0wmgOb>kYZKOFy5m63pp8x$Gnb1~=y|A%tcg#UQMK`7!v{k-`KyYB zk3dXic6-9#-8Ev_g7v^6mnU`qlFWMxPaUZ8PM|L}TE#7Lvvt4*fP4@!5IsgAp-^_f z80)uT^+bAjNaD@h=?JU!?oMkf@hp+-5xB7?+>EK?7ND6c>#LKL#}Z_}tcu=GJ51Ia z12o>eGVrpA|8^2FKJa!_pe^nFAI77z1|(s z!gk#nV>>N!I8&IUM%a_Yw6>E^R|B_zNtCjI9_;y3N!IOzglLDlV4Psx8EIQvnMKgJ z0Ja5c;T11M#Vt4SYVGspLZOA%!=mf=+Y6Mm-oDbV0qt{qU^3>@UvuizD)}1RxdX-x zkrBGF2COW*v?RdpbZ&T7RRc1r-L)X&P~V8F+TJW>G)_SK@H@Wr0NXSoD|f1%0p|vt zI4)Jb$TjrkVWC>+snk2$`|P>CyLJHx{M}91iKEk%>tr>2?ILfDs(DoZ&~miI`FWt0 zvXVv1U5qq!bkP1van`*VgzX7m4NM@$5XqnEN*AU%$fck8%gen^8Tw0K?QpN4H+tseRCe|KHM{Z>=FHm;4y7}DBXVe*96tDvnBUm6-0vB%9h=Rp z@GMq_E`8?dx5o}+F-JP@gizPnwf7VQJ%?Y371UBW-}E%le{1C!y18pxlv?1uC0Ga! zzlz$lt6aT+aU?TnFM^lc9+MU0`kgoj`(4G|3*JHs5MK}=?V?!XDFxceEl^lW&KU0k zQ@YF!NjqT-$j9Ub?2UAIAdm?hl_&D=^rI(WsZtW~46jrJPM;u})Y`e@%2|iSy(eN2 ze1PlW;RXp1IKR0I4mgOgP-Tcyubz*tqxqP&!&3T)veJclD*86EI+lx_k#aa7)T|1! ze-E#JLk@!)=HerdIwrm<2CROw@L2mp#cnLcqnmg3M1Sl>>6{NlclS(xn2^C+WHjY2 zh>f6cEgTIa|B~Ay5 zCEM-n4Y7uoYs7G>vOlc#my^RIW`q=i@CMZhvWgqm;nR-`C9iR}SO=;y#TOf=>Oe{& ziYu#H_D={0ooL8RNb3XOJ`M3b9=bKG3k`AWpSO3leE8$jSmi29cKYh}=Tf1(FsP%G>68Kg-|)JLTgsf*}#!y@IcYsxeUY z%!|X1I3KU)ubw&2k9SS>#Ux!D`5fw0lXxxM&y09h)5ZEPmIm#u0>oHj*H{F)Xyw=7 zV5nJ`7saXF##j=rHT}og5Od4k==p>FJjZ8#)-XAhv3?*|tNRTfcfg!HcA9k@{PIBs zm~PUK=x^i@2k~)D_Puuv-&VkWIjVeI6%CEz?3=Sb1XJcdlh!%%J~d54by#yM)3JDjf7jfmgh;^8=@W!)TJ&(qER(m#@T<@o>6?w+fTeLGIB;6X zru$I`s>m2r#gNE=x#zjlJo@xcTX@j%$F>kIz-G=Y;18`~Z2UJLnNy`DMf{H&nuFGE zzG(!cmzBZBwS8n~ii@JRW~>8$O%z55eS827It&Ur7zRSEXLj(|*jv8dNH>Pt{L190 zpJ&$$KWA;~+h9zfnK1vNkGYrbLDJg1(|$tnb^*g%OZ1CfE> z7C8O!qc{YUu;@qMGwi+I+}b@SM!kBkXVaVDeFg?LM}l|$)cU7It)jkIBuF}} z-hzFOc$D1#twI9ecREd5PCqswBu8BOco!S#)SZKh4#sv~NZ}@iH|V}JH6l-z?PUUeYuvU+pL*IU%l!WBb!BE*VT2FNCYtfb$aGX`@b!#}qcR#)UvvBx}Ao z^N}1og^mbR+#$Y)*Fs!2J=`Cad|2ZckpSO2S;q%*18o`pN1rR=5_11_aHS(oPL|cpp zBX)W)A1l)~!@s~Z=kMfrj5>{^9uIm#T@he% zK$KCs84q_XYqDSeH6~%6XmWDRfSiMPTG^WjnjrBv3tQtdl!DOc z)3c_fr3;~Vi4m@RmCHUYO;IjYZjHF}oWt9avu+qDeB*FUT~f@STxZQlH^rY4M-Jl6FL2nuS ziN~0<7>bcPm)o6+zk1-$xThGN2B7GRLmclH44!s6o2J1;b?ZZweGX*oL%*K1iZunb zu5XNUu-opp*cR5tHd-})nCgs9-q@ET!G#j_YwNbJZ`gD6WZxs+sJ(x{*CUm*ak_%9 zEAJU?c<>*kv*T=;vjHq6&K&3~AuKCk<#t}v z-L3a%I;Ll&!kOcKx7N4pXQXd3SyjT|HQ1zho!VP4He97&_2VW}Hq&UcQFB(29#_ok zNwL^n#Pg0=MkF0TN}PZ5R&!~^Y7K*SLCZ&w4`ByWlayeSYogouRCBh3az=-JdW=1& z1oSOmmPMWZcjMx3WoEmyp4h~VH4&x(wLF8>j2TanWTC;T@mIQRvKtD}#%&tH8Zk?& z?WXKLDHeQC^+i5UDr&ahU}l^<$r()7nQ}6?du+5PdpWs~kY|ij z4~~PF_S;127Euqgysnj2IY^R|J=m*N^K9mb7=Lj(fr=^WB@^qy+*H~olhkCS;G z-P^O@P}wy*)7gU=&zpNbBr8qev$kwuxnpKS_=}O78#g@uv^^R$^IH>Qq;~X4(?;W# zm$r3LJRsBr_vFfmzkL4Gv4?ZcnR;SGuCH?~=QIpzKlV&Ds%yn;qP%J2BgM(_@3vb+ zf8y^L@<+tj&T>YzgBOiqN{o`uFJTF&GEO^e$kt5TuV8uQW2br*a8_?i)w!FVWl2bz z7d$0Y&20Mi(yTmoh~euMheK<+l5hF_Rx!24znMTd44IPU#AOj2v9iBX2d6&9CDq9) zSV^jA<#Cr%gH9sSJz8vt*=6O2(ybUDk(r}0XS$J0;n=pO>a$i5zyr&sfAug%SiwLm zrjoL9rkyA8;;?U+3P8z10))%>9wE~^kFLEWw@Vd{_EzjYF0O@C?Xw(s-z#mh{bH!| zVf9l|{3xN<<%J?sUdC%$dh^T@Av7)-9}M0dIR<}KH!0_@E{uZhz36k0P+6DsM!Lk_ z3hHCdCY-W~K#y}OXzznFaPu_m!8;P{bzB9`#ZGDC%G-wgk}Sk*7V3bXjukCyGQ!Mz zQU`N~h;5p;@(X4O?cU0M8PuFUDe)e6zpNrFM<_o+*ORfMzDqyZFG;708Iy@eiaq0Z zLRxoU1Koc5>~!5#5xQ0|>n0}JK?e+T5@hdc(n>JFy-PE5(ytw%gQv6m@Ugdy0eO_hRoD4Ox{8Vu zeN~fTVwFoXO8rH=tE@!67f`Fx z@&TCz;pU;N--feA&i*87zJFpRUFF;ThF7c_343oK_E_+0>BB6ku-e#g8rj1xADL|3 z%RodoG10|sz2t7wHh01B?|he6AG@l|j7zKJHiQCNZTD9htu5N^IjK?z(L)4Y&ZU0X79b4c{)2=%1zs-Q48ZE%8mx(v< zBjvc!AINTnn=B7xm18ZsuPK!WU9p9?TR!aiRaDL4W^|pPJtE7x{&rA<@`opMma* z=0zDWB94({o$LZJLos;{fA-bixuzk>$@cI=c@*lTQimRAT+cH&C%CbW%KALY(p5Z) zlfhk!`RKswc(yB!oqNa#np>mS$5is*ljuDVUs54*vetLouE=`-7B!GVQ& z;&!TCYNizpD_!ZW&(E$gTqbe(-B+q45jguoX^)?!v}fo`mztv%NPJmkv1BS&IFCZ3 zM#|9MJ5`dHCRnvc@8@|s8?DQ_w|JOxGRR)T^WJSuD@@G18Rq+Ey;LEKoVujwlq=iP zzRW31$8{D}q-!x_|MphhAS19&e5}3ffLR@rFe}e3m;PL+Xo*51j5-YMhhwMq{N70S z2l27rnNW>fz2We3CrPHc5g{VGEIj#~Fgp30ub`)@FYF1jz#x^E3-mfC?4{UuK`$}f zuuP5nbo0j5gsBfws?_p(>#XDG!1jRf5IW#s8e7BU&5URJjpv}1MNl}HY4fuMRf#0#%%YzKU$J=v zwe_cb98o%6T8f3q(dFh|SM=?9G?&A)t5fs_@jt+dM@i^uf&j?@@efj@cF7{?163={ z@hQ^;es`mKVT&{=b;2ycl{Zty9Hc1t)2%l#9lE!0hFHpI+={UH_I(H5Gw`(r0Zt~~ zSbf&gg~AOGmt0w=Z#X}lwm_=rn{#)Wi-^O$f3W0{u?HUbOrUU8PiGh|QPx*iG{Ro| zL8ez1PTb~vyjQSG1+PJ>JhnjQ4XaS{T+H+F|oO%$dKKTy3?!{6&-a zq}|1w{Z%72Cg1rQ`2bmIjb_Famz?UJ9o+D~#~ea2`SOgBL`NeSfy+4Y4U9HW3Yl9v#gJe=2I3xfqW`W{%A~5=J#kZP zMP}4)0}FASGE622f=csO2htzL-*K+iseOP@>m97}%0zmAdsVXpvSD)#DdH(3$5X_R zY5zLEu#FCJpUa`0ys#sX4AEi`;TuML`4CaW`FMIS&v*~T6GMDqpi<|CTVVVB=1@@5 zCdHeAVw%gb3y&#}_eLA*DUC5}$h^2+`1bAnn22lW*b?KOE(tI z_GDH@j&i1B5*Zu~Ohnm640@^W*`fOVK9DfH0|y^w*Q5z0EF+&SEG*2W6~|VYFbnV< z6=svGD{zEe%pSOl<0#Qn*W-*B?i{Ai)Nw4tC!(P0a(tN?;tpMq4Gr z6*`2lSj`iiW@!zJWL1K`09dM61Dr^CVu6lvuK6jdp@;^T zx!-5y8Mr`nju`GmBm^V|Tni?w%Jqp65 zwPl?L2UWknjLj%Fd9#nQ=?fP(H{x*{^ib~%lq55*f&7P&&sMrvjPGe7mU3*5FjbF6 zIt#gwaZ6|3>y^lBW)e~X?emmuy-`M4J&QE=#D2}lQa0$PGg5cFL#%u zn(0mN1Du7Y0(SiYLTP=ykEW%*(kYS+Y_=5JXa3X6ZUm_-Vo*5Ws$-{6U^%@34`;}W zEoQRh*XNz-O(>$Z-S_%jTBw3iieVC-K0@dD4ZMvISl^RYs+8+@oWGVraEIk`0|i&z^g#oc zkS2Z4o);KIo0b!!D|Z?2J$bS)=uQRN-1oP-(r#B#lFhWap>~LJriXu3J=HhgAnJQ2 z6y=q$GrqV{*6Rs@vreWP^;&q3Pkkv}CD~`Qj9@7@g%mO3U%EsA-(dZ_8_kj`wPRq` zmIaS&7X~mk2(r|2{m~``epj0nEid0h&}}-{b=c+?3ApReCugGWp+y^3$>U$*aQ5l_ z=Mzbd4n=zJQT_b202vX#eXokXXmrv8L?j{X@PgdQLWYwXlV zA_WIJEjYu))egC-lpWCPVe^f=LorFq{R%Ps{WTZ))oRH$Djl*SgU>xRm?B)Bi}$yZ(R*E**so1bYJxREA1vtAqbg6p!TZ}S zbJ|-+A9MH&f97R&<@W$%HPJ7P9}EpQZKjsKr*P-)eRA#DyFc_z`~E5OQ5sVsxSWWr{mRJh~`IxaT6~nd{w+J#`D5)+ZPqT;CTq zXnT0K8RwZhO|numET-LQ}9NwJ#E0Atb}6&cXQCLo~P^R zTPzTqh^M9_kV{-5hiEIZ$z^QAh(YMuob$+BlZTgPNQy=crO^C>C z@#?C&%~fNen(8V4Oz0OSJ66#vDF4Ba9kE>PN3)mX(}UH6_8T*@yXVHfwc zw_*92rNPg(VoI&T;OkU5SfSnG`>(=t9s^eeWqxa?v)=U8TUAh{^%vJ*<}AENt{vKT zNgy;dM8}t@z$Ia!9Fv*FueT}aZ(q99ckuuu7UV*g$eTK_S{Zrs3493iQoe5C)MB$4M=xT9=~uY({C6Ul zjfGjNph`mDB_{6o&pr$0UkAU1qpnJd78N};Gta1YNwvMmci?K`zxO$t^cp{&UzbFA zOXciZ1V=jmn~z#QY91?6^Qm8qk$EEiv4HB8N)T(*P5$uh8pFhw*Nr3csAlSK;@7>f zf$gJ4{qM0&3qH%`djoQJp7$7oyWQXFcG1%&8+FC|_K80YqSM)`Nesy{d{3^F*0~~8 z-CUD&7H639lj+OPKWj%@{4~t*QMC`gLt-T(Yoq$+2LlE)JY5H$&&;6rcPuvZsVo4a zCCeCj){g7y_mZ#c9dhW+^CeyB)L}}#sL^|=lc2nl?YuW|pS+E>b<@=py4bv%+`4k% z5<}Q+65cOm{%6Ywy=cu;$42NoxeTutH~6qd?;fSg%x$5|o$p_J`L4}&k}v}L{*?oI z;azj-_*#Mzw1&~5j{o_ROzZI{u>}oBw-*by-?h~KsPS}^@O-j4^L9=$;o-EPXuu6g zc2dz1&ciy+O@!fP2zMB8CT2kNei?tUzlt3+t`q2Y|nThKUGmRGoLzdwcAV`5sqfCnb zdc1~DN)KsCZQCGO&yPgyTm#nOly#*5Qmkl%G1*uJRt z7~WAYwxKiuHPWu^E*ub0sefC3e?@UMD-F5R} zO}+OW+KXCzJ$EAKnr-&-CCy6dM9a8eqbbzkJgQmxE)wVS*C;hIM&jr^-xkGj~D z9^Eu^q5V9cck_~BqZ8rvRqt|ue=PaK!*P|=*r13x`yW;p_R8~PcP>k9)|y=oS^px` z4c*?^f10|3Qu6Nzs7E)5MCq%&qF8R1vdk>q4en`T(2qJ~CCIUJ`yga(IMgedB>rSQ z7yR($CiAk{E6EET39@tAu6SknyIGh!{7A z13v|i0$#}+u~i^yJ;aBoCSNBB6Yui7zajW|BRpSjBQpI>h@e{ zv^DNyx}6Q0V-cSy5v7{c8L+TOx=8T(?PICq3&=)-wgNp{&NJw94<#hn2_pdvyg-4F zFO47D@@=$&K^}|!_j38O|2(QLFe4m^1_>WeaJJ8ldfoGBLL4_*3yo*2&1s=uuP$PC zLxB_-yA8XVntndKumLnEa8I)QUa`ugN>D|U%|@GTatjgt?mqC1>UM4}{w<{hQ$X%f z9siZu$)qh);^!sFDYPsZX8&f@bKLcA=Zy#r58Kt=Cb5^PM|mUN!ZcExFGF2tgP+nq>&u^Z5sTO~zB9sTq1+5_tq}wtw1d27p2Lzx@V*!>V6G z+T>(Ef+b_r(mv6-M?CfRx%8nlYntO53np*+mELHBF_fT~K|{s1+jKiZcy{Kb{)3_C zIx`|bD)QIVWxQPV1irBM&?*%(>9btH;|}KQ)cs(hKu9a60BDX+P{Jyjse;@uFa!N# z@i1}yKPS5CJr9FN8^A$J2i4#wolvvOfTR_Ld@iV{G>c_GS9hXr%ou|)f#@6pYy6o+BD&-Owk9;i5UJX`D zoFM5ka(0u;IrbMfDeuWm_~njCqD?%d#+D<7V|DD8Cm_`*mgT_|3{no-_}|yGI=F4` z)1kx$ig8#UEO0)==4)-m`?zk`no3E82w$`c2g~nz$MAp#e6t$*cWVsEtu{NRsCO1Q zOQEvaya_rR#I8cy2*2{SRG<_65I2ESB4B#KLM0URppc4CgNH^HoL(og#br#>-Bmwg zbKJk}@jQDW-zc~>F2l=ViLnF$tO!{PWdkyYYOGAvkmgY<%YE z%rvdqN@{#3g(t9Yf1KGt%>I%StHgVy{$%#g@JW^(@&`$2Sxx}AwE}S!#HXYv*}K2? zHSk)QanE(1R#Nl*bHK)9Kh18t0l6MjyJTB}ey^+25D}RW^mom(eYFFQ(Y|SVK>wxI=m+jr{dCZFm;v?DLfuBk{Uuu+%!50?@12PU5Y1t0wxzNx4wDH} zpg%6&6Wa-&a%|rb%+XBW-~btd$IN3xyuAOd&uHJj=IC*&z*Ecr+f9k;4>l2OVsZc> zTFe*T5slz95h#~%N=h75`I@sdhE6jcEL=hPW{N0JKiX z5Zuwe;%@!hd%Qoypb=`Tug!TTtscXBRUScJkwN*$KSAqozG1+fBF>oH89kNSvb^4& zzmc@SbUYe!5)B9%x&Jt2XG(W2H*gaWg6x@Ft~2ECk3!N6miZu=rKDF$)fx6(7`OPP zg_j`GK$v}i2$~G_^jEa;z|x%mrsr%+p8xiM2zV+FEUs$Nedas}&9S?P{bONOK?7<- zn-y(MDY@;?BSPvIPhXBuO`N-`Ig7xaZSUiHuPa@GVr%s_HrFM%4<%OOo+zV~w6bQOMb`T3+gK3+m|Gaseu=`2YaIDKv z^ELmGU|_Ej40u%_2Z2llAMv|pj-;McdmIcTv5`BgKFS-Y>ml5`>311ASx-f`M4ll_ z@ux0oO!D^bY)|P2=-xH-6Yx9@rF6Rf+JAz=on#*A*SJ1QiITI9o66`73J$-57EV_w z)>IeG4M8srfE_K1#i3q^5&tO%C6nB+lwPYXP*YTHqUV0nqwjeZ_1^Pp4N0NzwswOu zQW}IusNQK8k7Kbeiu>mnL^Ff^kv@_%PL#QHuS3ENr6rd2RdYp*Cf-^N!JJNV=J<#x zJVf8rS@kW2k@RkQ#@AuWei&y9`wNk7d0x+RD&gl0N03AI=sKs7F|2upp+W%0B+%`rbB zi)fwa`kxvO)*bU4B`4_HetmA3Nv~84wp3pA5zpgv0Hm6TEJaqVCX#4A&(3}9!gXg~ zaECGGlmQHLV)Wo35U&QonQYbtvgGnpWKSCne} zQ{AS=eKCPvY@`x^K=sY8V#$m*iN z^Ha~Zt{~!*cIqMGH4#7x83GiF;a#Aw{rh;MD$+tz!X$vJdYcR2o5@V5Ik$=mU)fO;DbBTB zXX{NX-K4WKzIS!nerGGpt-SXM?2{fU1!w(fB=QLboXhThKc_qu*;W15{qh5fC%rWD zOL=qdTkvZ60F${kS^;3rh5tbf&^oT12Qc$diJOxbJ#-(|OSmkDi0uc=I8cx5y%9Wd z4RYOa!_SWn1vDXO^zXuI1eS~wTH#&1`NujF9gS2F^ap)Ya=t4w^Z*Sc(fz0!#ax)u zS@D})ly0nkAv!l>RH96(6Xg==qjPJ!p3kbX?8PuyeQ?gRqO@qjdzaO2*EnW0LY5j+ z@?~iq`DyLB1yKg_i*PQG+33gbXQ`+E+jQEIDD$#4wuVi$KrQcmmzdqOph4DxA`MmX z!$o`^2YW4D1!@L({3i+N>+(2?US}>wld1a*&;62clQE!V(lLfixOu$nFvA>@@_O^N zMbH^xd1~o#aP79<)1qMJ@`)bS`Hmn zFrfD!L|rFq$n?%f`=?fX=H0i}^C~r`3M6Yl8{b*1+U&rUo$oeJ;0i!Z3qKs|`=(MN zm0|8ln$Y>KuA#U_|w#n0k8QD zp?tK!bsH2(+0&_T;C!ea2V7QK)Q2+fOaHHD!4WPrF%4nhWwvB)&{ye?bTQ!!g!nr2 z+<0aBxfUhQ35E}0;Irrs)8nXLx^XU=*l&XqhqZ81WHW0DS}9l;LoN`l(y>A%c2y|^ z#Gc)QJn!8td(FXpICKq|NQivF9j0c}34Zwm74GkB6Z1b6PEx!9iXELez2-gPPeF3aVb&M&KoKKnMm2ng!veeAUa1u!-+o7$My6)lXE%Q zZN>QdC=c+A1jlQq$1S77E!9%9S+-Y(Dc)O=)X4OnUME81_-plW8XaAEn@pS)edt;> zoc@u);>Emw4W8l7s5!3=QPUTAq6OOgsW+{G^e1Q8|5kF6zAFZs^hfbyt<>C;TKmcc z?iFuEZF7@ypWvF{F*9>PvNk#{Bmf^fHtIUQN}Xr4&SbUuHBzXuG)MO-Elra1I0#7@ z%!oYAXT}cjt5uG~%((gn++5R^NPTdk?G5`3+FrB37oAYU-p^ge=LhC?XKB9PCfd3U zZQCq*A47KpjNZC|hla z)YIQIjcyDBCqt?AYIUa+J4I8uJiDyTA}+<3)%KAGtX?t95sA1JwNMK&)Ln9##<>_R zY_=|a?;=;Kp({!t_q($IN=3$9v|P~(;snUmtg;GZ8x)%Mv;M~7m~H*UlWJ1Dd$#qq zPVp2LBw!1RbYaFQ007NsD4J+3VD#Pu{5e?=@>w}Au7U~AJu_-XHsB{4I}uzOM(SjaMYfaceY?QYyPz1!Vq`$YD<+` z{UR9iE;0g5!nz}8zFJLiWve5I6*N#I$9>xxEI9PJsE90TC-MKy=Ck@g0G!@W9F)gc(VUQ->DIDKcWeKlBL zEB;N1)ik3maVK`Pbi={WTfETL+AZNy&8qMB#CT<3d@4awvkw!S)Z$D;p{x3a!m&;LhHRtK&5{5@ zi=AArjVpv62PgT4WxJ3=s$c~wO)1k%zQXb^T*9uha@@>!paqDI^O|lusrzfNFv&Ji zN&kjGR`<~%KNGnc)GLu52o-clQKD9GPkBfc@%Kv~*0p|sa_hJizgF%Jn54KTsG;Y)oXu=KJ_SOm zE7-C3MAyJ6g&^iuJH0JDoI*2A#FSk9UHjh4#!9zjQk$ttE!#9si7Pm%Atp@M^e@+^ zg4N(WX-xS$=4qYkbM-?te4!S2e7aoIl~Y58lqa*A8NsB|f@Tnamt~4#FuA6+0G%bG z=i79m5+mG?A^EddYp^BWrKZyBixhdhoa7V<_3^HeW1sva=&CwdO(;-(vVFTUK4?8U zTQyknFR3#1d^#oJRUL;9RjhOgXqj}~*VA*}%X8l5=kVhWoW}c!I z#4tC~Ns}JV3Z$kjtd}T5jlbR>ukK*qPf{T0oN6)Yg?11-{@eC-zQjiI$*6j1@GKC>vmJ zlmRWb9+(koD^H^#2>BNBy^_xAuR1NE&afZN#Ja)e=cWR)OzB1!Kq{J8FI4;EM`Hv& z4D8d|KPUm5jVM30B&u7CRVk~uaXwSl3D{}eUvG$zG})~^GCOII+xpbGL3n}YK}{OY zu!W7|;{kGBV2a#)3VH2s7!=Pw1L`Dly)R@-`tMU?ce7~z2y{g?;Xhe(&8Is;b!X;b zW=}`Gzemx9x7e~@|Guo@s;CV+9dy<8bSO{->4L@aD~>tj!U9EdG<8l2<6Eh3 z%1L)s)MP5sF;OfBlK4cq*<<4&DU&?3)(~bqj%n&PJQvoVi*ks7fD!r#{+og#-;3Aq zP@|**sUG)SBRRG;(Af#{cyll$?XNKxxwI={SaaQtkKgTvlIE^`3T;R4;=}pk1lBnv zF*0dxyeDa1%|kR$z`-Ijo1`$0;k&@i?8`B+6ObH#t+MUg7<5@PPxN+XoqB(`2lxvL zP!P^=Sh;|sky{R5V|-Z=>I~D_-1OHCZW;Ird$(5@=03aAsnAIwde{@A?Dz zKNsKt-OJKyxbDjfjw>3ZNU}Ql>>p3UQM9#(dsi)~*wQAyf(Vqax20-Id$mrHwbM6L zQ0r0BKKP{l92`+1=yFJFBE&|N!_tkEoMQSTw$%GB^HR%mJ647=o3y1`bn?edRx1p?X9+=&hI3)31-VNUKe9SI zc^C-{exOc@v=IofF^Dzgd~G*kv7l%H7Mcy28gnT^lghgdM--zrI+ucM0J-g~*v6<% z6bP#~^#6iZ%NbCZJFl4ShXw+~bm&fHj6D~Id+FQ$l0^MiA{g+26@9eoP`^?dmn}uh z-mXCMg$2>bv<+}W5-_d0@5ap>*V}=GExMvypX+!I_4B*mJE>&&m4~Z%Yg`ohSXm$> zt!7a)v_96JqC#mxNlAFhsOKA!qd_P87ZGrIBt?^r6VTdqHM&afo$qS?qxr|&6KQ9@ zXOP8Hr0Ykn=_KZ{P*cTFmw+pKED2d3p5sK_=Us%lp7RRiH<1bVgyP=wSg5}3Ai~D% zk3xAMJUxRdWl`({NT&SmYnj&$zJwk=Spz+~jiDM&{w<*YVn!Po6L2@$Zx3g5y*9MB zJ5OzO951~e_NKC0xsp7hQ`(phg6|#FyxD5N!(%;8OQ<>pAvjri*OM3+6buCZ+cbO) zAHvD1nNZRvyH*Xv^^z^jMsVmp0oyS=xMo3TlJ`ZUO;eN>7oWLXJ#Swa{r0{Oi5?Tn zzkO}IWA;BMF1_=sQj<9adhRB#R*8(q-NWhmb zYmEMwe2a7CeeZPvmluWb_<= zJou`Y$QAV=@kmW6)#a&7Sfk03F#-=wCH8Z0MjK7Oj-(Zf%+{=yK{(EnLEK_}{UbC+ z;yjvW4)veN+O%XVHf zxJRV1py7d-_v^)9Pn{oUS{a$gx*~*x)?$l zD7AF4b}ri|=-p=Vvkq54^2YvPC+XvxF5tDum%g=+^EN4u8IJ=%I$J&jlz^HCVZQKs zw8`VSBTFbz=P1h&UyBCWpL#g$Y#X(0*|1TZ8eLr29{u_ zWI8pj`dlj8Hbnd5zm+`Kot=_V@DKWx%k!7x*vowSIxMHdW_O#HP-1aw5VOq2gc#$f zjmU2TP3W6IJB1Hzy-rt9ByHriLA&6yK(ML+uYd;$e)$VtA_^P$`)csIg$^k27Huw9 z=xEagiL-I-G1DTaC=X~->PBHo{V~8|C;lD>E0jHvP`g6-uJ57C(3unPKO}TYfve%yvlh#pHcVQTVYm4h4&|(8!+?n5#;kS19b&JZ6fe; zkzFI&>_eIKkUjZ|5X#l6%zTWia&%uWed_r7F}0kGdDwKBtHWvJex=v9YS0dZ6JYP? zvZ4+yg!g@><$Zm5FE@><1|0pz*Pg=f_s^A~NQ1syKSx~o~K zL4yV@QJMZHL=BnnIR34yL>v!vr_&SPPRI4j$+PE>`j2NFhv^O;K&}0zQdH5c__!CF zO#lpgxclz}gXR9byyQm4+p=R0?hvhnIATeNiO~jDl2TnV5$v$TVw$?%&%k@{mWGMw zcLuM64EOCaYBGetNDYL5^ThR)Bglcqf8!Ge%uB(c?hGZ%mNjixoZVdjYK2{c@8T?I z2+``rSZ~?|8AR9-xcTF?WwGS#PjGb-X|mzFwu%*q%G2WdF>q1(h}*k71gyJz5MRHK zS2ST#)0olB+6xh^v`J=h=+oZ`{ISA%hQA0{Rd2+3cEM4M$018S309{SF-RJx&b|8? zSJ`(@3yB#_JTzW-rt>hIUPC>LIo zj60Tj!dYRnTuUR$0ak{@aO6c$?2yDiUYwKE_JOmaU}^*Nb*Kt_t#|9Usbnc_vV60eK}tSn3ETr=N&}rYhQ&{^zu}Uk<7BzbpBRePpvm|2 zzf?(iZdb`BnkIdB53}6aT};NHhcb3)&O#7HFXaFc{>MJVt{#%E@7WZClkYGsQ2P?Z z!o@MSzN>7$UkAXZa$7At&d&>u`PQqLJAcv?Q*_5u`Os%iN~NM(>S$q?$CgieC71V4 z-M+qw$FmEEdTU&DNM|kh;*Q{P+7>7j>_`_A<&}CX6N`s$yt?al5bM$2R1Nw0^R`R> zBZL8o;Q5jYo_n53fxu@ByUc1BwB%naO1{_++qSsTjgYf0M-TnIJ>2cLg$UK-$g zq4fxn?seIJw`yn*zl9Xj;C8)XOF?Tl6Iq?&&OAxLirqbeep5Z8>-(v_Twkvgu=F1p zIcxkeA109f3!_YiTpm+ty20Rx`8Gw~H@EdS3SX ze1=p4Avlz~qWB34U(ljHH98R6H7I+oJOXNrjnL~RpslAS4_^Ow_EXEnLo30-uiQo( zTb0P@CMx8SwEdQ7RJ}@Mujw%@s?LN7aIhm$e`XFSGSh(+qh@t#SDcBJ!gW)=A3yS* zYM^^>6S1lvsGlL9d(Zx{*tK(QH@6fdb+&9fLlW|aKT09Z?zz~B4+sw`4RZXPh5*4( zI;Wb~fadcW#AS!-Js|+_KgN1XDgL%R=6ibQ!ZfQXmI+$4u?MA+mrNJ;9!R?p!(_-fj@sKGcfJ_V{&}Bz?%h%^|S8QdA6_g32 z&`H=r3DcZZj*P6McBWIRn?iQ*15W-YTEGk{82*m;y=UF|iIF-gg|0K5?K&%t@m58$ z3tJ>}UO=Okib4khu_@PY5K8SH=O$}(-7AH}dm=jKi!I~{|6@9w8BOK{Q$(L&<=F7q z-+gRLrGfGDH4Zj*X6R@mr@Wu0pIWZF_&Ky1V$AeWjs}hj0^+0V z86+axe^r*Sh`Z4eH^!pa;dCa%#83tr_#N7V(rAYZR1l7~c{VU{ zSbqE`NM#K>HMwOuJTH;;h$Q()CvWZV1^AYmFgGHT`=~RC06y|KP__zgx59vM6GYI# zNVTM=f-15SZ@TaOt4iKuudvlj_24V+>lNU0&ho9GQMajRoQc|jYZK6I$Whu zG?9IP_1AwHW|REJ?jKb+4c7MU6+@p5!8C*)fWV&TGqUcDEcRDU@(-;G9>`a|tf~A~ z7W7$aB4N0@aKs{*G9^q#yh_Br(;0?dfIRCR+Ua+$|n z*Ssc>+1H*04lZh4P{TsXsBxzqQUe5)NP3Y~%!qWp;gXaa!Y?O zB6^q4Fkf)7BKd}cv#EodC&QEWt)*Nwr*&LVL;kAZ zoBK=IRe0Zfe?>0%$)ureW=ORt)J0?|+cO>*bO4#GK(GL(o z|Hg+XORv?y_vy54235J_rM$`OIxr?TiSA@;+H>=R1VBuaO>+~AGZ+7S&V_lmw3`kC zd~feuEEdJV5ddU&TF^*r1Q9<7riR{k>WWu7@t?f6#uf4n@5y1Pf=NJx)viHqGWpO~ z8*nIP9LQ)mK)Ho^_^hlsvhU`7?cXDJL-IaWXC3>v36VH>Tb~H4vf!(@m@PC-l*aJu zt;FS;u~kcI$wf0eaH;}>VIgpY)KIM?8{(blQh8yfN0@R>?K;4hYcfy;z4IBUVyqzP znFqOw;Hmx~Ssj6h8-}-XaX=99PXOr;44zBM$|lzhm&b+xE3_>QnT) zRBSn8q#n`1A+9x{8qmJziK^a9Fy~tE6ae|j>~1K=y>>IkIf9t;&P&nysr2eOF>dcv z1=`RSp?udMj$ckY@Px@JTK~UA4)hxZKuGq&bFL2zD6UNrm6v+d7S%KZSp}mc^>1eS z96b0w13&|Djzy+c&&SvS#gXB7jd3EnZh_}2fZJK=4-)LhDQ&@bbFMd}C0@k4=6lUz zN5-aydp6o~ZUJ_}=SeM34_s98GIJ?6+xMmvg#<0p%|6bJ`D|G+%i_M&&fXIR%En~^e zxrd~?wuRH=%tETdV<9!o=L>%4ZGhv^?`ImubKgOaxxO=-Emey2(xR7fvv`i#vg7W| zy~yuiP33saMMyKSXFEKuy+P|cf3x?PF;ZSU)En9aJZ${UuVzBqeW!zM;M0y=_dzaY zo9ZY~q~I{m{McwNUB9fRpZ4+&b=3R^zSq=|-=e2I6nY{|)Ppo{s)aVVpiQBXegM z{0%>xnDp}c^3{7S&Zj$p!PqGy8wRh6heiTwk+x$8Iwo6MlW@#<$+CqEsT10J@9nv( z2ED3Oj@Re(+pJl%@#JNI-_@yX7j3;#^-=$F2Bp5tTJS2EEG{Z#Hi6y3lm4QOi_NMV55%S=sIzZ` zi&(Mg?yv40PDWn02i)V5$)JmAK43G?wG}y2dhq95HzdGV&3K^mUJ&;%8g2^>auc|~ zCu`+QVWY;7@6c{+jJVNQ5*DRl_EJ${VL&coJA_`pWmNO(kd=}V*6isx8QfZ>TIlzM zOHoP-GHkQf+1j%NS55d|BhTGy6Zkx%jbNV1y!DKyiiVW-Ep4AOCq51T+djOg+?8q? z&?E6^DWOm@C~Gz&Z%LZcnn2?}Mz}q#xuUCq2BJa?Jk+P0Gl>V7WkP@zNI;-o=+{pp zh?m;nA$>jopM0d%!KZugDu;(_=y5FCWrp!n$13d%^D)K3?keJte>kg8&HX3|%T$D! z`f|8nwIqT~pHj&^v*5HqOr4-A$XYVUUtWdPgi(h2*wWJSnG%~^x2J_4Ai84?>C7C> z2+z$19}oYPGHi0QnX}V<{E$}nVD(6-9LJAO0jnzAvWVhY#2Y7?-v^q38a}S}EPoYvy|V^cPVU?TI9{i634olwi7LK+Di| zR5}(SKe*?=9mES-Bw}PP`ILmY5<8gyu>Xt*vncz`MOrffhX$pUo0Hl++y8Dq*M)NqsjOZhWT-nJ7EPO@|@Zk^ov=? zzrl%R5-Js}#m2qY{#m6k7?jp8HvUT^cf{{X&h@zrv)4?DyLxr}IH6Y#Bgz?C;uaWo z9WXQN2c2z{=?T2`+$hEQbl$`qW^s{gG-QCuA{yNM)!7lrB0zSzGj1VHy?Dq%Sz<;v zRM~xnY~;akq82JFODwEZ*d;$T=pRVkhe$yjArE%AS)d9=i~(Aj%aihq|G*V?N9JET zA6_j1%G@q&=VA$tKMlfzLl+$W_3QEcT<3e^PyYTIKYZQCc! zx5o-Kke$O|-_rX>hnsU^T3{eL(Q16q1Y-k)f2Soy+s@jdFf$!Vc$$qeuEbO+m zb3LutG-7^=umx9+3AbPezcq`?MEx!doM+3d1rp=b%wLazD z0YE)M!m7;1XCGq0b`@*RlMt#_kDr}d=&B9Sp|rIi-9TRbG+m{e^yi`v*b1|uqc}vfEsIc3vRD(KL4y5*AYmbPj-L9$RvI#cyWG+U2b(yOw-s2!mb&O>Qw?dCG`D>n3 z=aACD4Tmks&HlI4`F@pc1ES2_=+CE5tA(q(xO zxp_gSk^(3W^f$o z+V&i8;Xtt2V1iUUCuT~Z{4Ko2{hPpZHE#__a`L!E%{~P^21)|YM_Eg2A(;MaUeWUK zhRmxq{YcDsCTZ*jTZxcuY%9^pbvO5R(=7SV#_?In>A|1E81Qs){B@;hQ1nyK|N51E zybbOIqu~7)Rl_nfaFTt~GH9;Bi&u-H}iQ zZoXab>|`N=;Nfiya*|_S+NiBc0FqACB~?T4M8q>$GW?pupLSQAoy_P>!%34~F@kn< zu7u19#S&q{i6s+HXixt~O;Q;5w=v5Al1sjtNa5LgK*#Ba6)6xpIce2fignos4(8Xg zrIm4x3@+!jIxx>q?`-LU>lfkIOpURz0zCkdxxCs1jSPIz+z2xcr{18H-OH@D^g5ee z(bL7)FKvrsG1rHg|BllnW`U89Al=CdI4_6{@+euKZcZ?1at*4()--J$Odgr->iW08 z!01Q5AkzPqiXe;oU+`Ky(Fzvi_zf=x=7hqk#eWg5WlIG7+16Xw%UMp8vI_3_mE%p@ zdQD~B?j_vt`Na}$g|ne-9Exi_E~;7L<3hM1>h1!o{{@9=GT& zNFke^=^68&b%xo|?ZwXB`ZBLL8e+QsV#<(5Xk@CVV9uxg23%3&?D$`+)VH28T~n^N zf!FRiu2Zhf9X5%e61+*e?lF=Vyj#$}nZX;Px_DQW4A%jIbUH@uXTJn#c`AvU5J6ZH z0z?RwU@L zQ&^OJQpu0)zRlO}Lt>zzbS!FF!ydpuL+Rb*J`3EW5uL4+Vsf(n?+XS8P)jE}RwR_y z+yrzK`Y=%V^9a!hr&X+*~MH4hs+U+JXV`LttvVvy-Xq4Mic z_eVVOjO+bjcr%xea#4MaC#ZndV&C&aPi^0&@zkZF=UMD~O zc4-Uu@R>PCgOjsq7=^&zS0Ra)9&Hjd${_DhE#br*#tMZ@mpLTU5VfnwD(`n2@;)wF zWqFwG@B*Q2IeW~lO}ISf!NBUePkW@sGCS{Tub?0d_`C>^02)>AICuyymcim+9(4DI z*mi@SFWa~?8An?U0^HjiPD%DwaQlTim3ImlR^K+~_EFI}hdBt!dX@hHH3i&~f3@ai ziY?CkPmrBD(*Cxr`0PI6QBg`|=jdgDnksfcI(?svns*iV4=ZZNHa*YD|~A z=W@{W52KG#{PQ-7KV>_kUkE{~E?Ltu_tZDwZ33G-$Gs=tiCQtr!=Wo(X1#U@!H(O@ zubY=UX|-Tkbi6Q`^gd@Uz;WUv|MqdDKyO%rn`Ru1X8*&yG?V!VxA&SHMX@|%bB{|q zJdmBVUZ%h_jC`?sKY#Rz4f@o>g`3svQbrK!?|%C3Uu$8h8fJ|s>HlPaiIPTN+z*7KWQZ;uCTe5UHR&temvUMNB$xM4tsm(tPlL22V<9me$+r?u*x; zm*%bO;%udh#UFd3S(kDNI4iYmuT^lcA!)^W*MNSu_A@N+?RQ|bx*B6{|1D*+sr)v{ z{AUq<0O`AX1zzt51JqM(e7n?ZI$>Z?NQf*8JMz|YB0Y5#Lw~$7o5u0K-v6|ng!@+Z zEOU#KW~`8FoXqtlrx)iQ5)*BPq&l0YNjv&qn3|(Cs%rP01>SqL z{iy;g)AiD9Hr*hcvPl!nyCVtLp>wWm`nyPL9>WYvO}{z*Ks~Zu&f>JJb)~9#kUy8; zI;;p*sh797&0c!p{d}Ff+Vo7cbN@6%_x2><^XXgnF>R`ki;atqi|e9TC^N6%m1&*0 zt6&|1>q=RRmYDPl#5$-kX^Xkz7a&XylxBwfmO8)=SEV5zp5>rIXi=e)nbT_@i=pr0 z`FZDqEhfi2&*_?PXC_ffM5U0@-O}QfC6ZI4jK?8v9dyr|Rd}j%l)UdYW9H+=*8XCQ zgMnf=&*P)KD9$rdmb0Epj1FvSb{j&W#$3M*hD&gGM|@pmLSFk`1N(U~dKAu6AtlC` zZ?hIG?AL$o6^XIk&^U-|PzXo1cvP%|8RES_+A7`joNsaq?MjsCirR}YmvrN04tl$7 zbV`HkV|d69G^L-L#-*t^cw%E@>QtXFFBgsKfj1CV_BJAR^8t|<J8hm@{8_#z7tvR7#+TVLXK8n-!&`-X?8oNB@%7i zY&EO(q?QaUVOCP4ed}T<=c++^8+w=OI5)d?+VqfZl-i6l%S0NO5OnmxD4FDhD3V`* z&{+QKG~i_s@Lhgo+t!4zor16y-*=G}hvo`T)Z!;d4b0Be_9r~-r*qC4IHD{}tRv7^ zobuHT;bC|HQUCl`4aC<|55V{MbLVFqAsfr%+D)`O@ifY9RJ6#7k}dv?xzcV#*3Q87 zWj;6)QU~~GBBWKm1Ni?hF$M+(7J;nJ5<=u=gP_|Y@3bJ3M3rt{t1e!rGh-l7VrMVC z*Wc!Crgq~hjs#P=NTi{!4_InYxF~8P7Wcxx+W}&-s?CEmcO$3)jl|dg_F2YgiSto4 zmGe5ETraF}m8e|rT6msCf0n5fx(;}Odk%ubX-tuEY;FO0e+bQk7RbcLo?Oijs0#hI zq^P#RQ4dKJ{{?bCqgRGgy5Q?n_;rXD$NMn41AK+{ZX68RjompRc{lgF2_n;WR~c#j zp(a5bNhdAIbU<-m%`VchYGWT=qUCsw`dl>;LKR}2z!4BgSq{dUF@XywQ{gk|?0f6? zY*M0*P%8k)b_V$Qt55|DHu~@d*U}zs+cp&f^;AERWWZ@VG^|0C3AL)U1l^3lBqXxa zH>I`cpTo^^@k$%Hoq$<4&Q}IFzjh7r>avYLIV8h{3j#EHRlO4&#ZyDwDk6NZ_Tzv9 z(A!&Kva}B6|4SXf=)0YEKP7+Kj~R7i;jRz7M^>i&&IXPL=q(0u>zQMhJY9P=`t9rp zBy-q4V0XxP(!Kr0FLjU@3QpL{mKT$ChFml{JnYJASVU)rrt)yEB6$&qtr+bhk5oye z=J*^OxRCugfg}QU6K=2;kf_C5<}o<{(3qM03fcZBH6;bkwx?`tkb0v~oss8Ve%IlX+? ztC&~5dwo<@vcDwVnYxt=+y)|(la1IR$Ivp zd_~6X3)F9r+O}pZUQWXtRx?-q7$KW1k0jP^z4>8^%7L=zKYInnQ9=w{v(PB-8ZlfV zo*bL`q#$KdJ$cBZ8Y`x#nC4o%Chf;1XhSn-3F-LobM+Xk0UswfLVCgxG+Y;dF zj=;P_n+&wRkCgM}t0O5_rL5#E>0Rfr-S2F5+KjO$bodml@D>8Pl2FD4w#l`PntD|e z8Um8!i80Zl0vT4PL&YfFN$R1t89v}9bZ^^=mObiS?UW>a8>G(Q=J~(=4>1xwP4*21 zwTu2Mp0~F4uYv~iXJmY`XR~F?dfp7kj#e?D-$|zIRZ+o5ZTVT*DF}orF}l62*QQz36<6*#vKj zy*aQu-oH9Dng5bV{Z4oL-Dx{`ryqp1Okv@{QNq2*tQgnH*Ib@v{hmCd((o8Q-(nVm zz>_eeBQ)QT(J>?~~DhEZ>K@uJ!hpDg<}k7w(e9D1JZ9qZt2~eob<*2Z}L_;^9 z18uQT9!I7lIQ;oYFny*wP&h5 zl7W~D9)5*2mL0&MV`D_;P($pxUC)uow)WtP)mxC3)dmMrM242&2!|G(-Begz7yKT~ ztkJI(FY8*_K|$j+6gkXWaAY4f@KVvutXY?#w2#neP|L`iEK66RVTy6$QuDMZBG6?Y zBe}5o9L7s>z$Fn-|YVY$-^TR-K zYZF0`>X?CA&|EFNe@MCp_DZ^Ddtzr|+qUhAZQGgHI!PwBZQHgvv29~w zO_H1UyHEds?%mycuUf0BR@t0;&KgI<>2@c^x%3-s2%ne=QTlE>U6olu8 zi*w0z^N}UscqmraX@y5x5sr@aJGkpp4mgTn6V^h+Tl5qEQrtGlc-$xWE3$}D??!mK zJ^}OCdqlH)-$}0~%>QtR+Pz+8HkfYkDQ(V1+hdWEwzqKoG5^S5BfUKJv4Zr`SyCyK zh!TB=44zA&b#To7!j}Uz^EE{2ct2v5;{-X_#WpE=ZtOEe=IvMt-Ruv&hUr3bbGUkw z=khacRl!`zleU*-0n;@oSx^!^gAk_Nw1^QxtA8-{N-nD54ynv}{l8g`Mu=TmM5OBW1QLVc9 z9r|@e8T#=Sq77cPXu77n4s=^z>p5p->(4e?BKE{p; zPq)^H5Wp334yz?{r=W?Hk>~68760}Fy~w6pZM(Lks0Hqo?vL&Bzax&bs0=0qTpmfD z1fq>4PMD#uDmy`-#RC)j#ot3<^Fhn4Zis2@xSm$UYnNz-c6u&ir@tqUxVWfN*DZsl zNs>bHzh9%Sd)8!dXPP(_CQ?GU_m2Ot%8xD!vz8#T zL>59r9qKDs1`>FNZ*PccadNj0*=ZrO(STv+kw$#i)JX6aoE+x5{kEN`mnDs@vnuX# zH528`@>T!B==jIA*O==ZH27ULu;(byA2xO;8~ngeMh%f8oh(`GsgIA>q=`U5xDR%$ z_qQn&+UxHZkjNR0HhB?uP#{Pz*1&!GEOK>QEe2QK>o%QpTO9|l-tm%6I~c~iMGCUOIp08q{;%VZo0KcbBtZA07 zt(m1EuGjQ_R&(B(T0WJWMlr zIww=%A=%5PwvTJoR^=TF_o{*#3siwn0si|(N+V+?dH>*e-3Oe&trd^fM~qljO8F(< z_+TOUF_O8Q384eWndNvVWzgmGP)Ax)F_;);T!0f31`95j^5awrv&vg>ww!Fa0t!Gs z3{kn*Boz|tc^pxtoa=CZW8g5Nx5Ex53hO#yPDEAum7s6~A^OWc%ZeRZg2P@XC-@2D z*N%WnIf{%z7-Q{taHbh&2oEA14OH6u;mRLxpT-6XG#)!2Pq`LF*eaC&b|N`30an&5 z4~QFx=uI^yGXe^fOH6&z^clwHt1HQRFZ5V;Q+5&Yld)J|L80zg47%KL zTX_xX3~c#sHr&FWt>w?oaS$dfAP4(Od^puELODV`w!g!bnd1HK@5!Ddgcwt#@M=VC zG3rHOQ?|t1YLp`>%lf}-I&uPB7KpRd9On;jSwF>28fo>jR+$PmpDC49g;HKZ98H4p5l@_)w&|K2|yP*Nex$$0?&Ep2yjhgbvfthu^ zDdW4_RPeq2dE+rTkJ%)>`+U8iggrTu=w=+&3Qkz(iAiR)RFyi=7Q7jCF5kZ^d6T;C zzv}C4b&pYtAl1P&DO4wSUZCL%K*I!_jKA-q)~1z(tPeXf_Vn}-NBjq!WMawI=hPAm zX&@rDP4;q1(sA2fC;Qhy^Q!1`VSN8qMB0DT$t5Z$@L*z_69&lKc;E1PHA+oF>)}_C zCb&YQZexrRw(gb{)NH!jx@89ig!Y} zXxt3{#}BO@yh=tWo^2uDeeZW|{1sGy5^r=Ke5}dYJknRx4A#Tk-udr|bOh zCa3vc-T>dRm`p@vQdtqnDsjk*=TB8*7?#zaa7Whtbkyt^nNlO5rzOK`R^Lg0hhlnj zfx#|1jvBR{ppzauh+B^Ho#*rC-?=8{Y1l+V&7lN-iA6f(KtHOaXdSKr$iJ+X z>Nc+vF>~FY&Y9T;j_gI>R(&0q<5~G`w;N#W!%*GO$uL+Ck%_FN5j-sm-ku_yttFvFog2?Epw-NiCal!|3Y>*W<-|80?2ILp*l#cRcyTN-w!GxYcH zC(GfEDs_!!FDUz-N-w)1L+|{^1KX1(UPdm=XI}$%cnq0i&IB2&g>pf}bP?5ZNf|}} zQA7~y<$x-O`+razK4|xU!i@PAdU8z3arczr;1a+X>E(0xvI9)E9!}E_a#qH>SifxY zG$LfZ{c_QlU<;MFBe&PqUHm!fW4Rt_HHww+!ek@l0DKngzUF(zOje1Z-Rd;X7|k)s znO=u`p9~(J%r2Yt%|%1OPqK>~uXIY-yf+0ek9B}DbWS!jbR0%O>8+aBL~p#^0!XVv z-lKEK(7yE0&ySRqTAlf?e{HXqD$`weHf^s~ssv@k#X0vZ@almyTp!_e`>;mP zYRHTw8%K6WTPD!PVtQzcv^V_cgZXDBPctRTBV)w2J`$zxN=J;?yV)>MbrobrazvVH zCrMjvs={bu>t~S~%PoRVxH^$>!r!bkCHYCc1K2@igwn}j)(1BBK=$o6YSs`PvZ>)` zVetXiLF${7pd1;+gjR6m6LTzTnxKe=w`!Ye2*Dqplf$WO6{6Uu(9wxpxkC|wrjQxQ z?o{r($qqlUUI(UI7q>oj!C6km7KExyoPIg&ybONbcCYl-Q0{a|I1WObWIL=K-R_NM zNR!7TgvdzU3i)6y{oRK0zxY*HT=Rh)c1$N)*A1h2VXHUSmqNegdv-oD-P-f{(Ald( zqlRvC_qTX%ythRo2!lS>+^C5b+bX!DpnoEx%4p-vi?8fFCL6(rp_Zt6=(wFS(8D=5 z>`Tzw9+W~(JtaKnhL@71F{Ssj_qiqBC{et2k_r?tY@=*O*Y((|cH5%MJtsZBa-~=p z!qlohpk%Eh8B(kdUzb2)1VVTO0r`6nreRmmMH?-&9Ou@Twpu#!iO-I2&yARvxS5&h z_oc3ONXW?NuVH33GR}qU`2Ez3f2z@SL1Y5)y@IAePMIiqpz;R)t*)-G$2>evNm48c zg`MXE)j`r%ji|T!0Qco73;Go3k1L9Q*KC+FshDSf{Ofy#gX60`5a!T?gGb?4egqeq z8e3UPT0wPT+mNhM*UD>{BZCLF2@hdzoCwJEBgFbW1le-TQY?EAp=Xj`Ni6a^$3jxi z|Fv1BQGQvPG3z#&MT-BGBjWF3cJl4s-*`yIJbn;qJzNR@|RR zvz)vF_T-LA{{w0hum^?^74U&1>+gII=l??K*BYFa+}!5!Dtj}fXv5ykhimDC(a_yW zmx4nz?Lfi{;X$$26c6k5zGUxlP|NqR41wqZFX`@v;%Ip1MnYaS*sq)BmmX2*GR0V;)W)-l9c$Wdl4ved>;cZA8jxasEE-SxF+bag%=HzB+n%R4L4HEGjRuz*M;@04Cts-BtfD|Mz}KIg6T`Cl8o|F+OG?t^!G{U$kmw=(_iU<+>S zNw5@>F7B*XCT>YBhRIkkg4ji+NP{yuAiwTvR{ky9(TXg+UCel__xf&lCsxxJ-d7c@ z%D{h}AYmw;VMZDshND7NwyyOMTs(g}TYkN5-H6(-8h3s<=9(d-cZtm?1wywi4Xj-_ z;LLIZ@Q;6HJd{ygfoRQ}{EdRuQ=<{LI1w$fV;0{Aq(@K~)-9X&>(mK>EMx~~&pR4j zY_{K@DpqEpCM7`4P}EO{I>6hqiM;$wVrF?Yb@zS+BOsmDvq%h>5rI_GMx2XlX@{=c zOc{Wvh;Ppe2MsLH>o$53?AbVztly=tWtczEVum+10&ce3pe@hSdfbfecwV&9o!V-^ z_P%`i9Q1lk>Ll9Ohcy3*XTar0b2yG{z@utPX{*}L2n$ZKg<$giW-<39$+UB~_$|1t zd`)Mb+2((Tp{|x8gRXcl+G`-JjD))5?(imps1Wr75#i0c)muQg1Z6MN@rj*)RF>7e zdC|?m4eN`)zfPX25SKy2lf3DFkJYHFB7lD}^jiijg! z?5WgpUP;0{Ps|GGHNA?%qJP%EXD?bo5no_Ts>6 z|B##?K9uWodPliz&S1;%Wx-Mfhb}G>Mhjj##T57(e`|aZ=R9CDo+CQ5zi#Pq@ImN( zOWCD^#T^0me3b2ald0CCcF#fvsyvMUOD-Z_pF7+i>M8^td9Wd^oLjT{nQ+lbu& zl@~w>FG)eXF_`(wXY-2jH8Qo^V_St!@1?!ecWb5h#FvCR+1#dEUub?2N@k$Y5F&dX zPn4C9*-V=Fyi1&$&xjbzCQSJ*b*<0v{Ly{-?C@}yID!z3pQ=U5a2wqdyv>uy z%b(G{b8AW-c4J$sGmbozSZk#BiQ?d_ImZE98Bd)?%_3-tvGdpjhV zrz=FbSS4_H*F$rH?s^gYR$F8WVyCvyOv3RNjC7&SGK*?4A!9|>CKA3d%(CwcFhlD2 zNQ4d1Lj#s8`W}D(eDySb{^;NMnyLNDF!)4hT%xpC5VDz;O7-y@4kL9xbVP^n#JH|! zM}^>to1HiOxGb$g!;c#eImOdNQ#R}?=Fp2u1%d{)5W@FU&?kW-4o zX1W!snwBRLsnuuMlfn(B{V;6)zzlL60I_yss73plS#w^|M+ty7;=GgPPa7HJN5+}3 zC%K+{C73~p;;26m57z_fu1Ay*`|eSd*f?>k{dbk?)3yhrSKRic@b#i1aFT1FD>ynG zPxX0L1X^s!2U&=|q7278-lNTA6K(227H*RCa{|?5|JQ4h4hGjyxQWZFa&Id}m5f!F z#5q<`KS|naN8=T(=}x=Lu&z6kKiAiYW+}Mf5lvX2sbISI5TXX_fAF1!MQZ;`!kYuP z>R3^Z9jcG)xKbT7C?cf5Kyl-e+4v*FrqzVqneF{iTvgP!9GP_9lD!N*nZ3z;_aEhk z2ESTpt=Gqe5fhRb56krWsyNT6<2lvd4-rO;lxj!_|F{+m)5XScg~ouOxMCYCuTT5W z^FhXLlb9Q4^!<;W`>a}zgV@!d{57ezb{2!+c2(XK)u*t16UPw5pZbsv3THYYV3AUYbk+(+CMg^jsNoO!a)NcV3N?=P3`jP|vWDcI>G zNWk^{hToFYreTIH!~`zbK*akzX8_Mscyn{~+#TvIgtH~XSEf58?tI0s7T|pT3WtOL z;N*6xCwv$#Bvt*KveU>C8Ss2Gh_&YDX6eIo@0C>~;mz;a-rEv8CGNP~?6?eY1&}{k zRUuXFK+&BL?L>@8|3H*XF8V8DS_xV!5Jkb+VgI+$XZ}hv&x*uh`|Y=CgGV^~885e8 zFD+A)#I9UTZ4lXz29~yhl#A06|1IP=@dN5e8f3$+z5LQCa)bBdT%Y%ap4+gX9F(pT zo!qC#-rJp)>PHG;U0dXRF1CT{Gd!z9SZ`4fu8kfhH?P;bkTA|;Vt?KB#U(RH__V0> z#3RR6H%+tuAeK8oN`&Z1o!xk1YElwHvK&5DOFd#%Yqh0E z|AE_algP3|azrQUdomW<^y_tU2LTJNSki!9YNWZ1zaYCBGC zRJynyfg!ymBoJu~vP6uPfBnu*Vt3iT&9VG|fHh%{fh-*AoSqX(i?%^nVu+;o38B{e zePN^M^sdvr>t*NPajHbi#h3#A&8SC0M+#@B;EAFxg^3%z-)E)%eg8=nG_PH#iRk}! zvt_nck4UM@t3(hMv}9zJ$sPm|j(pVG)-UYHqr@9ey^M#zu+nl!;0s4Vu{nEt{o-2G z$68LlMfUyL-vFo-?)Cb88UKAsi6B?@7q!HgFq{l1e-bY=sflS6M?=I~8xm zztYj|erdMg`>6Cky9%=TW$LEPO7a7qr}}<^;?8XvH@56OFU|}kZoD0oV{Q%Yf zuJniRK)c;Z$UvnSzfCg{Q~@sU({Vf4S7L1~Q@isna3Y&s5`}1bRr7+uVlO3Bz_zDe zp4e?#unxjU>sXj4q=;V%Jk68mYl@GGDxb7CGvhO1)TjXMbaU;AIRLR%_qe(dGE2Qg zI}ZJ4Lx++={}Q_tqoq2#NWe({h(s#L=`2<@*sKwO!#pRAKCVg!tD7tXG!4&`GUo!^ zXunv8X526uhSISh>jZ_N>IXP+%wH=GTZ5Y+w4!A*67t*lM@KNGo%Nds+OvptCh2Xps|UUHokqW&XnW@fZBgj!VdJN^{Q}R~dyA7J;Cj}I#3pmK_6n$kObc%itTU)$J;t7|vC*{e& z@3TL-|Ir{uq*eH-k#-wQu4`t)ll@}c@FG}2Ot5Kmnmg0EKx1Q9jX`OabZ&?J?`4Xb z{0CZ|vj#e>0M+U`eR&k3QwF0oXnan~xRtyng0Ny)Cfjtdzsb*C5*e_~xd8~$V1T$I z-G#8>`t)UPw}&%VND*k?+--k9S3KbC&VwzTBwv(dB~}p>pkX(2T62K_U6F$(A8<&A zY>y5aiomw_8T%Dm^X%^Uvh@wMy6c2Y&6zWRH_`(h+U%UH1yMJ})YtDp#j5lXo7xn%q_4k&bw`!(tR_TJxK zEpu4KY{esDGhljPjZ?zw0rgcO#lx`+BnDt|RGI>mk4@`1ZY}MG2>G71zE(Q@puE_W z;0qau6;GU&YLswTNb;SHnYeo!!#Yt58?pN&a7)>~)td)OXr+yKG6no$S_;2{9;iGm z0Lh65(mMaU8Dq~qKcnZIF6HbV&9@tsOBxh|l(N=0e;8fK_X|LELuXP{9UG!xH);S$ zeBG-%6QYdlA9;&mwl0mZ7tOm9k;4JRoSH#!EGPDM@!;QC!*w|>^zznghqOVp8o6a40bn}h_C zhJ0NJ5EOG4Af|_@VJ43r|AY& zejVRZ$qm_v5Y_%m#=9sp!v$* zYxx*w-Ot%tg1nbn3!;Sov};>028|>AD-;vJ5(QJ=q>G4=&Y1hAR`c`!*wpHb%u<4G z_C#5aiMG$~#jVFup`12{XZvkeTWB-i2s;)SzVmm0d z-o6lt|0K~IT_wajsKZwW`5*ZF?k^eoo?Qf*fn0hyN=zdo1T!jIf87p=p9xRajUy%= zx&4GsZ=!|$%NDW!%8MHtO8=Aw0TRZK_;4XC6sO2Lab#f=06+yLHkPI1rOsvwFpB2p z^VkVgxI6@o8zw;#2Oq5x8fW!KMMEXbYdU)%T=$4OK*}_{X&tE|s8eDiC&pFly&k^X z`)?yFR1Av!Tdv;uN4R}!lPkFyh8i?aRK%8TCRm={k8Np~DO{6}Og0L3Sv0xv(DcJ< zw2wjCxXs+zbsccp@BJE*DbXTWclvX1NSh>Z^{ooht4x(r+e*_oO_fo#+!Q<4uCy^V zLbHXfmIsXEL;xW15YSz3;JLDO({c4G!#5)y(>etT43gnvXhgqnbM}zrcMf1*LxQOD4&38 zzwQ$$kfVfk!AJgG(?PEgt7O zC)5uJ4}?;kQ(2x@4&$V5P;y7jiK{L za8fR1s(baisv^aC!Ek4zWqnLD<2RVXa~4UeeWE^KXUFzBxB&3vV)f4uh>=eV-Bq(W z)N2qhJwk`jkkjilL}#O3=sqQ;0~So_KYIl*ZNX2yfAu1NCDn-nTTlZf(ATVAc(4Z9 zDK1vgqMJdcuEC(?*k9ed{~f_`XQGbwcAT)A)_1WMRr1(>WhWvwq}VGsIt=uac(mt9 zQhUH{WY`$CTlL&y zA)9OYJ^=RJR25{sy#2{;|D|?XtL1`WckEk4l4nK%HsLCN&b_F^Dcsq6wIsm>Lx?a1BVW_q)kAJ@VwKiLt#u;XsI7HQ1208nd5Xb|h zVIlo{)>z_D*`X7hLsHPA~^R45!P6Tnr<~o0g zWOrY^5)Ih%RmAla5UlGb?h4f#+MlLr;Ru!m4ewDGJ`dhfHZ8jUg~dqUyDOhL!B0U3 zSTyMr!(*eXnr*6?WBH*RS5;e_!4W{hh{p=3aWEZ#SK%hNbg@L~!%1{#>+0sg%QIra zlG<`$3M*J1(k!#DpzW*wWyBm~OqLhzZr=qv52ZkCbWn8#ab7(5qdTwiON+aQ* zQM|(Ldbt*GP&EpodZI*$8H@^2zQWRInp9#lAWJL&FV@esg*BXRRd$JJ(yMNbNa;t@ z6QEiGlJJB~<;tbibSd?%CGUE{fJ|ir${B0-;u81>b~pVHp1?{=;3KH| z5>emIGR@$qAaqiR-s&DAmLvp#hF&T78Q0u@(~h=oyVkc(Jm?h54_fGGspe-jdgCzn zL@Ig96?t>RMUmqPo}yI7>R|X;shCu?bG=7);|hpoajA{^k~4IIh_WlJkr*UBxFIx* z{JL zFLgqpZFbL(Chcdgol{B(S(KxapWureyr$5>iL5^;GTs-$gxA`3tyWoXfAhdk>#;WQ zkBiNDRR0*nYldko@OqX;XnVj1w|Uv`{%Wcs=Dmc{>#%ICc?6n~&PJ68E^T7CPR1u^ zgu&cg*(`#Eu`|8P}DVPd4pQwSUQ(1*8kgptLGa0lo_mlr zH^8YHVEUb|+QAqGyUYMWg??j5oLMrTIO`HMZS>~~X9fe6I2sa6HOw!4nd>FI2ZfDi z$HcPjH;C+vNz{^~1s?b-`!q@L`%Y@wMMUOvxQOXu8NKq4lE{?!p2h3$U{J||hdTP= zd&T@9nx>62iY>ft*w#O*Z_dvBV_?=OZ*;s*2SGKd-ld37$TNS@|55U%s{blzSFxv& zX5Cpq1cY+b5eE3q|LDH`*zvoVn?6(B{n+`Ks`Wdu54_;DVEy9;Pwh-oWCpy)hGroP1^JagX&CUFu-bZWY__9Kw#%eQ1-+tPYS@Y(#|62umB*CHqI*=&U@=Y9yKrV| z%*T{Or>LJ+2MwMFSQvXTn}r-S`~8saQ3&0ibMpIb!ot#A#M>TZ;OOUU*D}`}6~5WX z(yTNp;{b3hU5hzaOW?KJ*({cAH6txiMJ*@WN2OkJbL~PA3Z~KVE?rX;IvEa*s~E9yuba_(PNVk#uC5m&H>2XQmujIGyd2T!Ay>iNLO(NK|+^kb4uztlXYO zEfR5*E>U>^Ap2;Ja_$X*3%SdC{CdrDGO?OIb9b5xnV$0}g|r|NZv3_TFFEojcPgTs z@L=}mnvHw&cBS>3LT?~~mP>gDk5yw&_=Io$DxFGYa$Mr)ITDC)ik98`Q5hqg(q~$` zM+TNtpn1tFy*9mj5O-5=-W0PYm#3GM(wed)CXoecMS!&kW&V&>~sXIK{IG;*~V(efU zyl9Ffa*c_?`ged4ag`=IK(kyNtf}E^+znS2DFS625N0%1QV*sW$`vZ6>ZR@}0vu~t z1AN`WonW#khGNIt+1D~hrYfgNR|^!O+%I zn*rtevDJE<>!$&ZIg+=Ke8ve)RW22S;|1TkA{%5Lxr&YsbD`I3)Gr}A2Qmbwva!n9 z>ftVnWg21H6~fDeOIJWiMSe-U3v@h~!W0&-BLQM`6aHq@WE~YvtTmr@3k_nQM%P^p zk_stB-h2pCTHNzTgb@a1vlZ@Nqi4H(XdNzqa5X08{Er>K4JCzgQPfNQ4lZbHBovXQ zhgkpY=}ZA$k>Ip$=o1`-ugbUIjuqk|fJzmw`0xIpS?PuxOv|1~8Py$ZonJUgPjLhy zv|*;)u|uz>nl{l)h;i7!mZgSK2$1G*VegTC5_vKrecM=FJmB5=&nBsF$jArLeoAxY zLICL0*t{1Q%cZtpZvk);C7Ii&7RuB1cg2M!db*`}-Pf%lBU8i&}5vg7BNt9+H{d_<4Xpr zuIM@t5oWg-@H4=&_xM3WNedVZdNtZeaKD}SDl)q;qw$%WDquWJ` z_bb}eu0Tz(S3iX>{9$n*s6~8~iEDV1ZGrKfh{B}AD8xS|8SVt+f4hX8%ix-!w9H%l zc*n*zroG83#S;waa93p5j8@{HO@hgS$eyeh_VOdY{2_!P>u|KKrY4{{AvZ9gSmj=U zChmrz7Ycd$D-|)C`Lz0~PV#S^dHCc?g%-kp4Rw!vyU}Zg2gEM(`{iJXCa%BGfYiV?i&6eRK))brNpp{y24OOeou_) znZ)L#kVQwbpG57rto=T8B6#!T=Cg-9_hO)r*cnj%W80DyMXKL=-}FoSye^MHf-tcU z4Xdx2&Ut>%^uO>4C&&y0iZ=QuxHsi& z_%AEw#aUO|2)HE}yRh>j@pXX+C`>qsc9qic`Br#S)cGzcrpu-yjq(D;!KUx2E^{$` zAb z6XN7}GEN9mTD4UfmrCl2`!JzeEeow++}dl(DoF{_%tew@mRgZEYJ!y>Y*pHQXco}U z=c`BNthSrt#u(zCTG_Iy{|_{p0CUcdK{A^i*%;rOIUvC>odEe5Ej#Jja1p0N{u-}U zgf%kZ?)<+hQ$rfjpVIcn$_(xhK=ps*+@wsZ`C%fi5KX*AB_-D8QCo|sk(0$Zf6`_v ztecWSAPn@B?=ILk4xD#+U3$X{ZRLpCKL_y+ z2T%%cU-U+*7w#%6lvzf0v9%ns+MXQYO2Oc?7YEg%NX zVU-FdB)vDqjU6=NXf4%1n^v~TLi<}_DNR{Lt|6z=8P$yo@_!O;Tq8XO8hE3oJXEaW zr*5eM9ALm!(Wst|dG40$57qKB)=tHG)z5;88UBI^Q^VlW1|KC|mi!xieBNYc4!$9+ ztplZrafN1|K@9YbMlhRSta#5Fk%WslWIJX6c>vzO)k4)ys5VB8_t){MbnXRC#2*%Mb&_piIV;=a8|6wxE_WttvW^xELjjm4sJK_ z{jt&|(7XPxJW!b|99+}b(AXZ!X6Ba)X#w9vKtjbG`s|1J#MdB=tZ9tqKVtZw@A##f zGD6aD(SpNR@!I?^K&4p#+)7N-^Wm%o6f z%*ij%Hc89N1(_OJzBFvqn@(#58Fq9c@?CH{I!PV7DJS+4Mix|50rs(MPkmwxB1Ogy zx8iCP;y)o;1V}Kckvu?X(8RwotyTh~I>qdqBy2w?i;nU&<4W*^v_o6S$^#H-Bmc+S z>XAk({~DFmQ(GWxtfauAtKoHYiQ;WmJ>`s_kN7$QAAk27nZvmeJmv4EmM9Q3Q=%OfsM6vaM;{rdoz?t|jQG6hP9` zB8{d)tauy*@jyl|laY<)y7A(ZPoca&lW%lL5`CY0V)Wz!NLq|!%`NYT$5uM{{6GzU z768G*AP!FgjZ^i03vm}0h0}*!pfg6=R~=h#p>*SNba__~eFbutn$d5}9Il*%DaN@u z9}}+lU&X(fD?r9mWGoJ~LV}KRaFsP-%J)U(9|hI_Wqzk%wAI{_POkGWC@?^KYLdYW z_w|CSZ?g$L@6`bhEY{tDWkTt~oQy`{6+yd+L}N@F9c&NoGF?zCfiL8&?l&K!hB|-i zF&D0)HhHd7ZDO(KqZ(6;j`A^IV7NGFsaoDrRC2OrvEh5vwbmvbOF(W=a86k7aW znwU)j*!|Z!yVzjzc+H`zn`JbGlG9{dT9{0k{m8{*BwRe zkm0df!RmfDIc1J_(lSHFrQG%1)Sw<1h5Mv4Dt87^eQHJl9*^jmZ4<;ic$K<8v(*%L zr)Z@;O+mXwnfq9tsmKKwheX?Q)AKP_@xL=Y~Sd?B8x$- zt4XfZ>VC=>@3?UxyX*7ooMDY?e)}3#b!F8%X zN5nUm_>T%j@fEo=y;?lLzMCyc7>VGaJBO%F*O<>wmQv6R}NwrW@9TZxl9R>>9!lwI7*o89glx@ zi}nd<7#=7V9XgUtnA)UK;n+!@Fii@-RyoM7sCn$d>Fl3?jAu2w2m!53KDcs#KYgl3 zbT(m(a7L2jJXlXwelr`McBVz%plUB5y>UHE#de0oi4azg@!36cBAsb1MTb=$0n#Qv z!oMGPK71w;6zG8<`0=Q=t1t10TJ6vuG`BCo(>2@Bg~B-WL(WT!&0hYTfec=i792qM>vHY6@u_f1Qq z%UjYVpRV!%E&(rM9paBok+wy<9!h|i2D_+*7HWq6^NU-xoCV&O3h_U1lGILVa-poz zPdNgC?|G!g?FAel-n05!p5u{){xRsHp1tMipXl?K< z*64U#u>M!N17_)TOPV!c=!|r)#MEhb|jO!R!h~bdDJyO&vN|kkGNP z?52<;D7QPz`6@vmyO!HRq9{RHj&)t%ynmu7g;RN`S})GUhM}IxBLg)h=1E+K?aG!% z338+B*&Ks~oOX0@?i{g(OKuZRtisRqZPbyXPWcVflR2bwLYIo5^MCn=y$0e0EgPr# z%Avh(J&Z5^-o;Cmw*khuaYZ_%fo#}R4P8{=LqP59-aZI-k{KQJ)s8sLP!-^>o%30& zZHEawzG_Miwp!k-mjIqfed>FYR>~nxGebI@f~vDNO1L_hK+8?dP-$Eg>Wd(WexhFy zbK3k|=q8s!P|W#GY>*#IM+rRUky@5EH$!51GtfbUL#7x)Vu+wBvw_T&_rq9G+9%Ni z4xv<?*|2Q9LBvpf zCnkQ{*@xg`8|N4iB(UP^TgjWqtYmr+|*}@pzlehohEFj zQR6hkgK2d0pdne~rcd|N5UA##Ku9`A%tUVa{6Qt4O=jmZvI0Y^fN}Ur|Dclm{Y}Ux_e^D$@9O@7aFQa+$`8XqJDFME@S{{u{dsorY z&9!bOUrC2Ea3`mh-MJD3z zz;cmsUjRvIbhlRPyA_)Inth)w6Utpz-_Fg)(DEXpA*&35}kiwmP0~Al{+C1klPTwtGc+<_~8G#^Nf{Lv20t zL^9%#N{21%JL2=;{a4p&34gSeA~a3cbr-z}v>H_$CNwwV{Y9SZI|irnq(7c426q{| zlay&gs*tDn8~bM$Y*=L?$luP5_TNYJ*&#&_W@Zs zz;=;JZS1W`R)11$W+k;>zL|x*2uiJqcPxq2h*7tQWJd6r*cKC!PZ~s#aF}@SAcu$l z@B+<q(uGvnRTP47)0!bFTpK#*vmM03xs)L<#$ zY&J54&Kkd`H{ks7hr*zDP$|Bu+G1T*J1qF--G;x?VbG{RueiEqyG~_WA(NFcS~cFpFH)^J5N6IOgKa3UADVy^2_e^Z33*M3K&WR zopkiC;*oTQ8lXVsJCwq*^Pz#!;dM#Ft~5b%;o<-;e-`Eop-Rs@Z*!b6y3#Ht9h`bu z(`nkJ{8>^sHpv$d1{_7PnW59E?SD134riRx{n3xo?|@|sXQ$c`ygW|=e6(Wr?85Eb zGDr$}BT0$nFDiz!T^(&?EI*`T-D^_rv?zbd2^;%ZzS-R8%7%u#?B8^=u9AVdY)1Ju zmYc-US`Crh1j4bg@S&uO1f|WRTLYLnl!4284vUBE(#{zt!bG%VE!MD}C3`|*K72d` zcj_jvixg0On(@T36=+BJo=k+DR5cY&h-zPO#ysL)OT6E;J*fXok>cdJIJBzXk9baE&72XB4<^J!1AT{PnAG zg_oUnkqTjP1x2auP%i~$^$Trl@7gw%**oD7C9!DdsK^P@nAMA65Lw*qy=p=jGho6p z;vW>2YY;iiHQU3RR-c2EHEATzy){U}I2kVRcv)jc9||XIiXhdVP(g}vvb`@Y z#`lq17zriFmzh@?(+O5PX3BpLW}+&WWziQqj1L;kX6S`Wz{+eWr5#&1wM6`{Bnwj0 z2)>V~7VB@ja4mC$$*-~ek_I{UyF%K&&+qm;%(}b zeym1`&JYv26!Q1850>zYqHj;7A;U2ZF%wC88~~5a@4+Q7%NJu}$avyrWkkfn6BuK9 zC?LFP4drUMWp_%0(Kloytz)`knogz92ynw69G+v~$m@itP(FjV)_qr9Em5V>Q5&%< z0h0BZ%`%GbG1}s^?9^|NlV=AhqK=6&u6J2T3Zn)s!YAFu{fs^796^YqCsmM#AhcFg z7|es42-{giYX~FGA)QKjn=wRTzI_S`An|xpdq>v&D2?s4E}d#Ao-djs<3}5FTK4Obsw#zp$4}^OVQ_tfz zT|BHG-S=+m`6WMfq67p1vUE%#5hkY1qp?!|SYb>a$;v9Op6xHaRNiF)!(xUY$X3W` zLrB7i%CE4|ifH@7mTydbt;Ua}Uawh7o%?lKx1|qJUg?*5&hF>k?ib3`8EC91{pz7L z1=gNG8$La;%Ain2Xbcc-&#N`#m~H+V_fI$sI9S;B%ySp@861hg?VYMh^Z43XXSuroM+B=UuwvpVG9d~^!6GG{OP z1S2z6SWJ|KKvXBBI%dvOwq#MSIXKR0#C@+$Wku0_t{R1@zj12aK z_2YLwEk^P-7WL-gm{)?qy?943hb9_HY4wnAl|Vd$JLQ&o@TK;rc)zb%U8 zCPhj3M)M0*@GZJnyD14OA<&K4*AYTB$-PLbY6%b@8Pn;g1IcHlg0cY+Ir5jNOnt#0 zS^AYvbxzwt*o5_=@*w#8)e&(8W9Tv+Qg^CR&S5HW!;O)K-5SX%iC_%(1_qv&Mg!CvCUI=7FU=;CLS(& z1r?^14QJ$cUilxtXAWU11c68vsc2?hbZ1k+n9aN)Sv1b_y)dGEhFU4EiemGRP%tC8 zq#H6cmnJNLKuW>YP~Ej2mUDj2YGKn6JhgC!R4IH3C|~big%bO0)EoYVDofO8_QS|} zZ2Y+i5Kz7*>oI_zY?Z=CsRJsp;m6b2qTK~JOES-9>u@VrOB}2<2$BAC z2yY_|rGfgRw(~=kuN=J*5d_AkD-gCBu3xXS$&!(;$R!|zSO7lYvEK!asl*KDazm%$ zF!@5{Y~S9$=;@+hHHv1d5hx3gTAdbgEYm>{f4_j(b&-pkqbhns$rT)SwwcFx$0b*Z zyD1e&IG!I>^@soeJ`ge;o!ic&6k=Ye75)m&c_E8A#-GLo*{TlN@{r&k0A~D-Ce4VB zZO$B*mdEZGPxm9H1l*Ky9K8i!>zBMDG{Gorn17yh@F1$R)eLq?zyV*Gi|87qmEsM8`%6ED=U+Ucu8&FE&W={+!F!Xr?I{s% zOW_b|a{sK+%sOUNYUXqx95I7kE9s#F;Bwf!OQ_=lF_34(#JH~Oe?K|4l`<$ct(SxS zy&-m*#^wk}h_1kJS_u_3lAGNxf=dV3bn3h8lbIUfG_7#<`*Fs<2eP3}wR9~BJ;Ms* z$KT3F*P;f`rYY#e=!xhbQBs#e56ErYQBt3OTXr$0D5*|j+f5W~?jp(L_bZ3~58cER z*boJZp$69t^8?ZZXGjBTtNM<8|=9f`)ez;qCnbV+j<8ZS&p@{yr zR7s~v;$NLL{+twDC&SxJ7o>f8=aW5cNeoU+mDsuo2!4M!X`%#Yg^vj~n5pC~>1srL zo_iru=&!`}J9%$3F8eDF**cmP)VG$M^p6r&<{Jtlevj}f)T$ODQp7#Y;y76SXeNjN z_Vxt7mhE-*$on5mEH{J0{k@jRt`^|QWB#{)s}z^w0NA>+HHBKhq1by}P_CJkAEIS@ z`n&iwWGD}mTP!zH8-kZoXG-9EFF%NxES1PQFwruiI%ei7*V;}AOzKtpbBga1WFp@X zd}I%a!$q^nd|Yu2ydnJJFNo5@7yfUF9vv=4LxrB`dRCsJsrIis;bv;12Y83+ZM$)I zME-J6IW+^!%-%{0>6}@Jw0^RFwdX5zA8i1kclyi=G%%v<$kmAQ#tbeZY!uo@qU{ zYJU{ZhbKSPJUYIpz#lbGgSFp+cPWtr!dgp)BhP%vY!d7h$hsoQkF+xz{&Ec~+S&eB zEUn3qIr0uglZCTEMT*=a9rREX00QTS#`~C7?RtsleTP5yz>-+d&Dgse%N$|bqL5KX z%dWWEHhddPP61HaIOIvj-!P>~q7$mOpUTI+j}vEWh62mF&1>R|feXqK7D$KoQ=O|H zw>Hh>XItg2wh$c1 ztUS9(i}N1YQhYI}aQ(qEo(QLKlgl~TyGs5sf8X5}9(y$dMNLe_GnUUt?W}SrKFzrK zEA54Q67CCQlw@=jQPggDR$ocnEuHZKZy=E^SQX zhxcAeBskS^zVn#10WG!`r{^X{$3^z@=GSKAJG$!cY=?Jkw@>LDg5e7|d-RUvCnemg zYBwDj!Z;n(#dac6A__@^OnRr=z5Gy4eOMM2m)SxA04REVLujLxtcj5KU*ir(0wP_g zUStUJLC*tMm!tPDa$wv_C^Ud%3fjG6JP!TBF#x?xQ9o+=aO%Gf`N!}3v-!R1M^ZII z{4{$$EAP*wK-kn?^j**>W71fVnn!?_U3i6)yhqs8j`MG!$Zi=KhmZQ93+(z@4XIoO zu!r#2M_Zq;7BzxD0!mIw)BGSB81gaV8RF|KLbNfC9t3=SfHbhSAh&r#9>bXVQ@W7H zVQv>3k4^v~ff{6QpfEmJ9M!KY5GDrW$eX4rrEVcmB1xZ@p3ljtgM|WCOe1hZtsKJR z6WmB6{B(<7&mK??Wun%F5U~F5LzQ6oE*i-ir*Hu*^pxUC_6|J?rA@iW0~|RLCee;n z&=_ste*d?nAOb0+6h{|n0y4bb6!q04frehe!Vb!b_m=`G9PqtzMzVh>|Dp%0ld&g9 z8!TqOJ$Jr<$@;8Ut3wkx%zgtfq>LZn1Z{z5^A{?0)y6C{yo9Wb%JoG|S= zi}t##9iH9Q_D`Y^y+Mo!Ljo6TaOk<)B)*9`*iRK$Y4|gkuyf{YXV#^ z&&PS|UGf-%dB&VS!LC=xE0cjTy^!MYRbu6VeWz5~Ddi$TZ`fNB^iZDT-?g6r$)@oS zDbrt`4*WxT`@Tn{@;n@G^{xn>g8VCMc8*(m3FbdR>)u_OV6i|!| zK?%Wwer`P|7WG=|>l#oFuL|Y9LkZ-G3g9P!^baRES2yoJ6l1c}yM%n)EL{jQOJyfM z)6^p}&8jy^GRAcSnFMEqb>+GFqKZU^a#=nYj89vZ;2eF3(rlGpPRoyS2Sr|uw zx(w8%G6d7#_tGj{v@O%>pY8T2L^Fkic2jW2k3r&r~? z6df=cMiqf)(x`MuW#?jF1j#puy~4HMDiZbrAnFvUD)ge@*~h9(5T6&^bXr@UnsPio;FS-9 zJL(t++wfbU+4j(|Y_}eer;OL*fZoY-JsnK9Omz|&qWnyypeVn7kjRByR+Lhi@*`zR6XuF5_)0d-JgOmOS@hCV7%En+q~U6}d&Ma7ogvtoG*Vz#EDLs5Wn znVo=9n9cpfMv0w8yjGXn)7m3MlHWS!`QoklT1`u=v@`fR^8Fn(?Amat#Nr!=*zaHv zM;<)5TG#u2{Cm}C*%OQ5w-WEQ;Ks)+D%cr#%Ew6^XxLfts=>Yo6-%}YdJFhE(63oR zM0#t>C4Udj)US`CW?G|@d5~rwGBI>XqT4s%UvB{U-Wj@a4;^;&(kMW1slS?+l14>( z-6e~?-&pIjM^_wx%^~zbih^==riG6`_~7@t`D%yr50(5=x7`uv3czuO4Avi6uC}@o zLcihMNj1mQreHOO%*5LTudK07UFY}W9gZnAuExaf^WFA_$W$bY2^y#!8DRQ8pL1HD zjw1Ob1{Lrrf{gD6@^uCXKE7M=<{5{O!&FcO__~;|pJo_VwOL>4Si2v&W#2;%ee%%y z6n#IW5nx$p>Xde2JD2m3_=c3sQp&yg6CW8Z)D_P}Ge`8N4Rpj$5j5>j4UX(LYr4O? z3bMA%g~Y%sQMs{c!W4tEp@cO-9`w&qfY!*k6zL-;sTX=z^uQeY8< z{|00OLznvz-Ob(Tg+44qGuN7JCUF6JxHil)JTyWR|Km#2AkwEJiq6E8`9lB2V(S~k zI|46qEThR$u+5mLAH7kZ1Ffuf?M-cw9M};si4^C9v6zhE{wYiJE%pH(J&>-E2PMF+ zRB?gcSB-&Mz`Wl&=6xUie(mnjz*ox@?7yE$Q-T||pu5&n^!(yD&QZ`&6#?;2HBNR+5%?E>HM|5m_2Fz2_sWMZ0+|Ew z?2m5ddJWxvdEZ{D4Up+S?)dn35uzDlsmU|G)Eff;pVFSH3v%aM8iVH3NZOX#cjr+{ zLJA#vA-JFs;2K+DEt>OJvTQB?upjlC!0ddkEY>Tqo;uem72^h%Onwy;e^)A~$^%dQl z+O*96n1HAF|47tU9xBtzf-Pn&URB@a(1u{ZB8ExtFF21Y(kOgRhc_0;H!a~8BD%0! zF?tTkww@~H+?=U;@2vjJI<5c6c+N>(Gu;;ouCJ?fO)!7*hrQ;`wQ-HZ>SluW%EMiU zhiHcv%E^GNE`UsA!rww5AOoq4)>Bg$Zo&=hjxONIKqt>uod5lWOER~O(ued6zL2;k zvteY@$n|-qCj=?+{;dJpXp7fdlwfpnAep1xp-pI==q3<4@Aosi&>`@=WFB*!V)!zW zd1VM&S(L^Pi|0Ao6|-pe$&9N?xvxlCDC7q<`fw}C;_#(hSrOs(8kcpN(-b+RU-plAJ|(~B*XOoc z@q)$)N#HwNB5_143)9wdVkQeAXA-@_P)P=ef~2ZP{BI6Gm73iaw?L!eG9;^rtiLn{vjW zBBik+)Cd2zMyc{I7kz4@C?=+q7I)jR6Atgh6bLQ3j83CyF%#yxF&$r!@YB{um|M$`clu_vGYD5u3Ub5;> zaX?sTqd8wf`V$U5ZT6%^Cy+$tW8UP8Ns;k8Q$(t?*G*1AbIX*YmP_Ki3fb@a_eN`q z&&mG2_as@BXa4IXIV3l$mmPdo0)GajbT(MwJ7zr}(SjrSxJJ6qgM6|Z`>D%w-n9Px z@-KCg_vz1`q|aN`b8F*x6NYropG1SN1sD{w*EOE-9{L3#QnyOy#sRfwi*!&(682hjA%=q17{snCjEo4-V+-y zWuPEsXt!jHb1Q07H7;Nyo9OFeN@{=33e1g@(o1Yxkp3Cnon}S3jJ+wE zuBv(mu9C1qk2AZ18?D0l<=2}~5N>SQV1doN>B)T#Nkn?oOy}K&tgf}eqbNo?LTIoW zQz4nI;_7zwEB}WI(2)no{w~?mLC3|Bt@J__LMCTG6!-TF7&-Wh)H8H+gxuYWT+`l} zl?kK62q?P())Px`ev4@LIRb-$=@JvlvCK4*EEENnJ3J3v0G-u_^h73+Rq z#cw@cj26)n=QK{uI2XcN1}D559+)b%W`+v;>Fq zd9&Tw#M0<8rf{Uuo9hk0pgnXJ7@CO5sIv-yOVWs6bzn^1~ z&G|C&+-<<$--N7tV`$si;0&KYqMxB`Tw%)RxbC#pi_iw77cW4N&j@*r>N4$Hw`+F^ z$Y~{0uKQN^&?CdoTMd>g(~nVDlLA+G1hZ>+cXjPH>Y}w%=qV&$gE}w-L&Pd5TZHmP zn%7N_;qXpNwYM8}WQxc(=)BMlnHwx^l!?d_>R10a~-9eu4F0 zmG7IrW8bgoFNYISm+3XCKcYq4|5k_SHudl%SxrNL?8G)$2?c}dDOVt@qFyVGeTEgk z<~eC>omwA*<)vjzRqjJE?qy6{*kgHGW_d^W?jX1A+9~q= ziop0ad{cs;`$BBn>2x)8S%}dm*4GPDjPPriDm|zirw0^kwr!9n^wH|!HrCszO!vBw z?ySm~K!S3>U7^D0VYy?qA(BDVhkz%X)V~1&tG^a%ecjRfN7&d;1kE_LGa)*%A(Et; z*8yrM$${n46NpIT%Fy$FaWYU(#01D$^TJ8HOB5q^qp^ZgX2|luOMkA^zFJ^X-UAC_ zeJ1QyZ4xT61Z7An?l2^cGlLDQqh&#P^relG1uXPRSltF-bl+TQwfW~!uHJ>_bXhH- zLPx%Szne7Px=ujIk)v$xwbKpt;COe`piJ%N+G_m;zaq_X%FxVteVO(gwETXD$!gXK@IS{2cxVn-?YymNY8nDTFm&p=;O0k7y zc_$R9_khv0wLsc>x~%y{k7VoA0p@e!K9!W zOZOo~_w8lvC~yZipAh@Urb~$1@f!a8#T~{mocr_%*G=LlPGv#ZA&alp`=o1b$nCo6 zE|vd4dzY;uLj0AZUOU`}`g_KK6w6evX9#Zn7ywF%DuP}^;8fg_CaWcCvaFO11~!;} z>R6+Vz!!S-zn9S9_-=4AGz~9rU%uEqTEs{Qve;C6NoX3Z@uT6*^6;1_3AmOkgc*up z-<=*K6R&|5Bu<+xHe=bC??ldXS5B-kd=NQcRGF_4sbFnnJZY0N+>g$fNUXNJbA>WFk5}Dj;amp&D&W*lyyTgGng0yK1xuFEJjUJFD5czc8`s{NI{;}w6LV?Qyh5m`Ga+3uA1c-nvwtutR=W(+hze6xI?0*H-jje%4Pzs*GnPo63c~+HZRij>hL8v^g zP{o!uqtza&#i=%wN`c-WRYKpVVL!aFqipnvqsVk87XcA1DO@OG1@o8xqxkzq36}ls zK7Q-d1+yx}gWPKYN&0~Qg6o2I5{$=87-qYs4xtJ>y>sCV$DkTHA`xSmYiDe*U4IGJ zg{1GD_4Vh_w_*DBPS9-<{s&asE#VKa6Cv#0b@*-LZk3HfZ)l~tL~3N&=jZnP^&WFr zfyer`ynT__WiNgfoYrj~aWDh#&!Mz0I5HHhH#dtr%&z|OMIfe&HeieyZqLGVyp~WN zl2~?2+=K^V7TWs16wd+CYp+?;?n4X|QxOaQf^y^`UOf6)b`?_bcub)Hs*5y3;m{=> z4O;@nqgz_f#ZVjHsoCk1l^ai5_g#$7W7jsJetkb4mEH(^fb-HZuA&AhfO;38IED-W zo_uWoQoQ!Lyyo%+tC;@&2yXVimHiT6ON7<-9blgivs&=uE(od+IV2fV(c4n>WjH0v z^b6NfxqOH6OrQ_UbMqmqq1Y^&!h55V^yrk8TUq0}U&!%3F6w?i|Go`K5f(J%V=O`gkT@FJf12 zyWN91`O(4V(JBDEPAnL2XU&XS%jf7Ya(aRUV?iz(szET{EbV#6?v$*4oFud?6SX~K zIkoR9FLVI5%QIh&iT`}ibiA1uEQ59VZSfQnc}Q&Km8D_=djgnpPw-W^q%0}|qW=1y z7p=jWQ?-31;yH|f$dlg#aa{O2KN-!w8F=p-fK~;M&ZJIe#yBD!$Nfndwk*Z)-C-UM zw}Zc^h4z@(HwudYg8)iOvsKx>$*nAwBR}dU6O4NotRyXTMT?d6j_TlEoe6>MRY@o# z_-fCK{p`Cp{I5<^uXSKiUUjdnaUB^1&xJtCW11ZTzypQC_E88jw!{JkHZ=tZV?jSl zd`|6KC20?i(3Sr?#!Z$C>B~f`)Tco!>fc|Pw}FO?TowIVjgicUA+S*W;5#+e(13tm zKLh#1@=7tb619PSSSDlV40^_%*WTzSK+X0khaczvt&7!B0pYLqL#f$+tnGW^t;Vy9;x(SZ((^8%+H?xS`3%8fEl=%^@xNRM3vyVvj!3IF zPHm0+6uu1gLA~s*Dqv(9}9kfjYuD70vh@@X#PC`fl0R)g7 z0H^*ulj&@3)9(v*&#AxooRsYjBzzH|K*(DtK_~zZq{yR7N|(y}{nDqgBq#0rU)1eDrbA)1ANXpu0eA^lB)`L54WHuC!{78xUD@f`CFD*($<^2uoy8k{Mk~H14RdV1 z&X%_~&B4WiY`qR<4=(PD3*!5&M$#tMpernlg6x$stKE^4e9bEpLLSG{bt0i>9O57S zWKal*lvOWce*Q@8ou4nOYBJpxNHSyE5o+j7kOpuT2X$Ulq&RuJM;MPvBB^2F6ik7} zsN~nZjdR*=y9`MbTag6RNd?K%BlTXwqAwtO4qu(GR@XbndM5LkWSnF)Z#LY{ z|F#FumjXys;+t0#wNbQ7PDm_87wM1-%i)Gppeu`ZdPeE`yX=sh&!Uh|jb(vM^$aRN zfr!E~ptI9WjYcp6QZkqJa&hCYUx_D(Wv6EJ^z{(D z_s8fhauGk*1r)0>{~#b4k;9#+syGbhkW(b}DQg1Fvf26I&|3^MRKxaAn|9Fr?n*O3 znH94x*T7$YPm%SyT37wv5}_R?+xyuar;828*z`00zDz#LtYaIcjDv)cG%a&;61c}mW^i(a4$LrsBL*{m7+U@xwJGI-f8zRyXM>x+bMa0cKL z*+`<3cxFH>94scn-9Tw0uL4o0^hgc8V6`}sr7GA1?1pkUwD!e$|63>fidFY|EV(J# z(>|-G1WN82mBlauO)LT>{8}h$1XcfI_(;7hGY*s4srKmmbQI>)CN_%%vTK6Ej3LMh^YVGCSQ0h<|8DcV5s)6%txG4GeA2-&{hEniB~dcAjEYi;M*VE=2o zCSG)$vR)AN8LY`oEDn(`y5lk}QK)MqiI0cf_JHycC#-Mo@n16y!J1b4E`d_Q$Q?wo zJ7q%W5~TR3$?a?6VSVp^zsB4Hvi_}=bf9pDfR2faMU7!#J$sh0_N@_|OfUfu%xgQh zpfAE`HCQN9F;f0-sz?6q)cjkYiAxVNIG zpaGSTDAS*Sb1Pv#u|`OZ-0$}vlqk;E|j0G9VDX3ps^ z;9=KEM=4lhq<0mz&@Ka^yk+u0pihx+Va}rS)IOMO``a$zYW>{L1w7Lf3<;=QO*0B9eimjU!)A}_vovP`KmQ$yB)q6qqob_GUMli3>ZggF^tY)ExlZQ_E zhpUMi_Of{q0a!(jKiEhNG*`H)^6Rf)%-&9PqV?3Xl}irj%gh3E2_AKB{}620oZV#! zl*$cxje7*#P$A4z;5TEEYRR?5KjqpHVT^+X?j$hdyPvh(s$i?KgePiA{`IJ{RC*_| zZOW4YNLj#-C{W+!BXx^i-4?JtDtnkHhVg@L9FjNL2OIHDAU^gvuig4pzY9##zaD5H zb_S4~8wqRREb=hXZCb$=`F?w1A5w}FiI=%MJc~4SOy1I7w;x&SzP#<$j-uEP#8hYu zPX+A%xZ|Wf6fffF1FU!dEW102*~jpZk(Ou^U>U~5GtUC2ja?Nous;^lexb3TqsQRA zj!tG+2iIP|Wt_iH|C74xT^s3OX=U^GLrFB(oQN86`; z+AqFJfy@XSVO~Z9%f|_Mc-E_9q%dPfH8C+Y5*ip*pC6^yYe4Ovw;&%3gYqlII3Eob{Uw5 z;&PO-R(PnRM?V;oVj?J~C|aJ1NQZ^$>uVEh-y4Sc13MoG$$aU%4MRkC9Zz23GWXjp zzP9INTXV8j*WfYE36}Rmh<_SG<^QuA;A< zJPnMd)kX8!p3f>0^oUAgdVJMDL|w6Sf&5ngC<9fk2p5(wHJZr4`cw-J*>c^Op*YhN z5ML|?ac4>ADaO4mj=hpjQ^B0bLfugfke=B{uAK@qMr#c))q@O^OIV>KLGgvd`Om>- zNggVfT7>1!Qq&Bbx74aoIY0!bv{;9tVKNM*eigaHGLR6gilXYe?_2Y>d}sS-E)?f6 zJLWSqQ6>{LWd6M!8Y&qOX#-+-I(!Ga3{pS69?RqZ2S%+Rr44o9h%D2rY=>oIweq_RCh1nG?s;4EX8FX5X+mQCXEtLnfk@uFQ@sYS+zUE$1l0DEPWw)GK$=5)s9GPt zmOTcio91l2pN*nSv5;xgjBaI

xxKcO;AGIcY^bSU^XtvdoRu3<{N>E4xM|NBfosme!~@)SyP#1@Mo7bq&I4?jE@`{ zIcAvTa~2Z6c8B+18XH8m@vw`_OC?80l+(yCEKtkA0OrToNEIo6b0G6F8v}^g-5C5% zF5?*FHTR_Ch53OcOEgrTBe0V``y!URZ)A-vJfW~ty2Ts&UYFGVD)!M8ZEW-&gFE7` zBQMtLvVh1R;oYLz(a0bDK7%?xkD`8M!ZHH%dwkP=ONi#hvK-~S^-!O$zNG||8%}i# zbtVprOXVa=Ja#xh&z$qn{;U6HynBR%pIes19s9hbRHdhWWwWK2I6+c{fWUrzLy-g94HkHB#!Ik-PmgTAevmM=rbR(BJ*GvHb92!+@qRrWcor z{DnXvzvIfK2JM8eMz5C~0~-Hy$G3w|iA0|O8dK53U@12BTjrCb0xb}PA%?qXG zgxK98CvgPcxD6W-B?TNT{=D#fA-Tc!PFWkAUEB8eNlltKnlah9(JGNp%mamek@G|7 zb*D!Kg0ou7$tUajsJHaOoUC)g=h0VUv@^Xc4(HPmJ6*3AGKV+N$?KEkvGdm_Q zv7)_9Wx+BvD`2`Xb*bH&m0xjpiE_}!Lp6>N~2 zk`GkITn|1C>!5`#A@XFI=H_rniaRvsMRDyXJ%A#{T{3DJfBzz!Db4G?pk27zl7iy6Y&9 zpU2Y~e?js}ShF5dqrw2S-N{zAmhC_QOa_{< z4&fcqKNv1J-MjE*p@8A-QFp?vx6B=}7M|zk2xNiHlR$?zPE+BX?lb0=%u+00E zJpBFAU=94S^MteYGT7Z4CR(1{$~7)OOeT}HcpyR`(O^@1KBD5HdI(XZH&5BFwpX_B zT8F5_H{c7LPH+d(MD+`v4xdO>Xd5ezhz6L&F`S*NUOegX@O5-0Vv@cYe<*i~qWy;z z9=yrN{rere5C~I_>8OK=fe}kUG1j5bQR8=@KU5P?^8lJ@)e9aja*U@WpU99WYk^ja zjwds$vgR+iYM5G{EI||~0eg+iM+kg)>f7#qtbMu6^>G?<%z)*W(ujt%PbD))L8aC9 z)_NH!j2LmILJoJX=4G5BRmdnGapb)yF>kV@K1q|Y8ULAxybfv%EN4(ar>;m&93GQZ zukoAG&?N$JS$D?^pFeo`!wD{}gRi0ZUdcHcB7pwUHD!3D3z|Lv;(ROl6pR9PV^vQ@SzG z+yN1O7{}X_q}i1TZDp@RadYamC#{!U(7CTSbc{RYltW!~2Z6K<5{6QTo??c^(JWSQ z0>b$Zt4@x&yZP1<$eU$9Mf$2ySvgGIEMw||b@-xpg7#=i4#|90p109!o%g3`+aAgBScC%spD+LJk>FzeBaG3TB8qbi3`N?`@e#?-DdPE zLWCPglLCLD9=tBL&V5}!PS3y?e!^4OVsI5z z`cSY8Yr#rPfa`be3*stXleGXy^zou3(OyyiW8R-jNPW84c$2tkI@^1ZeI1 zf(a2C2$o*f&Oj-kXRo;*E&9)N*S$X{gpUj96z+e;Bbq2DJ7$VQ7gs0E0YeEXqK&8m zepXAXlDOv{htdIp|wKmm4Q4x+EMN6_{K-(v) zGbGTnmpUbp6xyyU?mTEJI-W(Sp~vE`r}dmWIm`dmG#lIZav`28)-X{L7|ZBOMxy{q`?`c8(cy= zxv&YEeLBBC35XZ@zi0Mbg`0&e(SV5vg+W4r??v{B=eE za=wn@-$e&}KQd$QfH`w}4lfowu#ox%sU8(2fTEu+f1QGqM(q7{IeMAJ-}D~-$!JEb zJq}QnIw!#x4s0lIQ2FzW-?U&HhLIuG-|<<*h=br_ox#g}Sug%?Y4-%yY`ekJ$nn$w#tb{E6` z9?~b%TVw5F!ifF*C@TcZWL}?|{knMb)Q*QJePxs0(!1n7r`Q6+#VS=S+~LaU?whR7 z?-8317rex1dbckc(o{i6gq@RFEMT45A;O#W5GNrzm3UrrsanslBxVI;&X>4R3i{bw zCxqHFGW+4h4v;et{I633;bXL(rN~oFJV=y$+*E^@Y~XymM(<^!kkXMfNI?ClcNoZg zSQv;g0?Kl`C6K)!GrzP+@~s<#rFTHOIgD_WMi*$Q072>ycHdlJ7}l^X$!GO`LD_bH zHm?ZzI)}L4d>$d6@K@!_q)26Ce{h~b6I@cJpy*V&^--t9vlu`&%EHkJrpp3W#&`dH zn2W~^5tf$Zrs+S&fTb0MIFcil^@lj3hBu4pQ=KlDgs16}8bxk6CJEY>DEvKgus@Xl z5&}gn52QvUi9fEvIZ z80t043fbsRgY7l2!C`uJebibdek?Vde=QYEbo6^ z+4py-%oDEJOh+R)4(`na+fU6m5M?aKn8(Aw#jZqoPwGDq4HJ;BQgBroE@9a)1q1)q z7y5hYD?nomnx&;pjx!jNr8K6Kr_G@30LVn*b|fYfDRXT0M za^cNB>_+3_rlK%OhVP3dP~dbr@d!e|j@HGYJcw}NTKKlyZ2yI#Tl?afzLR7t3%6Y} zQqJ{T{KTC*Qr3W6t(OMhC;=RJl?V?S;a@isCH`@~JMb&CCuE2hI{&$vVaNhRPoL7_ zGsP#21jx_?v3t|a1T92YyLGUYgMkeH-+4FW>2;=1y*)DHB(|_vpz_D*9@;bR(C;A4 zB(HG7le3U@Dss))HTD=+D&NjzNY#0yLxTdcRa8n|#VgsZc05;em#jWr_h8(ge;0BF zX%qFCayxfJ3uUn!3tKE0IaMwEQdSWE>jMBciI!j0l-*A`+NA6Whs5GiLR<2siW0Cs zCo@gy?B?*bm0g^?}gbDkAC|uVg zqwZ(u_BU^c+n8o_u;BE61@>Mfth^>V=?jUH8YZ#yB-R{oJ{dPrA;v>1 z=Bv-RMdU{USl9NHAA76U70hL%L(x-V4xIWX195$Jv*`P=>RU=|H@#Gp9=W*Q$yprV zvTy4-MrWoHAxqoCi)?uGz^BWqs#NXd;G~2FP+a1THW6IG#ZcO2ui*FHN#Oa`_0w<$ z&+D;hg}C6^@c*Of8W`*9qGfC~Xx!MgZQHhOt4U+qY-}~QZKJVm-22k+z4sUPS$mz? zvu0))hND5Z1(ADNL@{zVg^hC}$Yuf%q(C1M|o!sLVLS~U{C59eVJi(e&+ zoKBkPfnOvT{@Ut+kps{+dE9wEG-8l=)}t$xr?m=W$dZ%WQSxG>awuy(*Vh2%7zHb( zvZVSH)4h0$S?#=0ngw=EjoWy-ZD@tE(hOiw>iZ*&6|{_G?C|obU!*w7p}g*(Ih{Ow z(RU0;Rj*`Q+3FgBk1)!tr#3u8<<-Au%)!-()@43=12uUdm&sxp=<|F5c)W3EVdeFC zpVIJq+A&j#i58lUK`AfVOlp%lD&=|x)$ueIDbmU*eEZ4|l169;{IHtLNp2_>or=tk zYC59y+9(QmnzGqpibvbb9YyB<#XvAcb{x!&W3dL2Ad2nW{4-vqFPejPzokyHTS9x_ zQ}^T*VR_E|d=k#vNU=D_Utx+;laJ)A_l*QWd?=Dqcyuk+iT{2xNx^Tgz>VPMY2+mo z6TdPhp1B_YZL3Vq%v^XO$_zFmgqhx-*D`Z{O@l$HLJWc=n$HidRMgPhN5ySkTYXqk z7<^${1!Jzj`?wp6t}AW5q}$n}nJzG?K5d_`%T_-pnvA`d^ugEvfcS=Om_SWpKbicI zhl(rqugfBAUbynIQ>Atmmi)j&WI6!~{!NqwKagdZ>q8(^#NJ(e`Rc8yScf;?SeSoI zC|`<-Fhvr_M9?nrBCjUs>YAPX>t=h#a4 z0#j{jUp7~iM#ACDEZLtct7t4WXgyT3#6rG#>~x0)9%(-{Ad$VNv^HuL1k8Y6<%}r` zWd|P`=*IRbe}K2`WLHc{MxtF8FXq#Nro<1lof7Q$?}NmdcKkhT-CoO1x0EVG$3!4l zq_dDB7hYxbxg~hgn1M_Q_0N>Fi{c{K`&AFeFh>qfLCGhRTCl_(5Y1a!FMJT&&Dp8) z%}5AcGQBv#5n@U~?EqBsAVY)caf$aq-}Fn-Y|znmJ z);D}f;X#y>$frwsi0s_mt55~X;c0&Ad=~guYu8M1xK=|VyvdArs~IZrm4u5;tn z7)iv-=li87=n0HeInq*1+C?&5n_JL(bJ;6QBZ#P=3r~g*p&_1MM~|a6crT5$PF6$; zC0ryS)vTWm3_U~lx95ap##p}f4n$wY%#8O*&*2%1=vethoUtTEEKOsE#D^Pbgz>PB zg_06;%tc?W^K{}PVz~Uut&WX3TMt(wx4=lKEJTVDA_a{6EnVM>wl>>(>vl}EE(ZEs zQO=i%VJtRFvdJ;KjhW$YPOwY7P2>%w#SM?GW^=9y_lu=cGsX1W34vHR_0Qa|Ef+s^ zp)#4{uZDF3^;g%)d#Qg3Ntav*np9C*w{!!i7naYt3Ar5s@YL4K(KNGgP0`Jqb5#(#&xaOO7jBz>q^_W{@k?dVD4#) zP}3j~K7M-`lk+*UV+28>?y8VOEL4m`F(VSZo3{I=sI48Cf(7vpqx1$^6(MCu2uj}y zBj80Ss$tldviZ$K!c~o~!rI=Nvpb{^7$`RZ3X^(PQ{|6U9>!e(53`3h!v6QB1*P`0 zSL8k=!Aps+5RZ^)^kTK6-luXnR$kymR?y5J=}&E-pzlFHdZI<$ud`-Zkk%pCk9UGk z)j*C}LQD?xyfz03cRfezmF!sN+SKpL-Z7H?gq)r1(0^*(OVfgtLa43zMYjW1Ic;!D zAQU4&^S_272ng^aaeD{b-X;E%>!MjG747VZ%+E<6N-HyYqyftV-|KhgKp-qpIjoD` z9eU0~J3NYKMR_@LF1dd-#iGXzhKXe*BK~UeGhFiyrmG?s+KD<_h{9P+E;qq|s5Y?+ zSEFeKiC3y2VUgf;-u>vy*>fKh0L(e)-wyTUVr{U^ycpB$q@;5Pr$Fj!{k|g3ivwA| zuwwf11S>yhx}CoLy{hY8S%DUBJH7Qa%Ex-3DnokC#ft8BLdw=qdbEruv@$?m z!_aW^bx(@lGRh14@iGbRji_)HG%>BK<=yb{T-e z7ySIM+GYgqX1VeJ#w0&LO*W+k@)g(s9d9^2-wEqgW_vAHV&y<`Jsz2-U&-Aaw&$J^R2FJr@m<__U(T#Nk>b+{0LYvIj-Og& znIes&D4Wr61*);BatSL~kGnkvIyi32IB<`*9ew7#-MFReo1PSFJq3G9Y?6I0}7v#Ixq{XZIjvRTmg*!b_0_Zs(8@kBfgr zY3w*N3`y~4`}S;57pISCYU}jE1o=|qcJStLaVPInIvxE}HX)#La^S{YeA6y_$RgXj z{#lw}0ZP~BoK?&=OxbnDz?A@+gC>!({-$73>9n`Yl7yDjw!A=?$`>t;;jSu!1!Vrt z977dxM=eb!Ut2}%GDLC9nhw>@af zi$emVn)fe2Ie!rE0evus`>0iya0qw`aihm2^u`CGLI+~Gy)c1Wx-#u#YVsH_WLPM; zoX#Y`GjJ^5X|5OT;7k-bpF>{UvaaPBX zgJ_X|{bSel1x(oW_qyJ-hkuruXf_AvqdrEJkhegF54|N|uc`I1#s2s)44TXd{!h&T z6>;=8E%6i68gd$b<)pu;bT&NPn-vtwENL;Qc{;f5OyO()-?@ir`Slu)1NY5qtvOl; zZa!VqneOxfjZG*`CpeOe+ZfbSgD6>7$W?{)fO3_beYJ{WM5Vp&UL-Q$6xpHMAG~90 z`Q=pZ`h6wNuTPGwE#k;I3o9o{>;R#2H<7_5u{7UJuM2UjtE(y)8lYGeF2C7n>OnJ} ziQ=+pVa#~Zc!C#DdWe-9@5q(QnSHd@YM0)o%kL&kd?&*Bdh{OK? z)8PK|mfshh!CtY*;$FZoDfVCx4Xm+W!k*PTIL8ots8S?0jb-JUujsbeX;(0vNFd}8Frcv7md-e1hD~H`rEn<35Kpa&3e9} za2$y6yF*FA&2|KVixI&Os4iMgiY4p6m7<0E!{1zt8`na3ls{Pr<*fV3k{tcx`+-D! zC(_{SpBx5gF!xY>$|{8c<^1t<_+R%7>0hJnwPgKnXj<6SYkASR^wA+-Wx1>qbH1>E+?E}O7o9r`o+Gh8zCbS7pSItC^ zT0y{bYzy6(1>FfhfNZYkUJeAVrb*Ya618`79aG=iN+#BQfzf??2!0F(R5iW#Bvf~? zlC?52{Z9GosshopbnapX^VzHp6)pwI`0RW2J$^r(vF7Bj20O@0LknUrPxA`WC8uGa={p{AVvXSYaM@M4=*}s{O-V0*x08dmn^gFgy){J{UWShC| zrH-MTA(hfXgdJ63G|B~F+xZ#FAK|FgO(`GHE3BN|(8$m_ww-4_cliBEPR#Rsyn!`c z49ePa)Y%)l73pP~@Zcb2CQuh(eboxKUJpBKdJ0BUW_P+-p1L+)xj}9`(6ju^E_>26 z-+GLl=b;1PdjE)X_*|46=Wd=A{UEMse>Tco-w3B3UrW!h^)QFP&+$J@8N`zj+Q5wv zIRd)+A%j4>HiaC7Du}s*${oRmRMYO5$PSfox)>{#w!N0gU2sPZx2d{mZMrg+o#J~n zgJ{++SVy&4Kj0fsbqDTM^w)3ZrsrahUP&kacg03jR(d|w3_hSnmiDEsk|e@mHSl(W zTev#HcayaR@vBh1s$S@d7Q_F@&m++E!=RKtT^jz)3;^u!bllMdOh{d(9|Itexa(b@ zDz$Od+uVLbdvt2pu2cqXogeGX_N&J0M6zP#;F%9`w;dENw81)_6!;iC6~wI|={gP? z8KaJFh7&}mvr$`AG~6dLS0aHv|E_r~e1DdiSmPWVbRBXC2N&c? zp#hDp3htE9DVRav_sPGJy{(xvTll{%7M0RDT=wHSC?IF2saaDn>xfi=PipI+<*pm1y7DM|pU_pU+fr0t;`vg+p3m&c6kjnF`q%ghZ)ei9H`sTmK3k5diMl@KGQ zb5zEl?Q}ey$+e?|44-cY;F3Lt>JEiFK_GHd?HfGLenigvdHimtPERU&|V*z~;O99>KQDl$yb3WtWth5VfNnKNnBJz6}kTSO`|HCMulmjPrYoaOsnQ zq(W{L<9BWo@ycw`=y}ScDmAO=kDHo}mb?64(e>~Qxz%y9ETz390E{4zdZzLo9fe3O zqg#XD=TJzkFVrpLTbkzelQ|##p1<+HE$AyHl#)x&UJ_z*x0i|58#xx3itL>yxUkyq zp*`!#Z{?4Q9evZd>>x^VqK=NkI^R((zW;m5pfGY24ZBl?vnp1plH0JWVPD)>jlc-P z;f$Nn)+(h8_BdUW03TDIk$ieY`X5+&WZf@`erGZQGJIb(8^I~oS;PI%Ei|qX9>7S< zFN`f>`rA9{2_aiV8==mUiX1#&26j5^CC+H8Az6C-&Cd>-bx#1l|5`9oUwqjJ`tb^g z5APAZf>H{AP~Q$JV17atV?ZK4Qb)0Vr(SaOnp&)yaBmxqn)&dpM0OAN+%L*=HR;HX z$cj{sXVRr>fR9&J2$p7$IBrJM{!oZa5=0N{;FRPAiQs4flSz<;*4)3lzLceE30%MG zf(Lco&*!NfK>i6hv$= zj3O9C1S!!r@C5mLNUF=qrs;IgYb}wjeZ|{*XnxPvQUL+3HsroyS`*9uS0dYnsc>|` zrmz@S230?L?d}`~YJz^OWsow~Mz@0_$@1cs_f2?x|MM*c|J|p@?2VR@r0X6)#f-1L z?Fv_;e7*WNj59o>P$HA{L*+98NKF7$@?3y{r9~r!o-c{D2_6=$mpvL}s+}-4K9A>3 zkWcHK<1d75k6p?>yl=Q#b2q1O+|=$WRYt*F_1*$Mi|tvl$%cw<~3tkV}x@*+#?M_i0kOD5Y7U{a2> zpaH!8b4d?Bx*9HNjn42lk{L9f7L##OOXos2(lS3#zh*I}<8J;RRIUgSvBowMcKZ_7 zZ~K3*{L}w@%qjdB;e7V~U^+|YvJbGq!tU^x1_ik}lVy%3!twT}7J1704ZdR%!Gia% zZBXtG>vpl%3?`=`2%k~59)!!8E*nGE*&lR=>h&?M{WjNJN;Xu*`A@`jh#eW@)Oj<}@7MIjz=Uo^`A91N6CIJ4v9g z@#!&4vQ08(3p3f%;{pz!7iqt3iZbL^VTt5u(s(M-my5S5oYQ=kGF6Zt-Di_o8tX3j5F*xxHUf@r3*2sDoc;*A8l6s%( z&^Z4Kjg@YfuH)9~+eQ!l&&(PB7H03KU>G1u3%GpVNR0$kQ%Lh92tspk_fpV1oaUjY zyqC4QRZ*k@`GTfh^W-gNUfW|6MrGFpNnDpEZskxjsYk4yZPSY~N*r}K3jT31Sd?6eVL7iOlu;E44e3 zMq<}(x-sj8^)z62z4h4(icC5i?7sq$jKjm>ISy9u;;wbWR|``w-a}E@p4~j4`YWhT zrGf=UM5SqHcHaE=(#a)$aw^8CUuw!4oIjPKVVXO}MjB+9PJN41cou`TOjSiRu&h5G zWY~X^VX8coo_+4>Uf+Ff>lyIyq_f?Ge*+Y;ux8MrTbWMjH}3^?;WX= z5kfEl>UlN%W`6^Q?x-si9}+V(9A3V%WN)Nb7rgZ2NM?o4ouf7JvDkpAZ84*qwwn774GskJy$QR0 zfG}Gz8iPhtCqY`Jcc9C0M+8b@E21oLL_lLxh#mbrLGU+sL zF7UifzGfS5nhkGjZ2Y;j{-VQwU*Z4+I3PWNw7}|n?@=5tOB~Hj zf^#3k6qr9`yX@=ueL3;vGo)jN63FI@F%03(B$s(nMwZ06xj3GSvbGx|B-+cdx?T=v zikRft7M9CuAt{rgDlPLt-xGAba)4wSdXA&A2PLdmq)13Ar=XLZ=Pp(?pJ6TZt0*oP5hV=>At}jQ|gP0sN=A;Zlae z;H>9-2)ltdzuWC{N#kP)$fvZV zR;+G~M)fF_=IKdIAZ`t8IH+$eo#V%L>_$+Ja2+mgs;jX4YLfTI8&cMSt4F|vxIX|7#Pd2`(W`6Zjaw+kG3WR%K7$Y3>RkSSxOkyuSq9HZ$H zriLl)j_e6}gS#2EyjNXyT$0#W*7?f5 zI;rP@{Xh?PVYZ5V=_j7n?tuLb1|?(I|Il0zyvvwmHn?`}Q~`-yxu$T~nA$EO>wzEr z(6?GbWz~4c`i6zw6eZ}f0Z@s%7A$8rS zAFU819Z%gL!mk{?7$8(FoDp}IID=^%FiPHzg}3p8IBV+Ru9Zv|IjNj1|NXPVKVA~H z9wUn2m;)#4=kgbcSG*{d$7LZ-Iw2Gp*><~malBOC3TAPl~b;g)Ho&;cUX2hA&QLEK_eQedK zmM07Noi=-Do0;0+${(KbPkF#X&umK(2=SSA)r#R^&Qzk4RT{`vkg<2dLfV^&Fm>oT zW9uI4pYs_J_*k5`WU6lY>ksuKI{{PT?A?c+mww2~swC{ORAA94bTnBVq;P9XC`3iH zaHCobH9t=*lPPJwbn{{Vgl$ZslRXdfHI37i>V=SjsV*3dvjb92it*p8ow=+H#|bxg zc8@ija<_8t_1PD@9Kf_^_}QGsDv{8v=!CNuO+H!;a;~>`;~TxY1caX&_VQ8jwX8nw zVe&yyoG2XW9AgQCJ!c?mJ=f$~1^jll&Ur>Iqc4UGMlf|RC?kd1HZwt;+_;5q%&87@ z!OoG!Il6bcYe1-NvCs6cBG;T99hH?sLz)FbReuRm8znqq$R?QSFoq zCE;iazZ29Gi=Q(PzP07p+ZGGZVz!RJ78B$+9 zIscdW7fTLA0E$9JFTX;Dw}8+%bh;(o(%GT|?8|%TF%VM={7CcaM72tKq?ACE&WJ@% zYVC;W?1hPZO^MXffoyCuF}>U@o5MgW-NnJtz8F39LdUO+zHIxD_TIUi;dgC61lj4h z?Mm}Kg1?6w-k-~uEY$acGT8!keHpS#gt7%oH7M(L2B8TlJ825e9;qtYAIbZ3yc|Su zkh;&dvc>o{Q+Jy9}X-1+;=7|jRE@MyD*son`i zNCzBB$7Ntc@BdKopWe28YhUG4X|cBqyiMXqk6> ziu$Kom5{K%0t8$&$UvxE$#I4CB}|TNB@-j@nL_R549<#(gsJHk70UXY|5{d*gI>U+ znZY|xC`K9&I4ATTtgb~$-E*TcI+GOAB05o$W_@CUWn=$*|1jG{A-IBOnpnknKB!KE zeZrl6lU9J+u((QU( zo2NwhjaricEnbfb^->5?cxZhgoQ1>+B*B-Pw1Nib!-UzU zR=dpsE2zGQ<9JM@jA=8p>L98_dMZ3XB-PR?8PYHX-SMjX11MSzYQ)5|b}3a+)}SKpM0J<2PxVtFj24+}EBCimUb8x#P(6yquzX zSu%EZVK^(Uw3AWGEn+FU1f#*h$$rJs!fT5Du5sLgbT+J7hHWcBq(}|d<19_fomK&9 zgW9q6on`Tlp0i$(siW5Iu6>auj?G6y847s$1oeWUgZ7$ja^6Ja8&vG%F%yCPloFAx z|8F}dmR%3?2PcERy7kfz?KH*I{%mtq$^;c})&`8%DNEusDIv2PjoTcSwMOdaDFRZe zfyG0CgdtAKIc}(^8pKJ3H3X|XgNAM5wXaz7Wm+SBttIABXHEzHB>p&RhHrR!GS)%oMK0$h9!A^Yv_@P7oH6v|Iu;gmt}Q!~!0UlJ z0if;%xaIP$wz8$)rYrHN^vi8N9XnS;%Qo6Pso6F-6O>L7%JfhV2hCBgipTe*CybVM z)9h{?qV#}H?_+@_@*wAoPCXFCh2fPT5==its%VPh&h;n*U*NqmxgyW!N=a4LxkF9! zu*${Dj{BS{IrCSw5z#e*lOCw3S>u_WIjy16sNnz(-S>zkTp6v-c(PVF9PW3_;5x-B;gNBwdPAAn9-39s ziwY36jbah#ynu)9pzP77!DoW*cQcOdrt7A#}e(r=729UblW6BKIgQhbX~dV_^0YrA%`v&>CLY;1S=B2&&4{OT!ki>2vYJ zqvU!fBg`?OvdRX8YXj>W@nlYx5hhnAaYyCa$NLT-@0ah{2AF{VPKUwgY3=eknv&^J z3jdKawD0#}dYPcLB&NY%^hAfgY2=Vja{5jx^0!bT$9?_P9J8=Jt6XV-78OxF)X$%V zf%o){l*+V`7;N!2y-U5a&}D(;9m%oyfH^uI{x@E-+-_j^%MmUGgAd726`lo%%OpG; z5~mZ!y)QrIy9RyAyVaxQiJ+hP_2&frM45V$Cn{lX&gs2}O!5ixKaPiG zmiNWB!NLQNVv8gD@6h{<+g8-*_zQLSxnIcB**8 zMZ>v0@N|Gjzc?|joACz)-qKN%Jt!y2UR&)kEM0GFGB|-=YHHc@Ui04LU~n>qv_X`s z<`gwlEJ!aFM1#72M}?8BK>z;)3A}e$$7cq4Ls^kNI&^ryGkD-=lM5(#u@%5>bd*AA}I?fs7AP5SPa`s{B9q(O08jPRXkY&v5sw$%uKa}sE(k9!?N?X z1+=3)ZqvIx$l>TYN)Q-moN)-mUa?d(XEi~qZxs6|Rt>|mpXi1HnU*!(w1E-VU0tWq` zM8g!+C;Gf68-)*i>bRWg34)>`zqz|goBKzXqBkE0)=6`HMPW4JZnJgXZ?URQ_ZZ@c zGE8|d=OXe^CXX@ERR=PPussAuq@(qIgV0QW@1tA!Qi+gFyZ&R|1GzrH zGKf#`Ca8p{nSj6cVLt*Q4Y~Z15`xH2GP)CPo(4RzAr!i+#7k`@w%0JzUaDGVxjz; zY@hc^wON#3jY}*mA_b}78`?mdZ~?LEdgB6y^~iL6=7lqd^F;w&_n*v_oVE_p=yg1w zG(`BfI|Q;ZhJZTZR%W2F`=#Evv0y1`mL)xvC!gz^Pt8kow3rsR>G6v(7K|2xBe zg|FeAP0z#Qk>q0?Lv(CWWD^m^oDv@H5_|LI0Z3@5+)jfH);yF^x%9(~5Dvq{{r5`m zq3B^)i|S}Y_8Q&STZPZvO#E7v*Q5@Iu01pZtenVEQN+Ok>=^8XqOslr6V*<}_x&BNkaU5qm)BoE+Al)la{c;- ze$_jRDov$BN_h4j{%19m2b*V^VRa!X$%o>h%DllCR_7Wt=H zUghn*fUR0=D(BqwN~lO3k6g`BBrNyIHG4Pv6B}RhZvPz$MfxERt_?=qHJS5xv;@hS zA{n6saZv)=%~~glK>zd0yF4GD$aI8*cy3Z{>(M#N5m7=SNJk_}r`MZ0SvFSap^Vf^ zi-;2r!n_9~3lYzqpwbXIVjRMrXR0=lwey+-G+RCXlXJU}RqJ=2_ZgbA*%O$~y+T-F zgwm7}INnIr)KsM2075etqg$Z1{O0JM97&;FcA4oiMCVkf;)Af9MaS8&FK59KOyGYr z4Zj^Q5;URD6O#~wHoIzUO}*r5&mo!j(^)NQZ*mRL8nc^of)MfmBz-7}{O;DQWf9Y- zZ7S>H&#d#Z?X$xat+L^(=*P;2L2Sk-+yd_vJO8X$KqIF794{svqFo=x%;`vsGp=Q3 z^rS)1=~d=Kt@K|Q1+_NAj|%xq+!$4)O=VRzu?SLQM!U;tkXLIarnMd1l@|lJTB`?a zv8!_$w)pkG*~%G7P#)vxMy$%XUJC;RZ_{o(8|buz0e2Qfwe1&wf zZtgK;C;C4RH?$JL>l&lIg$9}$eR{?6F$@d$D~Oxi5iG0No{@XJ*3Uj`@qst7J8gGS zJ6M%uhOp(AaAR69c`g$bX`KS$EJf?D`j(YNT6Fms;3M3anFsfwp+A^kRvjSqIWLQ zNuNQM5zU?4sguc+1^^n3a#oM_B7X^Ft6L+vO%s`D2+|gg00n#;aLhE^4!y4Y!hEiM zZjBWwS((Fy!o3(eaoOX;XSd*0IjGBSiw&3i!+zZp= z7ee4f{jyJ299QQ%I8@@HS^c<>^_pRc-R zJXU@3x_|*ElX(ZS*d$P&?Xz<$Q`P$vX~x#QYzA-mrLNAn87#(NC>cE4{=3^z0S_xO z^Xr_Bbs!?wWoL@Nq-IDca-~OhYVzBdYia>x$U|qpWKB_$#-m}~8XxEq<{Qn2D(vyM zKJLywJ$wPLX=z9`lV~b9mtCXozY?TR-%4lS7%H|{?8=QF4rIvKMSfj{mS+mm|DBT z7@$0RFO$R3>_xhMOuk`O6+YqgGpSPWj>^&_)>61JS0^5e9=jdQcR)iUMVW@ zUG|abc$v+%{l$NpGKkjksN{p;2;;8^jbI538{#Fi!PUC^Ol);r-UC?omhpMmh_^#` z^K~MFqeDRDWJb9{)SNVmOpa5#*XSlAS6P@rNC{ybWnf#w)&1KjV8ua)v~fjdn#+Ut z(n`jlY(_S-kyp@kHllJo*%!|)`H3ZJq*Uq_mOmwrKpUVD4%^-HMc40vs5$ zt_gb?oHB`}Mir+Cd#tNIXWi-4+hN|h433>5r1fc`FdTD!d+mZ+s08e1X&FHL?2{A# zOBIf@3!xNZNh#&+mk@pRz9I0=;o>Yyfd+J(A~1MO>Tw!w(!$)c(MT~|N)8?uJ`ybi zW^}v1jk9VXRYeZ@mpACkVUY_yQl5ZMXYTiO`6YM zHY+?tK$lbd#%D-@ueJmc$L*VVhV?qR#5Ix4)%spkJ@O1l7?q9qS7>W*gM1=0fJsyJ zF~t&XKiQqtBtVMsAw@~6FGtA2t5QMyIMFTdLW zyu+riTpF?)zvpEZa&8CUHus+1dLy@`M%y!?lH$91_b3YtG`jJ`>tc8f45eB;ItzMa5}m;S=ltOc^Rrq zb?WG38QJ2J@F+9j?OsEX4xf+ZiTbZZ#pvJbif7XMGnyd8VI7 z4b%X)W#t9@-H#H|q;gh9`6?;hB8jJ0fVXn3S(${4hKe+<+@M;H{;etQOf;k*a^auG zRKCZD`}qi>84)t_>Owta5EA9%`FD&O?KVkrP!PlWw~boQvkq{Y6?9EBWQIX0Cjn*r z0tHq4qFMPo1&c+6Ghb)|6LfywTO^PzBtB40Kjy^ht5kfK zVG&pBkTynaCZkz4LKf%UDW7isUn8s&2sO6UDi9G0A*<9mlpffV(KSEBRq{k6V&G^h z8-^wJBnqe-)pG1jcfWS6yIm`sqr0_UGmND_+vJInjZj@r*nngB%A+HYo- zrdDb~xSg6MVbDtSU-97}DUz4#1Wc@4#)2mOd(A;1b3NY(mTzFtab;kf0u-BJ3Y7Wk z?e>JTj857P+S2^ro0GQD^7opvy6=XrKkF`Z^wYP&LFnk?C(J0VbF0Tqg!sYji}D0( z5~g9Ik&$_$>~3I#P1yFF^7T2)veHG&s&qqDao3)oH9r6P66^>c6ZE`Y3p|elnWG;w zmq>D*`@6llFEUH+2Gb+OtH*zXMa*jLiWn+^DX`fd5TB4zTtzzvnw(x|9)*rPwB%yGb}$`0ZzK>P=eL8#YOf^9%lhx8S#Oh zI%#>mH+2fJgBaO6e~u#2org9_)OzN!*ZES%{)>a@)xY1)4@>;jBy+@)Bw?a(*uXqm zU-eL`axn57oD}Xtr+L@(fo_3Gq^~B&If;$gYjS|1223=ga2AD+ne*7jYvvT8?seldZjv&;G{`ZOpdaH=RELYy9#P@t)@-p^p;J zO8mqNWA7lUv5^&nA?I)<9@vqZA{~^t5%neaB+%xaW0D6D|D7GyR*T!(T}nrFhM{ai zRa5S+sjYmUYu5s-p}7jX(lQ(`Ziu*z^nAeG*jfeW{hU0j}gQ>_tTg3k1ACZWB(@orn6%g8Z_XkB7kS- ziy*yO*5d#IS`Anyrt7Sxg!&}MjSAg_b=59m9Prc2I7kwrcHk~#pTNU9GwLBWj3Bb)z`3DxETLR7#SbE+?lFW;G zWi;)R#pN>mgHvk^c-wz&J#n&=|9g7Yw*i`dI5E%w%2KSdn zml9eHi$;T~VLt>r2QFqd6#t0H{S#E7p4{Yj%!AC3JGQvg>f3dqv!1)1QcGiwgHi$5 z%Zomw7DkUTfLVQ0k*z%kd6tul4Jf?#B zs7fc|!bWg95YUm|?ThXm?R2rRO=I&Uc-e2EzwSCfoB@Gi_O?;;t}*i=Sj}0KV;Y?r z*p*hJ3RW!${;LgUexA(McZ~HaA9@Nd=U`f{jwAQi>tZp`|ck$Xa(Zm-$Ao|+1Kk`bm=w1l8VJUoA|C;x&?OLJ@0M- zEV%S9>xg{rhEh9%@a1G*djo0x5t$lQP7cl}iU{xZf2)3zA&ZoOR8g?bp~xysr@vnN zj57eP`x3};U~^h*1yw;aS|z06{v~o9lek8U5$u!B=pUkIMj$#+j;*xGQWS}se=O9q z1-=Y&;73e`r-O(d{fa^6ciff{(AGc4mw>9Ke(Z?{F+4P!DInAKumv?wm{*xvWnX}( zIFw1t@!7}E%Ja8XZ{FW_3G- z{u;~k7eUXJygz^Los~TYWZQNgw*kbkK5JHfkL4vym2oAyB-Q6Lz48HRR164)7?SK2VEmXqBKJvo?>{suQ=i zX=}%bM8~}?RMg#FlmikE?ix5fy=%F{n|E*V>eTr5#BLXb(Ux9YH_sZTsgH6DHbJs@ zJaT|Lq9ReH#Hw}_LwA3t{3?vE%iB8(EoWtRLXXo5h;2MPp><}7t-e^Y)8RIis1(vf z2e2HZO_;#*OtE&iw$XNK4jqGIfe9TWINymmDXx@!N|w#3B?XZ8zZ(1tC}aDwg!hjB z%ehh`0>%A8uV}8qo}g`^EyzH9-CgJs44v)*8HYS~fsDqv@zk~I*)sqBDOW4!Pv?+k z^;5HqsS4pk1HG=z`Lb^C_yDid%{eRDAPsxg3vnD1lvTSlSY*!+q}_P_b_1^ zJT-{U{cBH%Ljac`Y%Cu8`LI0tDTpM^2395lmqOX3 zcf^^#iuwj;FQVu{L5{Xi3;WbgPmjONgQ}o*NYCgb2q| zRF?J-iRbkP_px7fRK2*O^}&FLc;CQUd+kmksEXgyw!`(vCnAW>@yhhiE=k=SyGkMW z_!C{Vyy)|H*$g2z6;uoySb5~eA7g%m_i^Me&oQr$3%*wZL=b-liPvn%Q5OV9;BiCD z!&(mS=eruRp!N8;7^Ns2JsMro1jfmR!OIs=-(i<@yW<8!w(mYgmqR~?LK}LLMQZgJ zpdlonokQw11~6-cO)1&44wm*99a`NKJQK@f} zWzAPyy=F}yVay6e;9+$<8`f0a#nz~%2NHwi_P~g!38%%~GqlUE-6H^g8FN;w<;ZlWGhJvF^}@Tp z?TqW7>vb4%!Q}hsyGJR>!0~xsufWgBKpIxk-{jXa!@KNAw62HZ>67O4$c|=93rh%p z08u;<^$M!#@yNbH00#MN^0SK+AOWpaRYgBWR_U6YbWSQ_0i#W>D8LY}uejuiJx#5A zp10;Gn&7~b69>(n@99goqyF=`{^2VH*P+e{cC8wBT=2IW!RRHA4QZdx3Z2d;MV@xq zAl-BRs%iD*Z%incdw-lcUJHaAk}oxMyQD_P(1MP9H&S2CtLQEYN0-OvC*I6A@tgTJ zzv^gvR5x9wbpHvML&mJc)nr4cd|X;<71I6P)<9NAj)D@*!r25qZLDwGa^HqLRbxd#XLb$^z55}q{dn}doLO>i0*!-Ij2thcJ0i3hPD-ZZldt|!L^TZ zl#I?_nRJ>hNZD7Rpg1?=a+o>63)o2*v*l-FMJ#haYV)hHFr~Rsq_562Upo{NyPqP}ShHqffqxT(PN z2@5GOsb@%whILqwL?cU$_E#&O-_D63V<3MZT%zPh0Fm=Fr`v$Bylux$(T;mt(D>mB zL9)I_|M`!lZ`SBdqj`ge+>SAHb+sMU-dwak6Ug@K-h((#-k2e@1?_toa-CDL_2B4k@ z)oIeT^tUSEyQpA<{TsMZX7$R7`WTr%a97Q5jLpgG+kSZ;h~u^MI)|fMr;YDBZDcu& zRwo4py;Ls@9QtCX-mmcp-}+vJJs8Y>l#XZY14s=DsOS+Zxa8cwO5mQ8MT z{fRxtcqd<%UgUJwSaP)nUxoA0q9@KV1b@+zhK!J5oL{KH=~3bE0tK@xuaOoL2bua^ z_G8lqq4_G=wd8+aT6>o7Z(7V{J18|V48~1bAve^{I5J;=_wySa26Z?E(eBqMpafg- zsq^0cv*y%gE9YTmYJWNOPF@Eub}wJsCTUs&T}IDVZ%4H|SvQ^(I^>a#!C0eb4N4JFm>%24 zK#q$_p14lhJzi9k`EQjr!e>sR^g;}%C{NFAyE-k=U0x-wC*vn$C~naw)zXpF>zV;3 zU+MYBYg)g}BsP;=_FwU$kFtC#(X1UKazm@UR0p*>?~7xGIYqr`x9o*%uTzwbmr{9r zq!R9*!76~yQjMY3Y?01IDBp|9rMUmVXejmoWQ3uO#`5~BI_%x&ny+ry?7HkvcEH4w ziVQX!(8K`_v9HywrL{vyWk;KS8@m|9v8MP>V`nQsODpD`&@K5wPXK4dR6CDD)AGL?GMos zBwZB$MgKku@#bm<`CAsR<=$s2`NYfiEwwi@!T@(TZY%<*+82hTtWQh?nna!Dyx5 zei4>zjmEf~r>xnElV*!TnH43VndttJ*z1651z1oCR76bW2zy_UfKB^uFWTCZXSK$Z z@3scRI1%YDS?80JixEI=-Y$`X9^Ih;7D*%`6I0<`siN9#kLiKHV;^+)g0?!EYwkrh zDAan&alB2wS|7a0_F_GsXpvxE_^a7^A>dISrffPDR;}^pz!RPtbz9?iW@Y^XvZUw6 zZ>+Vjb7HmELnd)#-*f9|ajh%{Mu8f}32dg0OZQovosWg*r{23M5Aq9;B`?rFdWZh} z$%L0t7ta&CD4E50bL3)*CUoQ+EX8cUnY-CNjx}d-YI!VVXek3~?c}#bHR-c;_svwQ z7)Up1oZTC6`H}l@DbDBpf<%sL?JybLe&|nB2dhA8(Y+lnN+4jl=LQ?f@!QlQ2wjG7 zeS=mPwwB$~fwSi726I)A+<_Xj>e!2u{ep)|Q7}&3mZ3SeB=Qh~8TO?5y-;;9%c!Y5 zp+2PV@oFvzzS~tqjso#8Srn~W;j^fww0n-lf%Zl=Xf2AKmD!b2Z?Gua}of->?WK@_?TSAZJb9`S6wb!QQH zXZd^Hu+uOhLN9**9V)d&gJZobs0~ekmzal0vq9(K3Oq<~TjUrtO2u$X{*HZ%Kil~O zXS9sEiA$me>L)8UQf0=~Q4K^!9mVLV&X%p|R3bTYpfeO1XbOJ0x41U2-*8buqWwHT z8WVc{2RR(kE{rtY$jrpDP0l|M5ien#&NQ_4-Fpkq`3CACEXTg0czXha=P!;7ql7|U>+(A7R{4E=J#qF6BFhXSO(V@-C z@_3@XjU`o(?zJ^dr2_&M%}*d!BEpN+dcea|p(v-+I8Zmg?!?3JC0+R*-ixPYfT&E) zUX+UXegXOFnBPJACj3NFa;W# zOr^s@qDt?BsKuyc5kJbB9m82O@t*I5i+nt|z2$s~DAqsMZE_GejC2QnNU;sY&G>WJ z^67J2JP+dgLWTGJ>S#I0vEA0pGrg5AYb9cu*uxkl9$3#3bp*! z8|{rHH)$-I7^f(R$Brsa*VV!fw{1S7rX|Y<2TH8bUQi*!*{&#ppJEZd&fzx7;17~( zKv~q3xwHIIp7)B+lSX6+>DunqfgX{(N4u1hCPtmP=EOUG-18xu4h@nBS&-AonE38u zJ_K1YVSa=pkcqvHVa1bU(2287;ceY}6wlWb^?y<3(&F+3pzy&da|zy#=WbJXu5?N$ zwZsj=ilm$Kyh>0Ot7{)PV?XnNlZR;$u%t}J%#s&fvQE&r5Rf*;i6|FGVHDUy~J z>yaw#!>G*1zKsp!ua+~nh&+Ei8)wOnc8W&?1c@Qkcw-EXT8@r`z*JbvFW4jmHQuw`oU-M5n&3?-$Pv--F29y?5q_0rD0WLqhi^ zX!3I^Kerjr&489}w7@bM1gSraB%loO)Q`Byvdb*#-k1H^&sU?6SCMA?Fqy0Q7uV8xe(UHVZcrfoo1y}Ae8fM)_I}>-0vVXpPCK5r zZdGthWWel;#ud`K0%5<+QECO3`8FdA^Y<4Q{Z3Rn1DcAV;i^s!-r20M(!ITK#D6c` zmi4%wR=jXi$!q^3HPf`dNP$k&@Zob9q2oBNz9d{wo9$<&qf~ndX>ix^4Xs29>9p?@ zZAbti^^}Pfav^bGZGR2U)D4>6-57ZN?^jw_(N<{7>GsFGWFHP-{^G?)#FrelLKIq! z9xnzMucAayamoZIATSp6S0`&R0^yU^gt?$FKWM4>=}esH82Bni`VZs{8RYrH&8F4& z@3&*!Pdo!{f0SmA>HroTN4EfKZ?ElNA-_a?0VXLI6f6h3jZA;|R+_!9d#Y?0F^-Vm zG^L2ouboEq*Bi)U@u`)yjw$_!K{)}#*AR<*T1d75WKzXk@$-x4Z=JNnuo_^vOB!m< z^_G++O(WuIYPni<#|c!=M28E;Rk3tFoU!BRcYdDvVcl0i6J_pEJxbu_u#q>oWI4sP z87sSzm2k~onZzwH^<}Baa>om#YohpT`VsM?Nfde}KEgXw>Mg;6lJ_5|k;(M7ELry| zg$7l>KiU*s`6C>w(Cp}RSiEcxP&7KH#e??(Bnl}_91zb!U zpLAX9)_0Uo;7SvW|J##5Rf%jp9Gl3Zo>4t)Q#}$O)AvhGh0{j(6L> zfn(fIyBpTXjN6Fj<`ZRk?O`+wPOV*Ypo?9)gpL%&ksTVBDW( z<06WwpH%0K*+=g|h62ba0dYYQ8?S~S%@Zwd8ouFi zEY%t;)MI3~AHRHq9{QMnQ30hWp4G9jWN7-fdb&W+xq!FmJuP%{+MeKE5TDaR*qf^- zd9pT6$0T7}nUrdxe{`{{2J`s~tVQNQe>k~it-LNjbL-Qbr(Fi$K2vz6Vgn&r!(p{ea~6U7D4%{aSnH)ZXOl z8!Y{|H^*b&@(f+*!VVbX@oqkOBEZmiI`k@$NVRu zf^-58oK&m>g+BnZYMFz6W(`|({q57et!zjm1S7-`Fi*_KZm-6&7WB~s)?(`}QLorNH9f4C4jJuigW8{K#}nif zzjX1U7ks2QAn0w}!Rtv5A7uuRaJmC?Z}Lq@a7$0P{1!jt|JAS}*&EHg%!r{^c|#~u zr=2@#&P-j`Xoa>5eQ*Pjmt8b8WceHlK*(mtvz3GhE3^EHq4=!wY@@E&?o^rn*mcDOxp6}K)Z~xxzaJBcOtE@eyxDTIkz*|~aoXr)1NX$iH>3jb= z)q-Qk;GOrYVH3twDaw=DJP2W|lT@E^{AUFW%H|hIEHw0)BW-;CxoqaS2dK%dh5xQY zlKseVVE7(!x}f3*#=k@_HRT`K2FCj>!DW=cG+2~8Dx*a8^1_{Lz~u}5jdc>7T%an% zQC0J1Ab*5M0li1$rQYm|E_I)@$b=sGdIB<)1C{k0A>NeLzZ17BvwoS-+(q*84^pr+ zcB6;>iOT}5D&urD;H#ZP?m@M;gzu)_Mm7{gL)Q5fw-lL{oS@E5A-0(fI*5~d#6C(||eydU1 zdLaIITWXkii=PPVF3*o^(BREtP-CTgBC!=bw7;khkhAb1(a~p2P$)+-C1mUmt_73{ zDfl@Z?~z--Q==r!g{f-h;=X)s8|<2v*p---)1RrhjjY3WUagj)KJsd95V(CY%+eop z{e1Ct!@&70=pEuIOVOU`cLoaJduR2M>6bxQow%XZH@8Pp&~fyM?kUNu;1u^*&unz+ z$ITd$LX;n%68;okim>*1L-bo(#t;_2c^UvYPq2vN@ALLxw#W5dx*60XRu0$KeIl;9 zKbIf)RWlnL;6Cj)vRucU2&PqaD-~)|qDl&);%@>BSTpO4IF;73k!S_X=oBD}P za4s6r-!?jEzQd&DD1Ur(eok_M@VkN-3odMaLcUy~^6r@M4uK5Eo-ZX8CGqdaChzSf)AGsH!g2nAteim8KRLdiKm48Ap}qqVDx!+>LjI!Z5@waMm76Z5@<(?n7yTuIxRlZJ|ykaaVa2Ds9${NS-FCou5w)AI@}sz=7~sJj74DI zcD`+{Uxr#Glfa^7b#7YNA2^3c&3+G?+b!j6zD!+A2cw|y)FTy44KKJ%RyL)@*VFl) za9S-mURB_*5CKtpoIyX@ZYj>L?mSpKp8X)Y;FhmXsxNH`I(%9S9jAT+PKBj$RqQJ! z_S*Y?e0G+F)L?4+vu6vtJ8}#zbf^vBvUCI8X;1o|7v6;@#Crsk@HHizg`+D$%+aa}gENY(LH#sgH>Gi&IjrRo7(6-`h?D z*ny@VOn==P%!j|10J&zdYgla*@u``;LC z>unz8Pjj+LfAE_cdno7@R&R%rLO>bg9ibUoi)%cH)-PoERFOi~iRM%@&9qo#g(xko z1B|g_ENE~U%y|LZs#Z~mAZbd_=X3<+R^Q2V{Vw+^7fJ`IpZ)RjE&jQ12FQq7 zDnD5i%gzkPTKMPZbJ8O^VgtJ1$^wd6Q)Xi7<@wNZ@TjlC7(kQ#dLpD$GEmS!g=sHv z-~Dc~W0t1|BR;EgLOXp|P-^9}eN92^OTb*u<7BQaS~gG!A``vHx+QuU`z=qYY!A6t zL3t73Pl8{jL|%f{T+)q>+hrJ`DftzWkLyB|zSrX(G+cX@#6gVU&{D@h3K~trsJHqP zY?4T`lPQq57nHU2;49)xqN&|2Hz~Z1CmhVHLEqRu#84DU^P?;@7~J80mQ4uS5+fDg ztOy|FM;_3(cDK~Cvz5! z=KB*Oq>e`~73)*IDgLuOC|rAm%lKE}Bg&F^7pRri>vPv;p4zAW(N3vZY!gd`%3Oo} z=M*B(hAq;n$}K~3a(zogaJ>OTwGMqWbwXnblKB4?p?*Co=)vHc9wbN(WPg*Ahwd|C z_K6mfIVDdU49zKW+TR%lsx5uz!~9>GEY-vUe5yZDB}9_U^34J&=5|i4)uIwjyErr< zK$I-V>&k+dYmLMLHD{Oiyr{ZQf~NhSx-oW-;tn_iC8VE2FwB|Gs3Mv$B}!*lvI?)4 z+f98bfcf_x5$#QmCq=jV*?VhH{(QbBes*%5N8zTCHaGd8XSN|431dnvDydXvCh(X% z7``~3!@9h?UJGV_$9~6dYHiB1@wo2E#Lu_#^eN2g%@da6_iBq4!$59 z^Dx!pK=8)jodK@JkfG9EP{)0KoX5ud@)uhwMz`oG6|ik#ieSLiN37=yxu1^W?wG0! z?kwaZ`0pk>LAqi-@2Ah!9*)VtS(`;8+CjL(tctg`;nu4;lu&8O~L4}5Cj)c1R z7y~ubwFy%(PT~>UJ&_fYdN=Q8mU?fG)vfDMrS$KU0Rh(QE0! zYYFgHd!-i=FjPzmQ=KpBbN|N-=xk=c0ym3YHV;XPy(t0;5WfcqC(EnJ^;Waxmssz2 zVck-s{R@c{(H~BQ1=FFbKQad+;R?SIQ%hO1h%P7$|E6j>3Jo^tx6Jl@Y}r`Fl{F+? z+qyl09D?fgZyHT)l6moAc52 z3aq{CdL8X7_FyvYXu>JJZrj0_h}F``Ds($g12f3WYjhg(oV5haJFzZOr@O>)e^tpJ zrG4;LZ0q|coaY?4;;zyjmGQd|p2VT(1wL!Jh9m%;7(pu^o}o;s604zkAAIle(rO{LBHY)x691J#b!s|8W3pb* zDq)BoDHZXXLFxOOx$<<4v;9<0{WZu105e+ehY(>kS=TSrI9T` z9Nw?#{UlAw7X<1a≺JY<2|qg0ZJc)rxMRQ5f*-9W}4@_;Li1*J-7vksH<#Y&D<9 zMxng7tAcr>M{7`y!J%j(i5z?csWD}clhPDAr;0m~ zrC5w#1#}NuW2tR75`o`dx0zIC%cn>w+e$))3EK6q))-1oPc27&5#Gompd-`O2T zIf}fdd0{izgI{C_(+klPsh{}KyEBuIiSY*8wadjvFfLrz81YsZxlaAFuo4QI=(Yd@^_&VEI0K^da|9Jw zk*$_rk(pCX;XeP69g$RTFuev-*i?e9BNvM?ZX~v`cBFsTQle?L)tiOP>w3LDKKVAk zc4|TtNQ(gk_KzcblO63imYQ1jc|_pZ{EHiM=*+=SkEFZRDkHHGdgj!N(go}85AfmE zxPE0JIh}>%C6oWE$%6oeGV)5-`|hB64d;AjpmG9#kyNn`bM`or^g z(uj!bb6>25jAp!=OaIpeRpfpHo>{j7UJ;7e9|}WBEc8rt97U)j7FB!3OX46K$o`AtMXCs?F4dc2 zO4nb9c2IiR6u>UA4qMZER3ew08an$@x!N1oS5k=VCBn-Hf0p4)1jzJ3+QyR+#;>B4 z&#wD&J}c!P$X)tfWJHWW3OZ!M(?D6OJ^Qe7L{=GYWxTh>RCATpj42$VJm_=29I6Iarp8ylbLm|H*thDf9 zv@fBCv8Ie*JkQA^FF`0t6JD(?gWr4jimCItUSIF?69uU*v^<`NY31UFV_1LM(vG*) zH$Y*ox@^#K(JXzhJZ;&MUq&H2b?KM(B6oAb`A=nKz81uP#Byl0jvF=0FW{D`^}mhW z^@Vh$*Y8Bp+fB6GC8;KR8m1!09wT2(7qDYmf zo8rdUfnnd@m18@6RSL#{Ba~Xef$2;2)6G+f_~BUgkMG0YpU=r%_c+zQ4;#*o(s9a( zoOYf2;h@*QQyTS}J+nCNG*a+PXM%kaV%+J4*<6WIT?u!DCEr9|xkWfaZr&5(+m$Ai zmj1kIuAm@P7;PZOkwpoj3m!pQt=;Re=Vu`vB-H$Uqks1hQ=gSIm#^{>_e7(QpcUgp zUL2p*pg;W62nqgHto0bpC@){04v8%MJ1teofPX^yvev6ai!abRLv#B<(l{;>sGN%a z^LO=a-Uia8HF4ysh#9=^_|0vB+h~LdOC&3p8%hQ-pAv8>sVxo?FFSA;W8e$3`_q?U(;5|TpYYOr2uwQdOc_wreQ9e7Nn$4=~Mhg$vu&!DW8Ar(x zmAU1vxWE?tsQMx?nas%eu7;PrW-tdUbrBI=sRQSym%)Po6oS8=m;L?t=?M1uysfeE zphy#rghp}Xnd_7DE`MRj*Bz-|DfV*a_goT>f-7~hYgL}^=R`1gl8wVZDwyn-CF;bMEH?~EyU-#)P z6nNC6>-nt=BwfhyV`m9d2qM{&K`0+sl0fJ_k8%C-nIX5cG8Vcd<%GUK~0cY6;L(g)39>RWuj*4Fk&~TSg2Yg* zkT@lKCz77=bu)MHs&F(z&W6T{x0IcF&9Yk#p}9!SD25;DX9B^M;;SIHZwhZ2M=WAy zP*(E@LPg;$v%`@P8PivmZacC_X__=)SDxfs5Y$YM#5xnPmfwc+lK4b13QPGOW|&qe5kaL)lcXL7 zzxL=wsT>oCx!xQmosU0%myi#4cDVp*wbeuWR_AulF{t7#)>iQkpAaW#rCe0?!nOLLn0$vDFta#6DKJA>xB5ax3wCW3L{neE$nX7 zei^0B=)1MI{`%Qi()$CmIels-Zon^EkB?VLYP^xik53)CIdR7SAqfw!e^Ij_O=-d!jbeGudn644ofrDDHOt#*1J_D z`DQAJQVTVyp#Z`#z)rWYhF3>M%pvIDXU<^nPs6}n@23wgb|*<-4w3Ql zq3y44Ds|{&LFe{?__bZI)Q`KmQKK)S+*0B7>%)Q&)h3yM!6E#g0AU;$k0>IVkyrYa z?Bw|t)5Zqb%DoJ5)cFgOe#h~BGkVyUux3!#&Il^g4Ws^RoNH1%x{|P%r=^40=CF(J zcenjysRaJ=Y^#2)fsMTw`d?-mjhA09!J5T zr>~YUN@L@H2u5~Uz4_bt8PXCVCRQZpc-^LwVGX42bhbZCnQ)H$y{p&pWPQmZ!r!zB z)nhL>xkA^qyT@-FfaQ+#LSvC?eTld^e7m8!)QUD)fkl(+kgDp5%m^KhqSV<^HufZb z_k5g&`yy@@q;B77widy}R1IzUH1Op^@?I=-d-6&0SxFW=9IqNax{w;ep9@UHIt4W} zI)JyuhebpEfZ&W#A$@w|g)Uz(0eFplV-QCZT3G)ejbrDQ+vJsKo_ItKE{KtQJ-Ao= zqShhN*oTR$snfm7jpUF1SZ9$!F8s5~qG3?4gMYo2EaI(z4V$V<0}_S8>E+pfHKR?* zBbZU61PI}l#oCX;)=GlS$usn$DNY zrr?)v0&_C@jr>cPRIS#>DYbn=YH>R`Myy%%6O#;+S`557zof6X#EZ?=JJ#1uf^o_L zvYh&k!@&n9!sQ>`Cd2I5T+1JQcay%x1u7%d5M`mnt1YNXD=7RFmK#P(+FN(cKQ}i) zf6Myina+$a=s%B*o)ucM9eq{6Qx4RC`D}Df4VyFfW%qR0FKNiLh4_sVr%PTzA(Cx3hE7~ckuDNZp7ick^!YT27iw7KfVn)sM0OaLhj|xKlBOi z?!s0I(y6Vp3TR2u-+TA<M4EQu8bz$+Ned1JxHn}$%V7x@^aW;m>m}bah(P1W5D$2Ts?};cuXEUwz z%1C688Yho?w7DOBqdZp^11Ymr+=ytbkfd1bjUbKaZ|C0$FFB*vgv4Et8_t7=Skhty zC}!ww+3>LYc`LRu2tTmU3TM z4M&b&a%3Ebo|!`e2PO)_&|mTqJ-gq|L1%7L0mO#TtP*MXvf@=sU_3`0B-^;!X{_`_ zu^Ha1jE}{rr|^M3@BS|h>*7X5Zl=-=iI(EIunS?Zg}<2;M%o0j$ZlR2pP?`LpUJpn10AQYB7xhj}$ zVbc>r5gouO|mQvc#LjWNkgliCfdu{f9j(SXs$BRISOSP65%X* ze}0fp!i6UXGBvd~-#$>8KS_|qJ&?Zf>Er++sP$qP=Xg|JI#M!nSd;d__HJx-R$(Nx zK;k!Lrj?I4-g2N7X|rg8Ru%(MLBtSTP@=`v*bXvkNVJvU&DElW@*J!cmq1DZ?KHoA z<-{O&cHnpG{Ogt!X3pj4)w3amB9 zRU4occ>nkJ+DKl@Nk{X+(|gilYV8Gq_Pa29RC*p%k4G>M5n_p@F0BREF=8Ss?VmDP z*mZv^+KbQ-HE;|nUht7&j9en1&STVI_%?778ns#{1CMQQiIQH+LgQw|@ac9PW^0ZD z8fjH&Bwx_tW60LE3$-w|9i!S&eS?Ltxf^~ME6RovdZB>=RgBuS7-P-84*jbZMxj9? zALh_65MwIG)5l)!WnJJ{X@1_G&^|b;AuHz9p5Ji3>y@YHX)9JR6L=EQR_HWg1qyy` z4LOUwwCYQ%i~frgOQW1$*JaZT-Fks2&-;Bt{1cb6JE%!}!tEyRG}4hxc`}j6RMCMW zeqI-uyB<(5ly3(+8#8+-Kv?&lt!UYqo#&clT-F!AX#bOf2WT?=2L1S39tZ036aXnav$ffQhjCCM>%y z5*)zAD=Kv%macRZ$IQo(64TSfV880V;zjJbt8x(7X4kg)09rs)=Y-~MsgM_9YRGF% z58suMadeo#I_F2J1(*2G{*(i4yd1yO-U_fz+Pn;8(h+M2)RbrO@U$O$20^zq+O1Z2uOAvtI&n1L*crH;s9 zmNEI?D!)zx+U7dPn!WGuGWD}TfzcwrUqE)pT@8br2+c{N#N|JO9m~d3CJE*(rrrr{ z?czxFko=rn^jEikzVZS&larpdyS^Mh&4V+&h17^GSerF1Nq&?r;7-5$_cJ;8sBh`! zt1%#PVse(&Ur4l@tgzXTJvYW%pP%LVJl*k^O5Bjwo7+#*7acOMe>r8mhi_cJu7dBT za=a8EC9Ev3EKG#_<6W$gRIhN}2T_W5glMX%VAGO!+AO1lnF@%!A42CjGPRFK*-ELK z+n>&LSsr8Pv0MaROduWLVr#}@=_dr4Wrw}*!;&)Oc};lK!w!5D?@1l!@LlZ1FXM+` zjhv;NMeM4dw)5{IQcS7W5a}nwIwbY&Coz?s8Ec~xzH9{Sg zee%@)L~&)$)d9=GTc%shOrw9tLcHQm*BUQ#JrDMOlE%5eh0~Oy$2s*JkD%f88%E-$ zq6-B!H8Ks8pjoG&!yXtqtRPER7J28bFe3o{rpA%FsiodyMv0ws&#IEd2*AKsF+)i9 zaWfODm9Z()?OOJ)Cp%Y@^HZ)!m#j|j9-DXj?PC>7qx=Ix5keJVxTOd{7YB4Cbg!M!jA zwbipZ%CWZ@e864#Zk?U#&Y$gyK*mS6wEZxVasBj55(z-6#d~p+gcjp`xL9=xN<%oUAePc_VzaZJ6|@pA7OH<zIB{7sC0VkS&;4oFZ5W){zS_5W{C6$C z{CechHYvc~a`Jej#h+!yHoiBk#i5*(er;p4-v>0T766_^ueX8A+Rh1qsdE@SJ%zkHWUE~F3Od6B zgkH9-J;AHqkUrHmDl39N`Bce?Hb&IjY z%JZHa%Pc2u^`yPsZJmQ5wx=$mr(!f-@Ei}clON|GGzt1={={@?$cnB~ZC#Iu>-C>9 z_^i@{`+M^N==o}*?HsT3F;MtnNG2|N7QeUArqsfG%{X=AFQtJhIA?Nh6lU3UiJtrR z+vvvqfxDKB6SuNyz4M`G;Axnk;($B~hwx+Sm6sd(^-9w`Rkx67s@r`~ll!nMCnNV?TP-gkAQycM?|pMl?>lIt0R4!q zxWYUedO1+rQahLawxvp_woZ5x;9Q(Y*Q_e9^*_0_1{#N z?DtZmHWj+Nmh1GRwQ`;|rk(Ehcq;c9=1u{fpOUWnuYG01?;S?AP<~ z$+fy$VX{4q{0cONiiY?Ko9fr{<>Nug2Lf?Pa?84u#2w4JuQZg}<9A!;*&V&b;y1PP z*OOyR6Y6~LbpF18H=NIG!KaIB)Yn@B|oy+=0hV_R$r^rdkOEbtaw4vn!hA zNzjOKZ5}%dXUElQPat?r?{@Qo&i&Lb+Ybo#`2{-jwCi*S-xBjR7!Eoa|6S6d{gX#s zUvMfjf2B69ckTq_B_5vH%1e<6-)&6Jay+EkLINBvLpJ$5?VgsS*F1f=c!8zt%a*My zlVYuCFRs|h>fz+pvsdG{Hih*KrCeQZhnt2g0)qC7kV^YR&b4nYYgFk3BT-QUGHt3@ znPOSv%`3Na&8;JHso2B&4U5c<&%ag#-UGgyLOS#x`_?@Nt2*uM-2SR!i>s?~n7ijw z*R^}P%j735s3#RCnWcqfyWiJHhM!yWD9WD2| zXC~Ku)#r?1FI*;lcFG&IJKGJx&U^Po{oiP=ZD=IL>K=cXn~JB}=x4Y;PrjBM&NJg= z3wi>aO*1Qk91?BA?Y1Z1)`+0AS8y@y3@KK~VC)+?p}& z*I`NvJ>9fHuzVsO1K`$(zybW6zxlx!JRt)7T-p5Ie$h_wMnP5%4$r&8OJ&6x@_~4O z_;gA+xk(kicL0F5y_s3(G5Y+H0Ydl9n4l^?0H8_}p7+03{J_v>Y(^rmK_B7A_9iYr z&4h)8$Ia@GY35V^yGYP}rVl+3r2kJ$@OA+ud+u#Br5?xtz$0*I=mH3Im{YFk68Z>O z5dZ*iY~0-e`T*REx8I_0l&DK1L680uwCxs~;#0Eg^_NeL&v~IqFXkm@ggD#9c^-$!fi` zXb_m>?H%jH2`djVW){gPtU z=FjCGxILH%S4B=zO%~G^b2wjy6Wwc2$avMYVB)c=S9h1Y?Fs2QUIB`DiLvnxC#P0k zK~?8=bzEi$Mgm;Im4id7k4S03ufb}_#Hu=fjg7sOKF`j!f(!U%?+`*IH!vVT@;Hz0 zM&^{xI()PA$-zP_E`W+ZL^9HL?m=wTgiI-m`E^*)oDbc%3hY)F4~uJ};+BXbyJaZ| z{*nCK{8OrMHuaMf_pG9f*pNoG`=n9ts5aoIOyPwOQksERTAGfji+CS{iC*#oSkW`v zO~oslM~gbv@Um3^SLjYl+d4Gx%CZ?%(4tESGHbG>`fl2=q2|t=g9=@MY2GI11n0IE zHwKf2EJsMkS`|bkiN2P(KBG1bte0}Vk*coz_&!Tb*gLMH3POC2ZL)=XweL;{&CJN+ zV{!CVAa=57lVq_-3c@%+gzpLv$0HQ+`$lM}!7^63ttrxvu>bZ3b6M3>TESsr! z|2M6&YKsg8*Bq~gb2bsvq4w0}HW_I4YZVVRn6lFbQ8OblVO*+pnQLU~bchago2 zme)L2(ru6youH^7qI!UiiU`X9wlAWWJosGtpBJjI$I%~ktfpV5P6)rN^S}jcRGX}+0-n!wwgy*gIrorukD9d z@%(*^N5Vyr?yyI<1eKI^K}yQY0n;PAZ0}?Z+)0Gw6BZGbZDC=7>Ic_>uh2g~iLBum z;w7nA>33O)_WA2e9&WY- zidc~r20$(0@XI}9yn^#g*w=`r*?B3yP)0n_0mdO8pB~L&u-mvrtN1KPHAoyR=abCc z7+C&ilLnqW5IPUmCzsR(4E{ct)N^dDDPZEKeQ|v=og(gdo;}(~gIDx*e@r@I?B=p< zC1Bk&uH5gFI8Yo#s)o~J3n=Z?e^n5Lg~9eob^8_Os7*a6Yh^fqoRoZfQiI4!EEY>< zPbMPahkK(R~Lv(1*BQ^9uov7Un@{DpBv7=iS^YM)@4+YTg zoVRq7xw)ktbC1F(^9)FxP#(G6=*oVE${-Wul^dOj{;@5JT0`4|3L^lyI;Fj@Nih9_ zMvoBc0Sr2V*6DKD)>grjy(d7(tT^7Be^^v-EBmyjDSRP%svMReVXeFy6d-A~_bi(k z+e}&ShdP9@3?a-+Wc9$Cs|_4}J8 zGFDd^z|6yooHM)NY*@flUupC^Gd}$mwhYOn>Peuh2F^xMmFMy?J;<%J-ep3iXg@BL zagTP4+lTgydJ;2!T{;xFxR_4&;Dp%hGFd>;e%kn^cg{cmS=7X_*u;H|g%Gx8Xrg~A zhavZ_e$Gnu37#@tK(P}jQ-y`HtW0eZm;5V>>Dd+qMRg=owlWqhHFcu;2iWgC~ zL`%Z)97Isn`6fY7xuiJaWXz$`w+B#9h`JoBAbZLGd(Hodz-xjsp}mlOHRVDJf)N~< Lteqv>y-xoF Self.panTolerance else { + + let drift = distance(center, expected) + + // `.automatic` settles *after* our first request, centred on the annotation + // cloud — measured 372 m from the fix — and that disagreement is not a pan. + // Reading it as one suspended following for eight seconds at every launch, + // and overwrote the expectation, so our own region landing then looked like + // a second pan. Nothing counts as a pan until MapKit has confirmed a centre + // we actually asked for. + guard hasConfirmedRequestedCenter else { + if drift <= Self.panTolerance { hasConfirmedRequestedCenter = true } + return + } + + guard drift > Self.panTolerance else { // Our own follow update landing. return } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f1cefacb..1833f41c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1354,18 +1354,49 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchControls _buildWatchControls() { final cooldownMs = _manualPingCooldownTimer.remainingMs; + // Keep this gate identical to the app's Send Ping button. The watch only + // reflects the phone's latest answer; command handling revalidates again. + final canPingManual = manualPingValidation == PingValidation.valid; + final isAutoStarting = isAutoPingStarting; + final isTxModeActive = isTxModeRunning; + final isTargetedRunning = isTargetedModeRunning; + final cooldownActive = cooldownTimer.isRunning; + final manualCooldownActive = manualPingCooldownTimer.isRunning; + final txBlockedByOffline = offlineMode && isConnected; + final txNotAllowed = isConnected && !txAllowed; + final rxWindowActive = rxWindowTimer.isRunning; + final pingSending = isPingSending; + final discoveryWindowActive = discoveryWindowTimer.isRunning; + final pendingDisable = isPendingDisable; + final canManualPing = canPingManual && + !isAutoStarting && + !isTxModeActive && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !pingSending && + !discoveryWindowActive && + !pendingDisable; + final String? blockedReason; if (!isConnected) { blockedReason = 'Not connected'; } else if (!hasGpsLock) { blockedReason = 'No GPS fix'; + } else if (txBlockedByOffline) { + blockedReason = 'Offline Mode'; + } else if (txNotAllowed) { + blockedReason = 'Passive Only'; } else { blockedReason = null; } return WatchControls( canStartStop: isConnected, - canManualPing: canPing && cooldownMs <= 0, + canManualPing: canManualPing, isSessionActive: _autoPingEnabled, manualCooldownEndsAt: cooldownMs > 0 ? DateTime.now().add(Duration(milliseconds: cooldownMs)) From ea78b4f801f96bcb9eba3d85f7c76c3d36e08614 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 08:31:37 -0700 Subject: [PATCH 187/360] Single-source the manual-ping gate, and stop labels and errors misreporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wrist reports, plus two defects found verifying them. **The command handler's guards were weaker than the button's.** Its manualPing case checked only connection, GPS and cooldown, then called sendPing — while `_buildWatchControls` mirrored the app's twelve conditions. So a wrist tap could reach the radio in a zone where TX is not permitted, with nothing but sendPing's own checks between. That contradicts the contract stated a few lines above it: every guard is re-evaluated because a stale payload must never cause a transmit. The condition set now lives once, in `_manualPingAvailability`, returning availability and reason together. One caller decides what the wrist offers; the other decides whether the radio transmits. Duplicating that policy is exactly how the two drifted apart. **"Stopping…" jumped to the right edge.** A SwiftUI ProgressView takes the horizontal slack a stack offers it, so the spinner shoved the label aside the moment a command went pending. Spinners are leading overlays now, outside the layout that centres the label — feedback should not move the thing under the wearer's thumb. **A dead transport error sat under the button.** "Payload could not be delivered." is WatchConnectivity's wording and it stayed on screen indefinitely, reading as current state rather than one past action. Refusals expire after six seconds, and the delivery-failure and unreachable codes now say "iPhone didn't respond, try again". Refusals from the phone stay verbatim — those are already written for people, and rewording them would put the watch's guess above the phone's statement. No retry: a send whose delivery is uncertain must not be repeated, or a ping goes out twice. Two defects caught in review, neither reported: The cooldown label put two Text views in a `Group`, which applies each modifier to every child — so `maxWidth: .infinity` went to the words and the timer separately and threw them to opposite ends of the button. Verified on screen before fixing. It is one HStack now. The reason ladder fell through to "Another operation is in progress" even when the ping *was* allowed, which would have printed that under two working buttons. Reason is nil unless a refusal is actually happening. --- ios/MeshMapperWatch/ControlsPage.swift | 71 +++++++++++++------- ios/MeshMapperWatch/WatchSessionClient.swift | 54 +++++++++++++-- lib/providers/app_state_provider.dart | 59 +++++++++++----- 3 files changed, 137 insertions(+), 47 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index bfc69c65..13735d3b 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -69,15 +69,20 @@ struct ControlsPage: View { return Button { client.send(kind) } label: { - HStack(spacing: 6) { - if isPending { - ProgressView() - .controlSize(.small) + Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 44) + // A ProgressView accepts the horizontal slack offered by a stack. As + // an overlay it can appear without participating in the label's + // centring, so feedback never makes the action jump under a thumb. + .overlay(alignment: .leading) { + if isPending { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + .padding(.leading, 6) + } } - Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) - .font(.headline) - } - .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.borderedProminent) // Grey when unavailable rather than a desaturated tint: a disabled green @@ -98,33 +103,51 @@ struct ControlsPage: View { armPing() } } label: { - HStack(spacing: 6) { + pingLabel(isPending: isPending) + .frame(maxWidth: .infinity, minHeight: 44) + // Keep this identical to Start/Stop: pending feedback belongs at the + // edge of the target, not in the row that determines its label's centre. + .overlay(alignment: .leading) { if isPending { ProgressView() .controlSize(.small) - } - if let endsAt = cooldownEndsAt { - // A cooldown is the one unavailability the phone reports without a - // `blockedReason`, so without this the button would sit dead and - // unexplained. The deadline is absolute, so the countdown is right - // even if no further snapshot arrives. - Text("Ping in") - .font(.headline) - Text(timerInterval: Date()...endsAt, countsDown: true) - .font(.headline.monospacedDigit()) - .frame(width: 38, alignment: .leading) - } else { - Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) - .font(.headline) + .frame(width: 16, height: 16) + .padding(.leading, 6) } } - .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.borderedProminent) .tint(isEnabled ? (pingArmed ? .orange : .accentColor) : .gray) .disabled(!isEnabled) } + /// The ping button's label, as exactly one view. + /// + /// A `Group` will not do here: with two children it applies each modifier to + /// both, so `maxWidth: .infinity` went to the words *and* the timer and threw + /// them to opposite ends of the button. The pair has to be its own stack to + /// read as one centred label. + /// + /// A cooldown is the one unavailability the phone reports without a + /// `blockedReason`, so without this the button would sit dead and unexplained. + /// The deadline is absolute, so the countdown stays right even if no further + /// snapshot arrives. + @ViewBuilder + private func pingLabel(isPending: Bool) -> some View { + if let endsAt = cooldownEndsAt { + HStack(spacing: 5) { + Text("Ping in") + Text(timerInterval: Date()...endsAt, countsDown: true) + .monospacedDigit() + .frame(width: 38, alignment: .leading) + } + .font(.headline) + } else { + Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) + .font(.headline) + } + } + /// The first tap buys a short confirmation window; expiry returns the button /// to a harmless state without involving the phone or transmitting anything. private func armPing() { diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 7eb9820b..41723b43 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -18,6 +18,11 @@ final class WatchSessionClient: NSObject { /// Set when the phone refuses a command, so the wrist can say why. private(set) var lastRefusal: String? + /// A refusal explains one completed tap, not the current transport state. + /// Restarting its lifetime on replacement prevents an older expiry from + /// erasing newer feedback that happens to arrive near the same moment. + private var refusalExpiryTask: Task? + /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes /// stay out of this state because they have no corresponding wrist action. private(set) var pendingCommand: WatchCommand.Kind? @@ -81,7 +86,7 @@ final class WatchSessionClient: NSObject { /// attempted unconditionally and `errorHandler` is the source of truth. func send(_ kind: WatchCommand.Kind, silent: Bool = false) { guard let session, session.activationState == .activated else { - if !silent { lastRefusal = "Not connected to iPhone" } + if !silent { setLastRefusal("Not connected to iPhone") } return } @@ -89,12 +94,12 @@ final class WatchSessionClient: NSObject { guard let data = try? MeshMapperWatchWire.encoder.encode(command), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - if !silent { lastRefusal = "Could not encode command" } + if !silent { setLastRefusal("Could not encode command") } return } if !silent { - lastRefusal = nil + setLastRefusal(nil) pendingCommand = kind } @@ -112,9 +117,12 @@ final class WatchSessionClient: NSObject { guard !silent, isCurrent else { return } let accepted = reply["accepted"] as? Bool ?? false if accepted { - self?.lastRefusal = nil + self?.setLastRefusal(nil) } else { - self?.lastRefusal = reply["reason"] as? String ?? "Refused" + // The phone owns the policy and already phrases its refusals for + // people; preserving that text avoids replacing fact with a watch + // side guess about why the command was rejected. + self?.setLastRefusal(reply["reason"] as? String ?? "Refused") } } }, @@ -125,12 +133,46 @@ final class WatchSessionClient: NSObject { if isCurrent { self?.pendingCommand = nil } - if !silent, isCurrent { self?.lastRefusal = error.localizedDescription } + if !silent, isCurrent { + self?.setLastRefusal(Self.refusalMessage(for: error)) + } } } ) } + private func setLastRefusal(_ refusal: String?) { + refusalExpiryTask?.cancel() + refusalExpiryTask = nil + lastRefusal = refusal + + guard refusal != nil else { return } + refusalExpiryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(6)) + guard !Task.isCancelled else { return } + self?.lastRefusal = nil + self?.refusalExpiryTask = nil + } + } + + private static func refusalMessage(for error: Error) -> String { + let nsError = error as NSError + guard nsError.domain == WCErrorDomain, + let code = WCError.Code(rawValue: nsError.code) + else { + return error.localizedDescription + } + + switch code { + case .deliveryFailed, .notReachable: + // Delivery is uncertain in both cases. Give the wearer one useful next + // step, but never retry automatically: a duplicate could transmit. + return "iPhone didn't respond, try again" + default: + return error.localizedDescription + } + } + // MARK: - Ingest private func ingest(context: [String: Any]) { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1833f41c..7d521a52 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1352,10 +1352,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return resolved; } - WatchControls _buildWatchControls() { - final cooldownMs = _manualPingCooldownTimer.remainingMs; - // Keep this gate identical to the app's Send Ping button. The watch only - // reflects the phone's latest answer; command handling revalidates again. + ({bool allowed, String? reason}) get _manualPingAvailability { + // This must remain the sole copy of the app button's gate. One caller says + // what the wrist may offer while the other decides whether the radio may + // transmit; letting those answers drift makes a stale watch payload unsafe. final canPingManual = manualPingValidation == PingValidation.valid; final isAutoStarting = isAutoPingStarting; final isTxModeActive = isTxModeRunning; @@ -1368,7 +1368,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final pingSending = isPingSending; final discoveryWindowActive = discoveryWindowTimer.isRunning; final pendingDisable = isPendingDisable; - final canManualPing = canPingManual && + final allowed = canPingManual && !isAutoStarting && !isTxModeActive && !isTargetedRunning && @@ -1381,27 +1381,51 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { !discoveryWindowActive && !pendingDisable; - final String? blockedReason; - if (!isConnected) { - blockedReason = 'Not connected'; + // Only describe a refusal that is actually happening. A reason computed + // alongside an allowed ping would surface on the wrist as a status line + // under two working buttons. + final String? reason; + if (allowed) { + reason = null; + } else if (!isConnected) { + reason = 'Not connected'; } else if (!hasGpsLock) { - blockedReason = 'No GPS fix'; + reason = 'No GPS fix'; } else if (txBlockedByOffline) { - blockedReason = 'Offline Mode'; + reason = 'Offline Mode'; } else if (txNotAllowed) { - blockedReason = 'Passive Only'; + reason = 'Passive Only'; + } else if (manualPingValidation == PingValidation.manualCooldownActive || + cooldownActive || + manualCooldownActive || + rxWindowActive || + discoveryWindowActive) { + reason = 'Cooling down'; + } else if (!canPingManual) { + reason = manualPingValidation.message; } else { - blockedReason = null; + reason = 'Another operation is in progress'; } + return (allowed: allowed, reason: reason); + } + + WatchControls _buildWatchControls() { + final cooldownMs = _manualPingCooldownTimer.remainingMs; + final manualPing = _manualPingAvailability; + return WatchControls( canStartStop: isConnected, - canManualPing: canManualPing, + canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, manualCooldownEndsAt: cooldownMs > 0 ? DateTime.now().add(Duration(milliseconds: cooldownMs)) : null, - blockedReason: blockedReason, + // The button already renders its cooldown deadline. The handler still + // returns this refusal to a stale tap, but duplicating it as a status + // line would spend wrist space without adding an explanation. + blockedReason: + manualPing.reason == 'Cooling down' ? null : manualPing.reason, ); } @@ -1437,9 +1461,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; case WatchCommandKind.manualPing: - if (!isConnected) return 'Not connected'; - if (!hasGpsLock) return 'No GPS fix'; - if (_manualPingCooldownTimer.remainingMs > 0) return 'Cooling down'; + final availability = _manualPingAvailability; + if (!availability.allowed) { + return availability.reason ?? 'Ping unavailable'; + } final sent = await sendPing(); return sent ? null : 'Ping failed'; } From b82b409a01196c304ee43ad4c94ad39271746c3a Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 08:51:42 -0700 Subject: [PATCH 188/360] Acknowledge admission, not completion, and give async failures a channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start and stop worked from the wrist but showed "iPhone didn't respond, try again" every single time. The command was fine; the acknowledgement was late. `relayCommand` replies only when Dart's future resolves, and `_handleWatchCommand` awaited the entire action — `startSession` awaits `toggleAutoPing`, which makes an API session check and drives BLE. That outruns WatchConnectivity's reply window, so the watch's error handler fired on every success. Guards still run on arrival, unchanged: admission is decided synchronously and refusals still travel in the reply. What changed is that the action itself is no longer awaited before replying. Outcomes were never the reply's job anyway — `isSessionActive`, the phase and the ping colour all reach the wrist through snapshots. That leaves failures that only appear later, and manual ping is the case in point: it is refused inside `_checkSessionBeforeAction`, a *server* call, so no local gate can predict it and the wrist got a bare "Ping failed". `WatchHapticCue` already existed for events of this class, so it gains an optional message; a failed action emits a unique-ID failure cue and schedules a snapshot. The watch shows it through the same expiring path as a refusal — one presentation for "the phone says something went wrong", not two. Cue IDs are deduped against a bounded cache because immediate messages and application context can deliver the same cue in either order. `_checkSessionBeforeAction` had `result.reason` and `result.message` and discarded both. It now keeps them, so a refused ping says why, and `zone_full` reuses the existing "Passive Only" wording rather than inventing a third phrasing for one condition. Wire version deliberately unchanged: the cue field is optional and both sides ship in the same app. Unverified end to end — reproducing it needs a phone doing real BLE work, which the simulator cannot do. The reasoning and the gates are sound; the wrist is the proof. --- ios/MeshMapperWatch/WatchSessionClient.swift | 29 ++++++- ios/Runner/WatchSessionManager.swift | 5 +- ios/Shared/MeshMapperWatchPayload.swift | 3 + lib/providers/app_state_provider.dart | 90 ++++++++++++++++++-- lib/services/watch/watch_bridge_service.dart | 16 +++- lib/services/watch/watch_models.dart | 16 +++- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 41723b43..8d5653b1 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -15,7 +15,8 @@ final class WatchSessionClient: NSObject { /// When the last snapshot arrived — drives the stale badge. private(set) var receivedAt: Date? - /// Set when the phone refuses a command, so the wrist can say why. + /// The phone's explanation for a refused admission or a later failed action. + /// Both belong to one short-lived presentation path on the controls page. private(set) var lastRefusal: String? /// A refusal explains one completed tap, not the current transport state. @@ -23,6 +24,11 @@ final class WatchSessionClient: NSObject { /// erasing newer feedback that happens to arrive near the same moment. private var refusalExpiryTask: Task? + /// Immediate messages and application context can deliver the same cue in + /// either order. IDs make that transport redelivery one visible event. + private var presentedCueIDs = Set() + private var presentedCueIDOrder = [String]() + /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes /// stay out of this state because they have no corresponding wrist action. private(set) var pendingCommand: WatchCommand.Kind? @@ -116,14 +122,15 @@ final class WatchSessionClient: NSObject { // older reply rewrite either would put the wrong answer under it. guard !silent, isCurrent else { return } let accepted = reply["accepted"] as? Bool ?? false - if accepted { - self?.setLastRefusal(nil) - } else { + if !accepted { // The phone owns the policy and already phrases its refusals for // people; preserving that text avoids replacing fact with a watch // side guess about why the command was rejected. self?.setLastRefusal(reply["reason"] as? String ?? "Refused") } + // Admission is not completion. The dispatch already cleared older + // text, and clearing again here could erase a fast failure cue that + // crossed this acknowledgement on WatchConnectivity's other path. } }, errorHandler: { [weak self] error in @@ -197,6 +204,20 @@ final class WatchSessionClient: NSObject { self.versionMismatch = false self.snapshot = decoded self.receivedAt = Date() + + if let cue = decoded.cue, + self.presentedCueIDs.insert(cue.id).inserted + { + self.presentedCueIDOrder.append(cue.id) + // The phone bounds its command-ID cache for the same reason: a watch + // process can live for days, while only recent redelivery matters. + if self.presentedCueIDOrder.count > 64 { + self.presentedCueIDs.remove(self.presentedCueIDOrder.removeFirst()) + } + if let message = cue.message, !message.isEmpty { + self.setLastRefusal(message) + } + } } } } diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index f50cf5e8..48abd68c 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -149,8 +149,9 @@ final class WatchSessionManager: NSObject { // MARK: - watch → Flutter - /// Relays a command to Dart and returns the ack. Dart owns the decision; - /// this side never evaluates whether a transmit is legal. + /// Relays a command to Dart and returns its admission ack. Dart owns the + /// synchronous decision and starts accepted work separately; this side never + /// evaluates whether a transmit is legal or waits for BLE/network completion. private func relayCommand(_ payload: [String: Any], reply: @escaping ([String: Any]) -> Void) { guard let channel else { NSLog("[WATCH] Command dropped: no method channel") diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 5b6ed379..f0d5092e 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -136,6 +136,9 @@ struct WatchHapticCue: Codable, Hashable { let id: String /// "success" | "failure" | "notification" let kind: String + /// Optional is an additive wire change: v2 payloads without it still decode, + /// and the matched phone and watch targets ship the new field together. + let message: String? } // MARK: - Snapshot diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7d521a52..c7e34bca 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -138,6 +138,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Last position sent to the watch, held until the fix moves far enough to /// be worth an update. See [_resolveWatchPosition]. WatchPosition? _lastWatchPosition; + WatchHapticCue? _watchCue; + + /// Human-readable failure from the most recent server-side session check. + /// The bool returned by that check controls the action; this preserves the + /// discarded explanation for a wrist action's later failure cue. + String? _lastSessionCheckFailureReason; bool _liveActivitySessionActive = false; bool _liveActivityManualSession = false; String? _liveActivitySessionId; @@ -1245,6 +1251,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { geo: _buildWatchGeo(now), controls: _buildWatchControls(), pingColor: _resolveWatchPingColor(), + cue: _watchCue, phaseDurationMs: _phaseDurationMsFor(phase.endsAt), updatedAt: now, ); @@ -1436,12 +1443,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchGeoBuilder.pingColor('tx', latest.heardRepeaters.isNotEmpty); } - /// Applies an intent from the wrist. + /// Decides whether an intent from the wrist may begin. /// /// Returns null when accepted, or a reason to show on the watch. Every guard /// is re-evaluated here: the watch's view of what's permitted may be stale, /// and a stale payload must never be able to cause a transmit. - Future _handleWatchCommand(WatchCommandKind kind) async { + /// + /// Once admitted, the action deliberately outlives this synchronous reply: + /// WatchConnectivity cannot wait for BLE or server work. Successful outcomes + /// already surface through session, phase, and ping-colour snapshots; a late + /// failure gets its own cue so dropping completion from the ack loses nothing. + String? _handleWatchCommand(WatchCommandKind kind) { if (_isDisposed) return 'App closing'; switch (kind) { @@ -1452,12 +1464,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case WatchCommandKind.startSession: if (!isConnected) return 'Not connected'; if (_autoPingEnabled) return null; // Already running. - final started = await toggleAutoPing(_autoMode); - return started ? null : 'Could not start'; + unawaited(_runWatchStartSession()); + return null; case WatchCommandKind.stopSession: if (!_autoPingEnabled) return null; // Already stopped. - await toggleAutoPing(_autoMode); + unawaited(_runWatchStopSession()); return null; case WatchCommandKind.manualPing: @@ -1465,11 +1477,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!availability.allowed) { return availability.reason ?? 'Ping unavailable'; } - final sent = await sendPing(); - return sent ? null : 'Ping failed'; + unawaited(_runWatchManualPing()); + return null; } } + Future _runWatchStartSession() async { + _lastSessionCheckFailureReason = null; + try { + final started = await toggleAutoPing(_autoMode); + if (!started) { + _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + } + } catch (error) { + debugError('[WATCH] startSession failed after admission: $error'); + _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + } + } + + Future _runWatchStopSession() async { + try { + final stopped = await toggleAutoPing(_autoMode); + if (!stopped) _emitWatchFailure('Could not stop'); + } catch (error) { + debugError('[WATCH] stopSession failed after admission: $error'); + _emitWatchFailure('Could not stop'); + } + } + + Future _runWatchManualPing() async { + _lastSessionCheckFailureReason = null; + try { + final sent = await sendPing(); + if (!sent) { + _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Ping failed'); + } + } catch (error) { + debugError('[WATCH] manualPing failed after admission: $error'); + _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Ping failed'); + } + } + + void _emitWatchFailure(String message) { + if (_isDisposed) return; + _watchCue = WatchHapticCue( + id: const Uuid().v4(), + kind: 'failure', + message: message, + ); + _scheduleWatchSync(immediate: true); + } + LiveActivitySnapshot? _buildLiveActivitySnapshot() { final sessionId = _liveActivitySessionId; if (!_liveActivitySessionActive || sessionId == null) { @@ -4886,6 +4944,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Check session validity before starting a wardrive action /// Returns true if session is valid, false if expired (triggers disconnect) Future _checkSessionBeforeAction() async { + _lastSessionCheckFailureReason = null; final pos = _gpsService.lastPosition; final result = await _apiService.checkSessionValid( lat: pos?.latitude, @@ -4893,6 +4952,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { + _lastSessionCheckFailureReason = _sessionCheckFailureMessage( + result.reason, + result.message, + ); debugWarn( '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors @@ -4901,6 +4964,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } + String _sessionCheckFailureMessage(String? reason, String? message) { + // `zone_full` is the server-side form of the same TX prohibition already + // named "Passive Only" by watch controls. Reusing it avoids three wrist + // phrasings for one condition; other presentable server text stays intact. + if (reason == 'zone_full') return 'Passive Only'; + + final serverMessage = message?.trim(); + if (serverMessage != null && serverMessage.isNotEmpty) { + return serverMessage; + } + return _getErrorMessage(reason, null); + } + /// Set the target repeater ID for targeted mode void setTargetRepeaterId(String? id) { _targetRepeaterId = id; diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 65b9594c..12e5aa3a 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -9,9 +9,11 @@ import 'watch_models.dart'; typedef WatchSnapshotBuilder = WatchSnapshot? Function(); -/// Handles a command from the wrist. Returns null when accepted, or a reason -/// string when refused — the reason is shown on the watch. -typedef WatchCommandHandler = Future Function(WatchCommandKind kind); +/// Decides whether a wrist command may begin. Returns null when admitted, or a +/// reason when refused; admitted work continues independently of this reply. +/// Production handlers must decide synchronously; FutureOr keeps existing +/// bridge fakes source-compatible without putting the real path behind a wait. +typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -85,7 +87,13 @@ class WatchBridgeService { _rememberCommandId(id); try { - final refusal = await handler(kind); + // This is admission, not completion. Keeping the handler synchronous is + // what makes the MethodChannel response fit inside WatchConnectivity's + // short reply window; the admitted action reports its later outcome via + // normal snapshots and one-shot cues. + final admission = handler(kind); + final refusal = + admission is Future ? await admission : admission; // A refused command may legitimately be retried once conditions change. if (refusal != null) _handledCommandIds.remove(id); return {'id': id, 'accepted': refusal == null, 'reason': refusal}; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index d5115bfe..4de1700a 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -251,14 +251,23 @@ class WatchControls { /// Carries an [id] so the watch fires exactly once: diffing state would /// double-fire on redelivery, which WatchConnectivity does routinely. class WatchHapticCue { - const WatchHapticCue({required this.id, required this.kind}); + const WatchHapticCue({required this.id, required this.kind, this.message}); final String id; /// 'success' | 'failure' | 'notification' final String kind; - Map toMap() => {'id': id, 'kind': kind}; + /// Human-readable detail for an event whose outcome arrived after command + /// admission. This additive field is optional, so v2 remains decodable; no + /// wire bump is needed while the matched phone and watch targets ship it. + final String? message; + + Map toMap() => { + 'id': id, + 'kind': kind, + 'message': message, + }; } /// The complete state the watch renders. @@ -295,8 +304,7 @@ class WatchSnapshot { 'phase': core.phase.wireValue, 'phaseTitle': core.phaseTitle, 'phaseDetail': core.phaseDetail, - 'phaseEndsAtMs': - core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'phaseEndsAtMs': core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), 'phaseDurationMs': phaseDurationMs, 'isConnected': core.isConnected, 'zoneCode': core.zoneCode, From b460b98e16344130aa116e5d97cd74d3cb296f6f Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 09:09:20 -0700 Subject: [PATCH 189/360] Send wrist commands over a queued transport, and expire them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start and stop kept reporting "iPhone didn't respond, try again" while working. A device console capture ended the guessing: [WATCH] sendMessage(requestSnapshot) failed: ...device is not reachable. [WATCH] sendMessage(startSession) failed: Payload could not be delivered. `sendMessage` needs the counterpart app reachable, which for the phone means roughly foreground — not the normal case when someone taps their watch. WatchConnectivity delivered the payload and the phone acted on it, but the reply could not return, so the watch reported `deliveryFailed` after every success. The previous fix, replying on admission rather than completion, was aimed at reply latency. Latency was never the cause. Both of my diagnoses came from the symptom; only the logged error settled it. Commands and refresh requests now go by `transferUserInfo`: queued, survives unreachability, wakes the counterpart, and has no reply to fail. That is affordable only because outcomes and refusals already return as snapshots and failure cues. `requestSnapshot` gains the most — it used to fail outright with "not reachable", exactly when a refresh is most wanted. Deliberately no opportunistic `sendMessage` and no retry on failure: `deliveryFailed` is reported for payloads the phone *did* process, so a fallback resend would transmit twice. **Queued commands must expire.** A transfer can arrive whenever the phone next becomes reachable, and a ping that fires minutes late is attributed to where the vehicle now is rather than where it was. Commands carry `issuedAtMs`; anything older than 30 s is refused before reaching `_handleWatchCommand`. The ID is remembered first, so redelivery cannot retry it later. `requestSnapshot` is exempt — a late refresh is harmless — and a missing timestamp is still accepted, for watches running the older build. `pendingCommand` was cleared by the reply that no longer exists, so it now clears on the next snapshot with a 10 s backstop; a spinner that never stops is worse than none. `WatchCommandAck` is removed rather than left describing a protocol we no longer speak. --- ios/MeshMapperWatch/WatchSessionClient.swift | 101 +++++++------------ ios/Runner/WatchSessionManager.swift | 12 +++ ios/Shared/MeshMapperWatchPayload.swift | 12 +-- lib/providers/app_state_provider.dart | 5 +- lib/services/watch/watch_bridge_service.dart | 38 ++++++- 5 files changed, 91 insertions(+), 77 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 8d5653b1..f5e54738 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -29,9 +29,11 @@ final class WatchSessionClient: NSObject { private var presentedCueIDs = Set() private var presentedCueIDOrder = [String]() - /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes - /// stay out of this state because they have no corresponding wrist action. + /// Wearer-initiated command awaiting evidence of phone-side progress. + /// Automatic refreshes stay out because they have no corresponding wrist + /// action, and queued delivery has no acknowledgement to wait for. private(set) var pendingCommand: WatchCommand.Kind? + private var pendingCommandTimeoutTask: Task? /// Set when a payload arrives from a wire version this build predates. private(set) var versionMismatch = false @@ -84,19 +86,23 @@ final class WatchSessionClient: NSObject { /// - Parameter silent: suppress the refusal banner. Used for the automatic /// refresh, which the wearer never asked for and shouldn't see fail. - /// Sends without pre-checking `isReachable`. + /// Queues without pre-checking `isReachable`. /// - /// That flag lags reality — during testing the simulator reported - /// unreachable while messages were still being delivered a second or two - /// later. Gating on it turns a stale flag into a refused tap, so the send is - /// attempted unconditionally and `errorHandler` is the source of truth. + /// Wrist controls normally run while the phone app is not foregrounded, + /// where `sendMessage` can execute the command yet fail its reply as + /// undeliverable. One queued path avoids both that false failure and the + /// duplicate-transmit risk of retrying an ambiguously delivered message. func send(_ kind: WatchCommand.Kind, silent: Bool = false) { guard let session, session.activationState == .activated else { if !silent { setLastRefusal("Not connected to iPhone") } return } - let command = WatchCommand(kind: kind, id: UUID().uuidString) + let command = WatchCommand( + kind: kind, + id: UUID().uuidString, + issuedAtMs: Date().timeIntervalSince1970 * 1000 + ) guard let data = try? MeshMapperWatchWire.encoder.encode(command), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -106,46 +112,27 @@ final class WatchSessionClient: NSObject { if !silent { setLastRefusal(nil) - pendingCommand = kind + beginPending(kind) } - session.sendMessage( - [MeshMapperWatchWire.commandKey: dict], - replyHandler: { [weak self] reply in - NSLog("[WATCH] reply for \(kind.rawValue): \(reply)") - Task { @MainActor in - let isCurrent = self?.pendingCommand == kind - if isCurrent { - self?.pendingCommand = nil - } - // A newer tap owns both the spinner and its feedback. Letting an - // older reply rewrite either would put the wrong answer under it. - guard !silent, isCurrent else { return } - let accepted = reply["accepted"] as? Bool ?? false - if !accepted { - // The phone owns the policy and already phrases its refusals for - // people; preserving that text avoids replacing fact with a watch - // side guess about why the command was rejected. - self?.setLastRefusal(reply["reason"] as? String ?? "Refused") - } - // Admission is not completion. The dispatch already cleared older - // text, and clearing again here could erase a fast failure cue that - // crossed this acknowledgement on WatchConnectivity's other path. - } - }, - errorHandler: { [weak self] error in - NSLog("[WATCH] sendMessage(\(kind.rawValue)) failed: \(error.localizedDescription)") - Task { @MainActor in - let isCurrent = self?.pendingCommand == kind - if isCurrent { - self?.pendingCommand = nil - } - if !silent, isCurrent { - self?.setLastRefusal(Self.refusalMessage(for: error)) - } - } - } - ) + session.transferUserInfo([MeshMapperWatchWire.commandKey: dict]) + } + + private func beginPending(_ kind: WatchCommand.Kind) { + pendingCommandTimeoutTask?.cancel() + pendingCommand = kind + pendingCommandTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled, self?.pendingCommand == kind else { return } + self?.pendingCommand = nil + self?.pendingCommandTimeoutTask = nil + } + } + + private func clearPendingCommand() { + pendingCommandTimeoutTask?.cancel() + pendingCommandTimeoutTask = nil + pendingCommand = nil } private func setLastRefusal(_ refusal: String?) { @@ -162,24 +149,6 @@ final class WatchSessionClient: NSObject { } } - private static func refusalMessage(for error: Error) -> String { - let nsError = error as NSError - guard nsError.domain == WCErrorDomain, - let code = WCError.Code(rawValue: nsError.code) - else { - return error.localizedDescription - } - - switch code { - case .deliveryFailed, .notReachable: - // Delivery is uncertain in both cases. Give the wearer one useful next - // step, but never retry automatically: a duplicate could transmit. - return "iPhone didn't respond, try again" - default: - return error.localizedDescription - } - } - // MARK: - Ingest private func ingest(context: [String: Any]) { @@ -204,6 +173,10 @@ final class WatchSessionClient: NSObject { self.versionMismatch = false self.snapshot = decoded self.receivedAt = Date() + // A queued command has no ack. Any subsequent snapshot proves the phone + // has resumed communicating; a separate timeout covers the case where + // state dedupe means no snapshot follows. + self.clearPendingCommand() if let cue = decoded.cue, self.presentedCueIDs.insert(cue.id).inserted diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index 48abd68c..2cf0a802 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -203,6 +203,16 @@ extension WatchSessionManager: WCSessionDelegate { lastContextData = nil } + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let command = userInfo[MeshMapperWatchWire.commandKey] as? [String: Any] else { + NSLog("[WATCH] Malformed queued command payload: \(Array(userInfo.keys))") + return + } + relayCommand(command) { _ in } + } + + /// Retained for commands already sent by watch builds that predate the + /// queued transport; removing it would strand an in-flight wrist action. func session( _ session: WCSession, didReceiveMessage message: [String: Any], @@ -216,6 +226,8 @@ extension WatchSessionManager: WCSessionDelegate { relayCommand(command, reply: replyHandler) } + /// The no-reply legacy overload is retained for the same compatibility + /// window. New watches use `transferUserInfo` exclusively. func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { guard let command = message[MeshMapperWatchWire.commandKey] as? [String: Any] else { NSLog("[WATCH] Malformed command payload: \(Array(message.keys))") diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index f0d5092e..04a1e7e0 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -212,15 +212,13 @@ struct WatchCommand: Codable, Hashable { let kind: Kind /// Client-generated, so the phone can dedupe redelivered commands. let id: String + /// Queued delivery can outlive the place where a transmit was requested. + /// The phone uses this to reject stale actions before admission. + let issuedAtMs: Double } -/// The phone's answer to a command. -struct WatchCommandAck: Codable, Hashable { - let id: String - let accepted: Bool - /// Why it was refused, for display on the wrist. - let reason: String? -} +// There is deliberately no acknowledgement model: queued commands have no +// reply channel; state snapshots and failure cues carry every outcome. // MARK: - Coding helpers diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c7e34bca..06e08dbb 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1931,7 +1931,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.addListener(_handleLiveActivityTimerChange); } if (_watchBridge.isSupportedPlatform) { - _watchBridge.attachCommandHandler(_handleWatchCommand); + _watchBridge.attachCommandHandler( + _handleWatchCommand, + onRefusal: _emitWatchFailure, + ); } // Initialize debug logging (enabled by default, respects user preference) diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 12e5aa3a..8ed94818 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -14,6 +14,7 @@ typedef WatchSnapshotBuilder = WatchSnapshot? Function(); /// Production handlers must decide synchronously; FutureOr keeps existing /// bridge fakes source-compatible without putting the real path behind a wait. typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); +typedef WatchCommandRefusalHandler = void Function(String reason); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -30,12 +31,14 @@ class WatchBridgeService { static const Duration _debounceDelay = Duration(milliseconds: 200); static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + static const Duration _maximumCommandAge = Duration(seconds: 30); final MethodChannel _channel; Timer? _scheduledUpdate; WatchSnapshotBuilder? _pendingSnapshotBuilder; WatchCommandHandler? _commandHandler; + WatchCommandRefusalHandler? _commandRefusalHandler; String? _lastPayload; String? _lastUrgencyKey; @@ -51,8 +54,12 @@ class WatchBridgeService { !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; /// Wire up the inbound command path. Safe to call more than once. - void attachCommandHandler(WatchCommandHandler handler) { + void attachCommandHandler( + WatchCommandHandler handler, { + WatchCommandRefusalHandler? onRefusal, + }) { _commandHandler = handler; + _commandRefusalHandler = onRefusal; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); } @@ -86,6 +93,20 @@ class WatchBridgeService { _rememberCommandId(id); + final rawIssuedAtMs = args['issuedAtMs']; + final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; + if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { + final ageMs = DateTime.now().millisecondsSinceEpoch - issuedAtMs; + if (ageMs > _maximumCommandAge.inMilliseconds) { + const reason = 'Took too long to reach iPhone'; + // This window is about correctness, not queue housekeeping: executing + // a transmit after the vehicle has moved attributes it to the wrong + // place. Missing timestamps remain accepted for older watch builds. + _commandRefusalHandler?.call(reason); + return {'id': id, 'accepted': false, 'reason': reason}; + } + } + try { // This is admission, not completion. Keeping the handler synchronous is // what makes the MethodChannel response fit inside WatchConnectivity's @@ -94,13 +115,19 @@ class WatchBridgeService { final admission = handler(kind); final refusal = admission is Future ? await admission : admission; - // A refused command may legitimately be retried once conditions change. - if (refusal != null) _handledCommandIds.remove(id); + if (refusal != null) { + _commandRefusalHandler?.call(refusal); + // Reply-capable legacy watches could retry an admission refusal with + // the same ID. Queued commands must stay remembered: redelivery after + // conditions change must never turn yesterday's tap into a transmit. + if (issuedAtMs == null) _handledCommandIds.remove(id); + } return {'id': id, 'accepted': refusal == null, 'reason': refusal}; } catch (error) { - _handledCommandIds.remove(id); debugError('[WATCH] Command $rawKind failed: $error'); - return {'id': id, 'accepted': false, 'reason': 'Command failed'}; + const reason = 'Command failed'; + _commandRefusalHandler?.call(reason); + return {'id': id, 'accepted': false, 'reason': reason}; } } @@ -214,5 +241,6 @@ class WatchBridgeService { _scheduledUpdate = null; _pendingSnapshotBuilder = null; _commandHandler = null; + _commandRefusalHandler = null; } } From e9982271e77d5179961d9a0d7070e5167caffa67 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 09:40:41 -0700 Subject: [PATCH 190/360] Start the mode the region allows, and name it on the button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a session from the wrist did nothing, while stopping produced a cooldown that passive mode never creates. `_handleWatchCommand` called `toggleAutoPing(_autoMode)`, but `_autoMode` defaults to Active and is only assigned inside `toggleAutoPing` when a mode actually starts. So until a mode had been started *on the phone*, the wrist started Active — which in Adam's passive-only region is the one mode forbidden there. His Live Activity had been reporting this all along: the "circle with a line through it" is the `txBlocked` phase, which fires on exactly `(_autoMode == active|hybrid|targeted) && !txAllowed`. Pressing Passive on the phone set `_autoMode`, which is why every wrist toggle worked afterwards. The phone never hit this because each of its buttons passes an explicit mode. Only the wrist inherited an implicit one, and the default happened to be the forbidden one. `_resolvedWatchSessionMode` now decides: a running session keeps its own mode, so the wrist stops what it started; otherwise a region that forbids TX resolves to Passive; otherwise the wearer's last choice stands. The button says which mode it will start — "Start Passive" — because a wrist control that silently picks a mode is only safe while the guess is right. That label reads from the same resolver as the action; sourcing it from the ambient `_autoMode` would have traded a silent wrong action for a visible lie. Only the watch payload's `mode` changed: the Live Activity keeps `_liveActivityModeTitle`, since it reports the session that is running rather than the one a button would start. --- ios/MeshMapperWatch/ControlsPage.swift | 8 ++++- lib/providers/app_state_provider.dart | 49 +++++++++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 13735d3b..d891f69d 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -65,12 +65,18 @@ struct ControlsPage: View { let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession let isPending = client.pendingCommand == kind let isEnabled = controls?.canStartStop == true && !isPending + let startTitle = "Start \(client.snapshot?.mode ?? "Session")" + let buttonTitle = isPending + ? (isActive ? "Stopping…" : "Starting…") + : (isActive ? "Stop" : startTitle) return Button { client.send(kind) } label: { - Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) + Text(buttonTitle) .font(.headline) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, minHeight: 44) // A ProgressView accepts the horizontal slack offered by a stack. As // an overlay it can appear without participating in the label's diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 06e08dbb..e97d1e6b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1228,7 +1228,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final core = LiveActivitySnapshot( sessionId: _liveActivitySessionId ?? 'idle', - mode: _liveActivityModeTitle, + // On the watch this field is also the Start button's promise, so it must + // describe the resolver the command will use rather than the ambient + // default that only becomes meaningful after a phone button is pressed. + mode: _resolvedWatchSessionModeTitle, phase: phase.phase, phaseTitle: phase.title, phaseDetail: phase.detail, @@ -1417,6 +1420,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (allowed: allowed, reason: reason); } + AutoMode get _resolvedWatchSessionMode { + // Phone buttons each carry an explicit mode, but the wrist has one generic + // Start button and `_autoMode` begins as Active before any phone choice has + // established intent. In a passive-only region inheriting that default + // silently selects the one forbidden mode. Once running, preserve the + // actual mode so the same resolver always stops what it started. + if (_autoPingEnabled) return _autoMode; + if (isConnected && !txAllowed) return AutoMode.passive; + return _autoMode; + } + + String get _resolvedWatchSessionModeTitle => + switch (_resolvedWatchSessionMode) { + AutoMode.active => 'Active', + AutoMode.passive => 'Passive', + AutoMode.hybrid => 'Hybrid', + AutoMode.targeted => 'Trace', + }; + WatchControls _buildWatchControls() { final cooldownMs = _manualPingCooldownTimer.remainingMs; final manualPing = _manualPingAvailability; @@ -1464,12 +1486,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case WatchCommandKind.startSession: if (!isConnected) return 'Not connected'; if (_autoPingEnabled) return null; // Already running. - unawaited(_runWatchStartSession()); + unawaited(_runWatchStartSession(_resolvedWatchSessionMode)); return null; case WatchCommandKind.stopSession: if (!_autoPingEnabled) return null; // Already stopped. - unawaited(_runWatchStopSession()); + unawaited(_runWatchStopSession(_resolvedWatchSessionMode)); return null; case WatchCommandKind.manualPing: @@ -1482,22 +1504,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - Future _runWatchStartSession() async { + Future _runWatchStartSession(AutoMode mode) async { _lastSessionCheckFailureReason = null; try { - final started = await toggleAutoPing(_autoMode); + final started = await toggleAutoPing(mode); if (!started) { - _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + _emitWatchFailure(_watchStartFailureReason(mode)); } } catch (error) { debugError('[WATCH] startSession failed after admission: $error'); - _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + _emitWatchFailure(_watchStartFailureReason(mode)); } } - Future _runWatchStopSession() async { + Future _runWatchStopSession(AutoMode mode) async { try { - final stopped = await toggleAutoPing(_autoMode); + final stopped = await toggleAutoPing(mode); if (!stopped) _emitWatchFailure('Could not stop'); } catch (error) { debugError('[WATCH] stopSession failed after admission: $error'); @@ -1505,6 +1527,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + String _watchStartFailureReason(AutoMode mode) { + final sessionReason = _lastSessionCheckFailureReason; + if (sessionReason != null) return sessionReason; + if (mode != AutoMode.passive && _cooldownTimer.isRunning) { + return 'Cooling down'; + } + return 'Could not start'; + } + Future _runWatchManualPing() async { _lastSessionCheckFailureReason = null; try { From 2768c79c621be27be4fefcc400731dc912c748fa Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 10:28:36 -0700 Subject: [PATCH 191/360] Rebuild the Live Activity on the map overlay's vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The design of the live event panel on the watch and iphone leave a lot to be desired. We have better design elements in the app we should repurpose." The elements worth repurposing are the ones now signed off on the wrist: a depleting countdown bar, and rows of hex identity with a ping-type dot and a quality-coloured SNR. Most of the gap was data, not styling. `ContentState` carried `phaseEndsAt` but no duration, so a progress bar could only be full or empty. `HeardRepeater` was `{id, name, snr}` with no ping type and no colour, so every dot was painted the same grey-teal and the distinction between a discovery answer, a flood answer and an RX packet — which the map overlay is built around — could not be drawn at all. Nothing carried the last ping's outcome, so an unanswered ping, a real negative result when mapping coverage, looked identical to a cycle that had not reported yet. And the extension hardcoded three colours, quietly ignoring the colour-vision palettes that the watch honours for free. So the wire gains `phaseDurationMs`, `pingColor`, and per-repeater `typeColor`/`snrColor`, all resolved on the phone through the same helpers the watch already uses rather than a second set. Colour policy stays in Dart, where the palettes live. Layouts, per surface's real constraints: the lock screen shows hex *and* resolved name, because that is what the larger display is for; the watch small family shows hex only, since the hash is the identity and there is no room for more; the island's minimal presentation carries the outcome colour, since one mark should be the most valuable one. A lapsed deadline dims its title to 45% and drops the countdown, matching the rule the watch already follows — these surfaces can sit on a stale state for a long time, and neither should keep asserting a phase it can no longer vouch for. Reviewed as rendered pixels, not as code. The content views take a plain `ContentState`, so `ImageRenderer` can draw them headlessly — nine images across three states at each surface's real width. That caught what reading could not: the metrics sat on the third repeater row's baseline, so `91CE -8.7 dB TX 42 RX 318` read as one line and the session totals looked like properties of a repeater. They now share the badge row, taking the space back from `phaseDetail`, which on the lock screen only restated the bar above it. The detail stays in the payload — several phases carry information the bar does not, and the island's centre region is the place for it. Session-end summary is deliberately not here; it is the next round. --- .../MeshMapperLiveActivity.swift | 736 ++++++++++++------ ios/Runner/LiveActivityManager.swift | 32 +- ios/Shared/MeshMapperActivityAttributes.swift | 17 +- lib/providers/app_state_provider.dart | 36 +- .../live_activity/live_activity_models.dart | 18 + lib/services/watch/watch_color.dart | 29 + lib/services/watch/watch_geo_builder.dart | 6 +- lib/services/watch/watch_models.dart | 32 +- 8 files changed, 630 insertions(+), 276 deletions(-) create mode 100644 lib/services/watch/watch_color.dart diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index efe9fa71..e959df4e 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -20,10 +20,13 @@ struct MeshMapperLiveActivity: Widget { MeshMapperModeBadge(mode: context.state.mode) } DynamicIslandExpandedRegion(.trailing) { - MeshMapperBestSignal(state: context.state) + MeshMapperIslandOutcome(state: context.state, isStale: context.isStale) } DynamicIslandExpandedRegion(.center) { - MeshMapperPhaseLabel(state: context.state) + Text(context.state.zoneCode ?? context.state.connectionLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(context.isStale ? Color.orange : .secondary) + .lineLimit(1) } DynamicIslandExpandedRegion(.bottom) { MeshMapperIslandBottom(state: context.state) @@ -33,19 +36,22 @@ struct MeshMapperLiveActivity: Widget { systemName: context.isStale ? "exclamationmark.triangle.fill" : context.state.phaseSymbol ) - .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) + .foregroundStyle(context.isStale ? Color.orange : context.state.displayColor) .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) } compactTrailing: { MeshMapperCompactTrailing(state: context.state) } minimal: { - Image( - systemName: context.isStale - ? "exclamationmark.triangle.fill" : context.state.phaseSymbol - ) - .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) - .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) + if context.isStale { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityLabel("Update delayed") + } else { + // With room for one mark, the latest result matters more than mode + // or phase. This remains useful when an unanswered ping has no rows. + MeshMapperOutcomeDot(state: context.state, diameter: 9) + } } - .keylineTint(context.state.phaseColor) + .keylineTint(context.state.displayColor) } .meshMapperSupplementalActivityFamilies() } @@ -68,9 +74,15 @@ private struct MeshMapperResponsiveActivityView: View { var body: some View { if activityFamily == .small { - MeshMapperSmallActivityView(context: context) + MeshMapperSmallActivityContent( + state: context.state, + isStale: context.isStale + ) } else { - MeshMapperLockScreenView(context: context) + MeshMapperLockScreenContent( + state: context.state, + isStale: context.isStale + ) } } } @@ -79,58 +91,44 @@ private struct MeshMapperLockScreenView: View { let context: ActivityViewContext var body: some View { - VStack(alignment: .leading, spacing: 9) { - HStack(spacing: 8) { - MeshMapperModeBadge(mode: context.state.mode) - Spacer(minLength: 8) - MeshMapperStatusLabel(context: context) - } + MeshMapperLockScreenContent( + state: context.state, + isStale: context.isStale + ) + } +} - HStack(alignment: .center, spacing: 9) { - Image(systemName: context.state.phaseSymbol) - .font(.title3.weight(.semibold)) - .foregroundStyle(context.state.phaseColor) - .frame(width: 24) - .accessibilityHidden(true) +private struct MeshMapperLockScreenContent: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool - VStack(alignment: .leading, spacing: 1) { - Text(context.state.phaseTitle) - .font(.headline.weight(.semibold)) - .lineLimit(1) - if let detail = context.state.phaseDetail, !detail.isEmpty { - Text(detail) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } + var body: some View { + VStack(alignment: .leading, spacing: 9) { + MeshMapperPhaseBar( + state: state, + height: 20, + titleFont: .subheadline.weight(.semibold), + countdownFont: .subheadline.monospacedDigit().weight(.semibold), + countdownWidth: 54 + ) + HStack(spacing: 8) { + MeshMapperModeBadge(mode: state.mode) + // Counts describe the session, so they belong beside its mode. Keeping + // them out of the repeater stack prevents the last row from reading as + // though TX, RX, and queue values belong to that individual node. + MeshMapperMetrics(state: state, compact: false) Spacer(minLength: 6) - MeshMapperCountdown( - state: context.state, - font: .headline.monospacedDigit().weight(.semibold) - ) + MeshMapperStatusLabel(state: state, isStale: isStale) } - HStack(alignment: .bottom, spacing: 12) { - MeshMapperRepeaterSummary(state: context.state) - .frame(maxWidth: .infinity, alignment: .leading) - - VStack(alignment: .trailing, spacing: 4) { - HStack(spacing: 9) { - MeshMapperMetric( - label: context.state.primaryMetricLabel, - value: context.state.primaryMetricValue - ) - MeshMapperMetric(label: "RX", value: context.state.rxCount) - } - if context.state.queueSize > 0 { - Label("Queue \(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") - .font(.caption2.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) - } - } - } + MeshMapperRepeaterSummary( + state: state, + limit: 3, + showsNames: true, + rowFont: .caption + ) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 14) .padding(.vertical, 12) @@ -139,85 +137,128 @@ private struct MeshMapperLockScreenView: View { } @available(iOSApplicationExtension 18.0, *) -private struct MeshMapperSmallActivityView: View { - let context: ActivityViewContext +private struct MeshMapperSmallActivityContent: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 7) { - Image(systemName: context.state.phaseSymbol) - .foregroundStyle(context.state.phaseColor) - .accessibilityHidden(true) - Text(context.state.mode.uppercased()) + VStack(alignment: .leading, spacing: 7) { + MeshMapperPhaseBar( + state: state, + height: 18, + titleFont: .caption.weight(.semibold), + countdownFont: .caption2.monospacedDigit().weight(.bold), + countdownWidth: 40 + ) + + HStack(spacing: 6) { + Text(state.mode.uppercased()) .font(.caption2.weight(.bold)) .tracking(0.6) - Spacer(minLength: 4) - if context.isStale { + Spacer(minLength: 3) + if isStale { Image(systemName: "exclamationmark.triangle.fill") .font(.caption2) .foregroundStyle(.orange) .accessibilityLabel("Update delayed") - } else if let zone = context.state.zoneCode { - Text(zone) - .font(.system(.caption2, design: .monospaced).weight(.semibold)) - .foregroundStyle(.secondary) + } else { + MeshMapperOutcomeDot(state: state, diameter: 7) + if let zone = state.zoneCode { + Text(zone) + .font(.system(.caption2, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + } } } - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(context.state.phaseTitle) - .font(.headline.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - Spacer(minLength: 4) - MeshMapperCountdown( - state: context.state, - font: .headline.monospacedDigit().weight(.semibold) - ) - } + MeshMapperRepeaterSummary( + state: state, + limit: 2, + showsNames: false, + rowFont: .caption2 + ) + + MeshMapperMetrics(state: state, compact: true) + } + .padding(12) + .foregroundStyle(.white) + } +} - MeshMapperBestRepeaterRow(state: context.state) +/// The phase as a locally depleting bar, matching the watch map panel. +/// +/// Absolute deadline plus duration lets SwiftUI animate between sparse phone +/// updates. The title and countdown ride over the fill so neither consumes +/// track width, and their shadow keeps them legible on both halves. +private struct MeshMapperPhaseBar: View { + let state: MeshMapperActivityAttributes.ContentState + let height: CGFloat + let titleFont: Font + let countdownFont: Font + let countdownWidth: CGFloat - HStack(spacing: 10) { - Text("\(context.state.primaryMetricLabel) \(context.state.primaryMetricValue)") - Text("RX \(context.state.rxCount)") - Spacer(minLength: 0) - if context.state.queueSize > 0 { - Label("\(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(state.displayColor) + .frame( + width: geometry.size.width + * (state.phaseRemainingFraction(at: context.date) ?? 0) + ) + } + .overlay { + HStack { + Text(state.phaseTitle) + .font(titleFont) + .foregroundStyle( + .white.opacity(state.deadlineLapsed(at: context.date) ? 0.45 : 1) + ) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + MeshMapperCountdown( + state: state, + at: context.date, + font: countdownFont + ) + .frame(width: countdownWidth, alignment: .trailing) + } + .padding(.horizontal, 7) + .shadow(color: .black.opacity(0.7), radius: 1.5) } } - .font(.caption2.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) } - .padding(12) - .foregroundStyle(.white) + .frame(height: height) } } private struct MeshMapperStatusLabel: View { - let context: ActivityViewContext + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool var body: some View { Label( - context.isStale - ? "Update delayed" : context.state.zoneCode ?? context.state.connectionLabel, - systemImage: context.isStale + isStale ? "Update delayed" : state.zoneCode ?? state.connectionLabel, + systemImage: isStale ? "exclamationmark.triangle.fill" - : context.state.isConnected + : state.isConnected ? "antenna.radiowaves.left.and.right" : "wifi.slash" ) .font(.caption2.weight(.semibold)) .lineLimit(1) - .foregroundStyle( - context.isStale || !context.state.isConnected - ? Color.orange : MeshMapperPalette.secondary - ) + .foregroundStyle(isStale || !state.isConnected ? Color.orange : .secondary) } } private struct MeshMapperRepeaterSummary: View { let state: MeshMapperActivityAttributes.ContentState + let limit: Int + let showsNames: Bool + let rowFont: Font var body: some View { VStack(alignment: .leading, spacing: 3) { @@ -225,93 +266,109 @@ private struct MeshMapperRepeaterSummary: View { Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD") .font(.caption2.weight(.bold)) .tracking(0.6) - .foregroundStyle(.secondary) if state.totalHeardCount > 0 { Text("\(state.totalHeardCount)") .font(.caption2.monospacedDigit().weight(.semibold)) - .foregroundStyle(.secondary) } } + .foregroundStyle(.secondary) if state.repeaters.isEmpty { - Text(state.repeaterEmptyLabel) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 6) { + // A failed ping produces no rows, so its colour needs its own mark; + // absence alone cannot distinguish failure from no attempt yet. + MeshMapperOutcomeDot(state: state, diameter: 6) + Text(state.repeaterEmptyLabel) + .font(rowFont) + .foregroundStyle(.secondary) + .lineLimit(1) + } } else { - ForEach(state.repeaters.prefix(2)) { repeater in - HStack(spacing: 6) { - Circle() - .fill(MeshMapperPalette.secondary) - .frame(width: 5, height: 5) - .accessibilityHidden(true) - Text(repeater.displayName) - .font(.caption.weight(.medium)) - .lineLimit(1) - Text(repeater.snr.formattedSnr) - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(repeater.displayName), SNR \(repeater.snr.formattedSnr)") + ForEach(state.repeaters.prefix(limit)) { repeater in + MeshMapperRepeaterRow( + repeater: repeater, + fallbackColor: state.displayColor, + showsName: showsNames, + font: rowFont + ) } } } } } -private struct MeshMapperBestRepeaterRow: View { - let state: MeshMapperActivityAttributes.ContentState +private struct MeshMapperRepeaterRow: View { + let repeater: MeshMapperActivityAttributes.HeardRepeater + let fallbackColor: Color + let showsName: Bool + let font: Font var body: some View { - if let best = state.repeaters.first { - HStack(spacing: 6) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.caption2) - .foregroundStyle(MeshMapperPalette.secondary) - .accessibilityHidden(true) - Text(best.displayName) - .font(.caption.weight(.medium)) + HStack(spacing: 6) { + Circle() + .fill(repeater.typeColor.map(Color.init) ?? fallbackColor) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + // The path hash remains primary even when a friendly name resolves: it + // is the observation's identity and is what the map overlay names. + Text(repeater.id.uppercased()) + .font(font.monospaced().weight(.semibold)) + .lineLimit(1) + if showsName, let name = repeater.resolvedName { + Text(name) + .font(font) + .foregroundStyle(.secondary) .lineLimit(1) - Spacer(minLength: 4) - Text(best.snr.formattedSnr) - .font(.caption.monospacedDigit().weight(.semibold)) - if state.totalHeardCount > 1 { - Text("+\(state.totalHeardCount - 1)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - } + .truncationMode(.tail) } - .accessibilityElement(children: .ignore) - .accessibilityLabel( - "Best repeater \(best.displayName), SNR \(best.snr.formattedSnr), " - + "\(state.totalHeardCount) heard" - ) - } else { - Text(state.repeaterEmptyLabel) - .font(.caption) - .foregroundStyle(.secondary) + Spacer(minLength: 4) + Text(repeater.snr.formattedSnr) + .font(font.monospacedDigit().weight(.semibold)) + .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) .lineLimit(1) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(repeater.accessibilitySummary) } } -private struct MeshMapperIslandBottom: View { +private struct MeshMapperMetrics: View { let state: MeshMapperActivityAttributes.ContentState + let compact: Bool var body: some View { - VStack(spacing: 6) { - MeshMapperBestRepeaterRow(state: state) - HStack(spacing: 12) { - Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") - Text("RX \(state.rxCount)") - if let zone = state.zoneCode { - Spacer() - Text(zone) - } + HStack(spacing: compact ? 9 : 11) { + Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") + Text("RX \(state.rxCount)") + if state.queueSize > 0 { + Label("\(state.queueSize)", systemImage: "arrow.triangle.2.circlepath") } - .font(.caption.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) + } + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +private struct MeshMapperIslandBottom: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + MeshMapperPhaseBar( + state: state, + height: 18, + titleFont: .caption.weight(.semibold), + countdownFont: .caption2.monospacedDigit().weight(.bold), + countdownWidth: 42 + ) + MeshMapperRepeaterSummary( + state: state, + limit: 2, + showsNames: false, + rowFont: .caption2 + ) + MeshMapperMetrics(state: state, compact: true) } } } @@ -326,46 +383,40 @@ private struct MeshMapperModeBadge: View { .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(MeshMapperPalette.primary, in: Capsule()) + // Mode is categorical, not signal data. Keeping its badge neutral leaves + // palette-resolved outcome, type, and SNR colours unambiguous. + .background(.white.opacity(0.16), in: Capsule()) } } -private struct MeshMapperPhaseLabel: View { +private struct MeshMapperIslandOutcome: View { let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool var body: some View { - VStack(spacing: 2) { - Text(state.phaseTitle) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - MeshMapperCountdown( - state: state, - font: .caption.monospacedDigit().weight(.semibold) - ) - .foregroundStyle(.secondary) + if isStale { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityLabel("Update delayed") + } else { + HStack(spacing: 5) { + MeshMapperOutcomeDot(state: state, diameter: 8) + Text(state.primaryMetricValue.formatted()) + .font(.caption.monospacedDigit().weight(.semibold)) + } } } } -private struct MeshMapperBestSignal: View { +private struct MeshMapperOutcomeDot: View { let state: MeshMapperActivityAttributes.ContentState + let diameter: CGFloat var body: some View { - if let best = state.repeaters.first { - VStack(alignment: .trailing, spacing: 2) { - Text(best.snr.formattedSnr) - .font(.subheadline.monospacedDigit().weight(.semibold)) - Text("best SNR") - .font(.caption2) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") - } else { - Image(systemName: "waveform.slash") - .foregroundStyle(.secondary) - .accessibilityLabel("No repeaters heard") - } + Circle() + .fill(state.displayColor) + .frame(width: diameter, height: diameter) + .accessibilityLabel("Latest ping result") } } @@ -373,31 +424,34 @@ private struct MeshMapperCompactTrailing: View { let state: MeshMapperActivityAttributes.ContentState var body: some View { - if state.hasActiveCountdown { - MeshMapperCountdown( - state: state, - font: .caption2.monospacedDigit().weight(.bold) - ) - .frame(minWidth: 28) - } else if let best = state.repeaters.first { - Text(best.snr.formattedSnr) - .font(.caption2.monospacedDigit().weight(.bold)) - .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") - } else { - Text("\(state.rxCount)") - .font(.caption2.monospacedDigit().weight(.bold)) - .accessibilityLabel("\(state.rxCount) received") + TimelineView(.periodic(from: .now, by: 1)) { context in + if state.hasActiveCountdown(at: context.date) { + MeshMapperCountdown( + state: state, + at: context.date, + font: .caption2.monospacedDigit().weight(.bold) + ) + .frame(minWidth: 28) + } else if let best = state.repeaters.first { + Text(best.snr.formattedSnr) + .font(.caption2.monospacedDigit().weight(.bold)) + .foregroundStyle(best.snrColor.map(Color.init) ?? .primary) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + MeshMapperOutcomeDot(state: state, diameter: 8) + } } } } private struct MeshMapperCountdown: View { let state: MeshMapperActivityAttributes.ContentState + let at: Date let font: Font var body: some View { - if let end = state.phaseEndsAt, end > Date() { - Text(timerInterval: Date()...end, countsDown: true, showsHours: false) + if let end = state.phaseEndsAt, end > at { + Text(timerInterval: at...end, countsDown: true, showsHours: false) .font(font) .lineLimit(1) .accessibilityLabel("Time remaining") @@ -405,30 +459,11 @@ private struct MeshMapperCountdown: View { } } -private struct MeshMapperMetric: View { - let label: String - let value: Int - - var body: some View { - Text("\(label) \(value)") - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.secondary) - .accessibilityLabel("\(label) \(value)") - } -} - private enum MeshMapperPalette { static let background = Color(red: 0.055, green: 0.075, blue: 0.105) - static let primary = Color(red: 0.12, green: 0.43, blue: 0.92) - static let secondary = Color(red: 0.30, green: 0.82, blue: 0.78) } extension MeshMapperActivityAttributes.ContentState { - fileprivate var hasActiveCountdown: Bool { - guard let phaseEndsAt else { return false } - return phaseEndsAt > Date() - } - fileprivate var connectionLabel: String { isConnected ? "Connected" : "Disconnected" } @@ -452,9 +487,9 @@ extension MeshMapperActivityAttributes.ContentState { fileprivate var repeaterEmptyLabel: String { switch phase { case "listening", "listening_discovery", "listening_trace": - return "No repeaters heard yet" + return "Nothing heard" default: - return "No repeaters heard in the last cycle" + return "Nothing heard in the last cycle" } } @@ -476,10 +511,13 @@ extension MeshMapperActivityAttributes.ContentState { } } - fileprivate var phaseColor: Color { + /// Phone-resolved outcome colour wins whenever one exists. System colours + /// are only a fallback before a session has produced a ping result. + fileprivate var displayColor: Color { + if let pingColor { return Color(pingColor) } switch phase { - case "sending", "discovering", "tracing": return MeshMapperPalette.primary - case "listening", "listening_discovery", "listening_trace": return MeshMapperPalette.secondary + case "sending", "discovering", "tracing": return .blue + case "listening", "listening_discovery", "listening_trace": return .teal case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return .cyan case "skipped", "waiting_for_gps", "paused_outside_zone": return .orange case "disconnected", "tx_blocked": return .red @@ -487,6 +525,54 @@ extension MeshMapperActivityAttributes.ContentState { default: return .white } } + + fileprivate func hasActiveCountdown(at date: Date) -> Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt > date + } + + fileprivate func deadlineLapsed(at date: Date) -> Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt <= date + } + + /// Fraction remaining in the current countdown, calculated locally so the + /// Live Activity does not need a state update every second. + fileprivate func phaseRemainingFraction(at date: Date) -> CGFloat? { + guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { + return nil + } + let remaining = phaseEndsAt.timeIntervalSince(date) + guard remaining > 0 else { return 0 } + return CGFloat(min(1, remaining / (Double(phaseDurationMs) / 1000))) + } +} + +extension MeshMapperActivityAttributes.HeardRepeater { + fileprivate var resolvedName: String? { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, trimmed.caseInsensitiveCompare(id) != .orderedSame else { + return nil + } + return trimmed + } + + fileprivate var accessibilitySummary: String { + let identity = resolvedName.map { "\(id), \($0)" } ?? id + return "\(identity), SNR \(snr.formattedSnr)" + } +} + +extension MeshMapperActivityAttributes.ResolvedColor { + fileprivate var swiftUIColor: Color { + Color(.sRGB, red: r, green: g, blue: b, opacity: 1) + } +} + +extension Color { + fileprivate init(_ resolved: MeshMapperActivityAttributes.ResolvedColor) { + self = resolved.swiftUIColor + } } extension Double { @@ -495,3 +581,193 @@ extension Double { return "\(sign)\(formatted(.number.precision(.fractionLength(1)))) dB" } } + +#if DEBUG +extension MeshMapperActivityAttributes.ContentState { + fileprivate static var previewRunningWithResponses: Self { + Self( + mode: "Hybrid", + phase: "listening", + phaseTitle: "Listening…", + phaseDetail: "Waiting for repeater echoes", + phaseEndsAt: Date().addingTimeInterval(42), + phaseDurationMs: 60_000, + pingColor: .init(r: 0.20, g: 0.84, b: 0.45), + isConnected: true, + zoneCode: "SEA", + txCount: 42, + rxCount: 318, + discoveryCount: 8, + traceCount: 0, + queueSize: 2, + repeaters: [ + .init( + id: "A61F", + name: "Capitol Hill", + snr: 12.4, + typeColor: .init(r: 0.20, g: 0.84, b: 0.45), + snrColor: .init(r: 0.34, g: 0.90, b: 0.44) + ), + .init( + id: "0B73", + name: "Lake Union", + snr: 2.1, + typeColor: .init(r: 0.22, g: 0.80, b: 0.78), + snrColor: .init(r: 0.96, g: 0.76, b: 0.22) + ), + .init( + id: "91CE", + name: nil, + snr: -8.7, + typeColor: .init(r: 0.64, g: 0.42, b: 0.94), + snrColor: .init(r: 0.94, g: 0.30, b: 0.28) + ), + ], + totalHeardCount: 5, + repeatersAreCurrent: true, + updatedAt: Date() + ) + } + + fileprivate static var previewRunningWithNoneHeard: Self { + Self( + mode: "Active", + phase: "listening", + phaseTitle: "Listening…", + phaseDetail: "Waiting for repeater echoes", + phaseEndsAt: Date().addingTimeInterval(25), + phaseDurationMs: 60_000, + pingColor: .init(r: 0.94, g: 0.28, b: 0.25), + isConnected: true, + zoneCode: "SEA", + txCount: 17, + rxCount: 3, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: Date() + ) + } + + fileprivate static var previewLapsedDeadline: Self { + var state = previewRunningWithResponses + state.phaseEndsAt = Date().addingTimeInterval(-5) + state.repeatersAreCurrent = false + return state + } +} + +private struct MeshMapperActivityPreview: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + ScrollView { + VStack(spacing: 18) { + MeshMapperLockScreenContent(state: state, isStale: false) + .background(MeshMapperPalette.background) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + + // The island's regions are composed by the system at runtime. Showing + // their shared bottom region here keeps its bar and rows inspectable + // without requiring a BLE-backed ActivityKit session. + MeshMapperIslandBottom(state: state) + .padding(12) + .foregroundStyle(.white) + .background(.black) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + + if #available(iOSApplicationExtension 18.0, *) { + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: 180) + .background(MeshMapperPalette.background) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + .padding() + } + .preferredColorScheme(.dark) + } +} + +/// Renders each layout to a PNG so the design can be reviewed without a live +/// session. +/// +/// A Live Activity needs ActivityKit to construct its context, and ActivityKit +/// needs a session, which needs BLE — which the simulator does not have. That +/// left three layouts reviewable only on a wrist. The content views take a +/// plain `ContentState`, so `ImageRenderer` can draw them headlessly instead. +/// +/// DEBUG-only, and reached solely from a launch argument. +enum MeshMapperActivityRenderHarness { + /// Widths are the real ones: a Live Activity on the lock screen spans about + /// 360 pt, the watch's small family about 184, and the island's shared bottom + /// region about 360. + @MainActor + static func writeRenders(to directory: URL) -> [String] { + let states: [(String, MeshMapperActivityAttributes.ContentState)] = [ + ("responses", .previewRunningWithResponses), + ("none-heard", .previewRunningWithNoneHeard), + ("lapsed", .previewLapsedDeadline), + ] + + var written: [String] = [] + for (name, state) in states { + written += [ + render( + MeshMapperLockScreenContent(state: state, isStale: false) + .frame(width: 360) + .background(MeshMapperPalette.background), + named: "lock-\(name)", in: directory + ), + render( + MeshMapperIslandBottom(state: state) + .padding(12) + .foregroundStyle(.white) + .frame(width: 360) + .background(.black), + named: "island-\(name)", in: directory + ), + ].compactMap { $0 } + + if #available(iOS 18.0, *) { + if let path = render( + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: 184) + .background(MeshMapperPalette.background), + named: "small-\(name)", in: directory + ) { + written.append(path) + } + } + } + return written + } + + @MainActor + private static func render( + _ view: some View, named name: String, in directory: URL + ) -> String? { + let renderer = ImageRenderer(content: view.environment(\.colorScheme, .dark)) + renderer.scale = 3 + guard let data = renderer.uiImage?.pngData() else { return nil } + let url = directory.appendingPathComponent("\(name).png") + try? data.write(to: url) + return url.path + } +} + +#Preview("Running — responses") { + MeshMapperActivityPreview(state: .previewRunningWithResponses) +} + +#Preview("Running — none heard") { + MeshMapperActivityPreview(state: .previewRunningWithNoneHeard) +} + +#Preview("Lapsed deadline") { + MeshMapperActivityPreview(state: .previewLapsedDeadline) +} +#endif diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index a806a083..16433871 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -98,7 +98,9 @@ final class LiveActivityManager { return MeshMapperActivityAttributes.HeardRepeater( id: id, name: boundedString(item["name"], maxLength: 36), - snr: min(max(snr, -200), 200) + snr: min(max(snr, -200), 200), + typeColor: resolvedColor(item["typeColor"]), + snrColor: resolvedColor(item["snrColor"]) ) } @@ -109,6 +111,8 @@ final class LiveActivityManager { phaseTitle: phaseTitle, phaseDetail: boundedString(payload["phaseDetail"], maxLength: 80), phaseEndsAt: date(payload["phaseEndsAt"]), + phaseDurationMs: positiveInteger(payload["phaseDurationMs"]), + pingColor: resolvedColor(payload["pingColor"]), isConnected: payload["isConnected"] as? Bool ?? false, zoneCode: boundedString(payload["zoneCode"], maxLength: 12), txCount: nonnegativeInteger(payload["txCount"]), @@ -200,6 +204,7 @@ final class LiveActivityManager { finalState.phaseTitle = "Session ended" finalState.phaseDetail = summary(for: finalState) finalState.phaseEndsAt = nil + finalState.phaseDurationMs = nil finalState.repeatersAreCurrent = false finalState.updatedAt = Date() @@ -255,4 +260,29 @@ final class LiveActivityManager { if let value = value as? Int { return max(value, 0) } return 0 } + + private func positiveInteger(_ value: Any?) -> Int? { + guard let value = finiteNumber(value), value > 0 else { return nil } + return Int(min(value, Double(24 * 60 * 60 * 1000))) + } + + /// Guarded like every other member that names the attributes type: the + /// deployment target predates ActivityKit, so mentioning it unguarded fails + /// to compile even in a helper that never runs on an older OS. + @available(iOS 16.2, *) + private func resolvedColor( + _ value: Any? + ) -> MeshMapperActivityAttributes.ResolvedColor? { + guard let value = value as? [String: Any], + let r = finiteNumber(value["r"]), + let g = finiteNumber(value["g"]), + let b = finiteNumber(value["b"]) + else { return nil } + + return MeshMapperActivityAttributes.ResolvedColor( + r: min(max(r, 0), 1), + g: min(max(g, 0), 1), + b: min(max(b, 0), 1) + ) + } } diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift index 25f16d2e..c42c0295 100644 --- a/ios/Shared/MeshMapperActivityAttributes.swift +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -5,15 +5,20 @@ import Foundation /// Keep the payload compact because ActivityKit limits attributes and state. @available(iOS 16.2, *) struct MeshMapperActivityAttributes: ActivityAttributes { + /// Same compact sRGB encoding as the watch payload. A separate Swift name + /// avoids coupling the widget target to the watch-only contract source. + struct ResolvedColor: Codable, Hashable { + let r: Double + let g: Double + let b: Double + } + struct HeardRepeater: Codable, Hashable, Identifiable { let id: String let name: String? let snr: Double - - var displayName: String { - let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? id : trimmed - } + let typeColor: ResolvedColor? + let snrColor: ResolvedColor? } struct ContentState: Codable, Hashable { @@ -22,6 +27,8 @@ struct MeshMapperActivityAttributes: ActivityAttributes { var phaseTitle: String var phaseDetail: String? var phaseEndsAt: Date? + var phaseDurationMs: Int? + var pingColor: ResolvedColor? var isConnected: Bool var zoneCode: String? var txCount: Int diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e97d1e6b..dfb53471 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -249,7 +249,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Live Activity repeater snapshot. Kept separate from the map overlay so the // system presentation cannot change existing in-app overlay behaviour. - List<({String repeaterId, double snr})> _liveActivityRepeaters = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _liveActivityRepeaters = []; int _liveActivityRepeaterTotalCount = 0; DateTime? _liveActivityRepeatersUpdatedAt; DateTime? _liveActivityRxUpdatedAt; @@ -632,7 +633,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } void _updateLiveActivityRepeaters( - Iterable<({String repeaterId, double snr})> current) { + Iterable<({String repeaterId, double snr})> current, + OverlayPingType type) { final bestSnr = {}; for (final repeater in current) { if (!repeater.snr.isFinite) continue; @@ -644,7 +646,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final sorted = bestSnr.entries - .map((entry) => (repeaterId: entry.key, snr: entry.value)) + .map((entry) => + (repeaterId: entry.key, snr: entry.value, type: type)) .toList() ..sort((a, b) => b.snr.compareTo(a.snr)); _liveActivityRepeaters = sorted.take(3).toList(growable: false); @@ -1225,6 +1228,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final phase = _resolveLiveActivityPhase(); final repeaterState = _buildLiveActivityRepeaters(); final now = DateTime.now(); + final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); + final pingColor = _resolveWatchPingColor(); final core = LiveActivitySnapshot( sessionId: _liveActivitySessionId ?? 'idle', @@ -1236,6 +1241,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseTitle: phase.title, phaseDetail: phase.detail, phaseEndsAt: phase.endsAt, + phaseDurationMs: phaseDurationMs, + pingColor: pingColor, isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, txCount: _pingStats.txCount, @@ -1253,9 +1260,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { core: core, geo: _buildWatchGeo(now), controls: _buildWatchControls(), - pingColor: _resolveWatchPingColor(), + pingColor: pingColor, cue: _watchCue, - phaseDurationMs: _phaseDurationMsFor(phase.endsAt), + phaseDurationMs: phaseDurationMs, updatedAt: now, ); } @@ -1567,6 +1574,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final phase = _resolveLiveActivityPhase(); final repeaterState = _buildLiveActivityRepeaters(); + final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); + final pingColor = _resolveWatchPingColor(); return LiveActivitySnapshot( sessionId: sessionId, @@ -1575,6 +1584,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseTitle: phase.title, phaseDetail: phase.detail, phaseEndsAt: phase.endsAt, + phaseDurationMs: phaseDurationMs, + pingColor: pingColor, isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, txCount: _pingStats.txCount, @@ -1810,6 +1821,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: id, name: _resolveRepeaterDisplayName(id), snr: repeater.snr, + typeColor: WatchGeoBuilder.overlayTypeColor(repeater.type), + snrColor: WatchGeoBuilder.snrColor(repeater.snr), ); } } @@ -1823,6 +1836,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: id, name: _resolveRepeaterDisplayName(id), snr: rx.snr, + typeColor: WatchGeoBuilder.overlayTypeColor(OverlayPingType.rx), + snrColor: WatchGeoBuilder.snrColor(rx.snr), ); } } @@ -3468,7 +3483,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: event.repeaterId.toUpperCase(), snr: event.snr!, )), - ]); + ], OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); _notifyMapThrottled(); @@ -3536,7 +3551,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: event.repeaterId.toUpperCase(), snr: event.snr!, )), - ]); + ], OverlayPingType.tx); _notifyMapThrottled(); } @@ -3582,7 +3597,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )) .toList(growable: false); _updateTopRepeaters(heardRepeaters, OverlayPingType.disc); - _updateLiveActivityRepeaters(heardRepeaters); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); _notifyMapThrottled(); }; @@ -3633,7 +3648,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: event.snr!, ))); } - _updateLiveActivityRepeaters(heardRepeaters); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.tx); final PingEventType eventType; if (directSuccess) { @@ -3680,7 +3695,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: node.localSnr, ))); } - _updateLiveActivityRepeaters(heardRepeaters); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); PingEventType eventType; if (success) { @@ -3742,6 +3757,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { result != null && result.success && traceSnr != null ? [(repeaterId: result.targetRepeaterId, snr: traceSnr)] : const [], + OverlayPingType.trace, ); recordPingEvent( diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index ec93f641..83d5e300 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -1,3 +1,5 @@ +import '../watch/watch_color.dart'; + /// High-level phase shown by the iOS Live Activity. enum LiveActivityPhase { active, @@ -49,16 +51,22 @@ class LiveActivityRepeater { required this.id, required this.snr, this.name, + this.typeColor, + this.snrColor, }); final String id; final String? name; final double snr; + final WatchColor? typeColor; + final WatchColor? snrColor; Map toMap() => { 'id': id, 'name': name, 'snr': snr.isFinite ? snr : 0.0, + if (typeColor != null) 'typeColor': typeColor!.toMap(), + if (snrColor != null) 'snrColor': snrColor!.toMap(), }; } @@ -81,6 +89,8 @@ class LiveActivitySnapshot { required this.updatedAt, this.phaseDetail, this.phaseEndsAt, + this.phaseDurationMs, + this.pingColor, this.zoneCode, }); @@ -90,6 +100,8 @@ class LiveActivitySnapshot { final String phaseTitle; final String? phaseDetail; final DateTime? phaseEndsAt; + final int? phaseDurationMs; + final WatchColor? pingColor; final bool isConnected; final String? zoneCode; final int txCount; @@ -109,6 +121,8 @@ class LiveActivitySnapshot { 'phaseTitle': phaseTitle, 'phaseDetail': phaseDetail, 'phaseEndsAt': phaseEndsAt?.millisecondsSinceEpoch, + if (phaseDurationMs != null) 'phaseDurationMs': phaseDurationMs, + if (pingColor != null) 'pingColor': pingColor!.toMap(), 'isConnected': isConnected, 'zoneCode': zoneCode, 'txCount': txCount, @@ -130,6 +144,10 @@ class LiveActivitySnapshot { phaseTitle, phaseDetail ?? '', phaseEndsAt?.millisecondsSinceEpoch ?? 0, + phaseDurationMs ?? 0, + pingColor?.r ?? '', + pingColor?.g ?? '', + pingColor?.b ?? '', isConnected, zoneCode ?? '', ].join('|'); diff --git a/lib/services/watch/watch_color.dart b/lib/services/watch/watch_color.dart new file mode 100644 index 00000000..6c62c4a2 --- /dev/null +++ b/lib/services/watch/watch_color.dart @@ -0,0 +1,29 @@ +import 'dart:ui' show Color; + +/// An sRGB colour resolved from the active colour-vision palette. +/// +/// Resolving on the phone is deliberate: Dart owns the palettes, while native +/// glance surfaces receive the same small RGB value and never need to duplicate +/// accessibility policy. +class WatchColor { + const WatchColor(this.r, this.g, this.b); + + factory WatchColor.fromColor(Color color) => WatchColor( + (color.r * 255.0).roundToDouble() / 255.0, + (color.g * 255.0).roundToDouble() / 255.0, + (color.b * 255.0).roundToDouble() / 255.0, + ); + + final double r; + final double g; + final double b; + + Map toMap() => {'r': r, 'g': g, 'b': b}; + + @override + bool operator ==(Object other) => + other is WatchColor && other.r == r && other.g == g && other.b == b; + + @override + int get hashCode => Object.hash(r, g, b); +} diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index a3ab903e..e14e18cd 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -160,6 +160,10 @@ class WatchGeoBuilder { OverlayPingType.rx => WatchColor.fromColor(PingColors.rx), }; + /// SNR quality colour shared by every native glance surface. + static WatchColor snrColor(double snr) => + WatchColor.fromColor(PingColors.snrColor(snr)); + /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the /// latest ping, then the current RX slot. /// @@ -230,7 +234,7 @@ class WatchGeoBuilder { snr: snr, at: at, distanceM: distance, - snrColor: WatchColor.fromColor(PingColors.snrColor(snr)), + snrColor: snrColor(snr), typeColor: overlayTypeColor(type), ); } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 4de1700a..34c2a3da 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -1,6 +1,7 @@ -import 'dart:ui' show Color; - import '../live_activity/live_activity_models.dart'; +import 'watch_color.dart'; + +export 'watch_color.dart'; /// Wire contract for the watchOS companion. /// @@ -36,33 +37,6 @@ class WatchWire { static const double minMoveMeters = 15.0; } -/// An sRGB colour resolved from the active colour-vision palette. -/// -/// Resolving on the phone is deliberate: Dart owns [PingColors], so the watch -/// renders accessibility palettes correctly without duplicating any of them. -class WatchColor { - const WatchColor(this.r, this.g, this.b); - - factory WatchColor.fromColor(Color color) => WatchColor( - (color.r * 255.0).roundToDouble() / 255.0, - (color.g * 255.0).roundToDouble() / 255.0, - (color.b * 255.0).roundToDouble() / 255.0, - ); - - final double r; - final double g; - final double b; - - Map toMap() => {'r': r, 'g': g, 'b': b}; - - @override - bool operator ==(Object other) => - other is WatchColor && other.r == r && other.g == g && other.b == b; - - @override - int get hashCode => Object.hash(r, g, b); -} - class WatchPosition { const WatchPosition({ required this.lat, From 224772d2a5a77926543010b846bbe8062f2473fb Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 10:51:01 -0700 Subject: [PATCH 192/360] Stop telegraphing a routine result: de-tint the bar and keyline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, on the render: "Don't tint the bar. That's too much of an error telegraph for a common case." He is right, and the mistake was mine. An unanswered ping is the normal outcome in thin coverage — it is the thing being mapped, not a fault. Filling the whole progress bar red made an ordinary result look like a system failure, and in doing so left nothing louder for actual errors. The bar and the Dynamic Island keyline now take a neutral accent, and the compact-leading glyph follows the phase rather than the last ping. The outcome stays legible where it belongs: the outcome dot on the small and minimal presentations, the coloured dot beside "Nothing heard", and the per-repeater type dots. None of those were made louder to compensate — the point is that a routine negative result should be available, not announced. The watch's own bar has the same tint and arguably the same problem, but that surface was signed off on hardware, so it is asked about rather than changed here. Verified by re-rendering all nine states through the harness. --- .../MeshMapperLiveActivity.swift | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index e959df4e..c3dc2151 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -36,7 +36,7 @@ struct MeshMapperLiveActivity: Widget { systemName: context.isStale ? "exclamationmark.triangle.fill" : context.state.phaseSymbol ) - .foregroundStyle(context.isStale ? Color.orange : context.state.displayColor) + .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) } compactTrailing: { MeshMapperCompactTrailing(state: context.state) @@ -51,7 +51,9 @@ struct MeshMapperLiveActivity: Widget { MeshMapperOutcomeDot(state: context.state, diameter: 9) } } - .keylineTint(context.state.displayColor) + // The keyline is container chrome, not a second outcome indicator. A + // failed ping is routine and should not turn the whole island red. + .keylineTint(MeshMapperPalette.accent) } .meshMapperSupplementalActivityFamilies() } @@ -203,7 +205,9 @@ private struct MeshMapperPhaseBar: View { ZStack(alignment: .leading) { Capsule().fill(.white.opacity(0.16)) Capsule() - .fill(state.displayColor) + // Progress says how much time remains. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .fill(MeshMapperPalette.accent) .frame( width: geometry.size.width * (state.phaseRemainingFraction(at: context.date) ?? 0) @@ -287,7 +291,7 @@ private struct MeshMapperRepeaterSummary: View { ForEach(state.repeaters.prefix(limit)) { repeater in MeshMapperRepeaterRow( repeater: repeater, - fallbackColor: state.displayColor, + fallbackColor: state.outcomeColor, showsName: showsNames, font: rowFont ) @@ -414,7 +418,7 @@ private struct MeshMapperOutcomeDot: View { var body: some View { Circle() - .fill(state.displayColor) + .fill(state.outcomeColor) .frame(width: diameter, height: diameter) .accessibilityLabel("Latest ping result") } @@ -461,6 +465,7 @@ private struct MeshMapperCountdown: View { private enum MeshMapperPalette { static let background = Color(red: 0.055, green: 0.075, blue: 0.105) + static let accent = Color.accentColor } extension MeshMapperActivityAttributes.ContentState { @@ -511,10 +516,16 @@ extension MeshMapperActivityAttributes.ContentState { } } - /// Phone-resolved outcome colour wins whenever one exists. System colours - /// are only a fallback before a session has produced a ping result. - fileprivate var displayColor: Color { + /// Phone-resolved outcome colour wins whenever one exists. The phase colour + /// is only a fallback before a session has produced a ping result. + fileprivate var outcomeColor: Color { if let pingColor { return Color(pingColor) } + return phaseColor + } + + /// Colour for an element that describes the current phase rather than the + /// result of the most recent ping. + fileprivate var phaseColor: Color { switch phase { case "sending", "discovering", "tracing": return .blue case "listening", "listening_discovery", "listening_trace": return .teal From a6d7e2dd50e2a06c2b036c2b695479aa94868037 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 11:01:52 -0700 Subject: [PATCH 193/360] Give the watch map its discovery and trace pings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The teal passive pings aren't being displayed as dots on the map or they are being rendered as purple rx dots." They were never displayable. `buildPings` took only `txPings` and `rxPings`, and `_buildWatchGeo` passed exactly those — so the builder's `pingColor('disc', …)` teal branch and its `trace` branch were unreachable code, and RX purple was the only non-TX colour the watch could draw. Green was fine: it is on the path that runs. The phone draws these from sources the watch was never handed: `discLogEntries`, where success is `discoveredNodes.isNotEmpty`, and `traceLogEntries`, which carries `success` outright. Both now reach the wire, with the phone's own success rules rather than new ones. The cap needed rethinking with four sources. It applied to a list built as "all TX, then all RX", which with discovery added could have kept sixty TX markers and dropped every teal one — the same bug wearing a different hat. Candidates are now sorted newest-first across all types before the cap, so history thins evenly instead of a category vanishing. Also mirrors the phone's multi-hop rule, which is the other half of what he saw: a TX answered only through multi-hop draws as an RX marker, because that is what it evidences — the packet returned, but not directly. `pathHops == null` marks a direct echo. Four types, four colour rules, and until now nothing asserted that a discovery ping ever reached the wire at all — which is exactly how a whole category went missing unnoticed. The tests do that now, including that the cap starves no single type. Not fixed here, and reported separately: RX-only repeaters never get the current-cycle ring, because `heardIds` is built from `_topRepeatersOverlay` alone and omits `_rxOverlaySlot`. That is the repeater pins, not the ping markers, so it does not belong in this change. --- lib/providers/app_state_provider.dart | 7 +- lib/services/watch/watch_geo_builder.dart | 44 +++- .../watch/watch_geo_builder_test.dart | 210 +++++++++++++++++- 3 files changed, 256 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index dfb53471..ad7f1f70 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1314,7 +1314,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchGeo( you: position, - pings: WatchGeoBuilder.buildPings(txPings: _txPings, rxPings: _rxPings), + pings: WatchGeoBuilder.buildPings( + txPings: _txPings, + rxPings: _rxPings, + discLogEntries: _discLogEntries, + traceLogEntries: _traceLogEntries, + ), repeaters: WatchGeoBuilder.buildRepeaters( repeaters: _repeaters, heardThisCycle: heardIds, diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index e14e18cd..aa68bda5 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import '../../models/log_entry.dart'; import '../../models/ping_data.dart'; import '../../models/repeater.dart'; import '../../providers/app_state_provider.dart' show OverlayPingType; @@ -68,11 +69,16 @@ class WatchGeoBuilder { /// Most recent pings, newest first, capped at [WatchWire.maxPings]. /// - /// TX and RX are merged into one time-ordered stream because the watch map - /// shows them together; a TX that nobody answered is drawn as a failure. + /// Every coverage source is merged before the recency cap is applied. + /// + /// Applying the cap to source-ordered batches could let a busy TX history + /// erase discovery or trace markers. Sorting the complete candidate set + /// first makes the wire carry the latest drive history regardless of type. static List buildPings({ required List txPings, required List rxPings, + required List discLogEntries, + required List traceLogEntries, int cap = WatchWire.maxPings, }) { final pings = []; @@ -80,12 +86,20 @@ class WatchGeoBuilder { for (var i = 0; i < txPings.length; i++) { final tx = txPings[i]; final success = tx.heardRepeaters.isNotEmpty; + final hasDirectEcho = + tx.heardRepeaters.any((repeater) => repeater.pathHops == null); + final hasMultiHopOnly = !hasDirectEcho && success; pings.add(WatchPing( id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', lat: tx.latitude, lon: tx.longitude, kind: 'tx', - color: pingColor('tx', success), + // The phone draws a multi-hop-only return as RX: it proves the packet + // came back through the mesh, but not that any repeater heard us + // directly. Keep the TX identity and mirror that evidence colour. + color: hasMultiHopOnly + ? pingColor('rx', true) + : pingColor('tx', success), at: tx.timestamp, )); } @@ -102,6 +116,30 @@ class WatchGeoBuilder { )); } + for (var i = 0; i < discLogEntries.length; i++) { + final entry = discLogEntries[i]; + pings.add(WatchPing( + id: 'disc-${entry.timestamp.millisecondsSinceEpoch}-$i', + lat: entry.latitude, + lon: entry.longitude, + kind: 'disc', + color: pingColor('disc', entry.discoveredNodes.isNotEmpty), + at: entry.timestamp, + )); + } + + for (var i = 0; i < traceLogEntries.length; i++) { + final entry = traceLogEntries[i]; + pings.add(WatchPing( + id: 'trace-${entry.timestamp.millisecondsSinceEpoch}-$i', + lat: entry.latitude, + lon: entry.longitude, + kind: 'trace', + color: pingColor('trace', entry.success), + at: entry.timestamp, + )); + } + pings.sort((a, b) => b.at.compareTo(a.at)); if (pings.length <= cap) return pings; return pings.sublist(0, cap); diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index c6980c4f..e5822cb5 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/log_entry.dart'; import 'package:mesh_mapper/models/ping_data.dart'; import 'package:mesh_mapper/models/repeater.dart'; import 'package:mesh_mapper/providers/app_state_provider.dart' show OverlayPingType; @@ -24,6 +25,31 @@ RxPing _rx(DateTime at) => RxPing( rssi: -90, ); +DiscLogEntry _disc(DateTime at, {required bool discovered}) => DiscLogEntry( + timestamp: at, + latitude: 47.62, + longitude: -122.32, + discoveredNodes: discovered + ? [ + DiscoveredNodeEntry( + repeaterId: '7a', + nodeType: 'REPEATER', + localSnr: 7, + localRssi: -88, + remoteSnr: 5, + ), + ] + : [], + ); + +TraceLogEntry _trace(DateTime at, {required bool success}) => TraceLogEntry( + timestamp: at, + latitude: 47.63, + longitude: -122.33, + targetRepeaterId: '8b', + success: success, + ); + Repeater _repeater({ required String id, required double lat, @@ -54,7 +80,12 @@ void main() { final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); final rx = List.generate(40, (i) => _rx(base.add(Duration(seconds: i)))); - final pings = WatchGeoBuilder.buildPings(txPings: tx, rxPings: rx); + final pings = WatchGeoBuilder.buildPings( + txPings: tx, + rxPings: rx, + discLogEntries: const [], + traceLogEntries: const [], + ); expect(pings.length, WatchWire.maxPings); for (var i = 1; i < pings.length; i++) { @@ -77,6 +108,8 @@ void main() { final pings = WatchGeoBuilder.buildPings( txPings: [answered, ignored], rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], ); final byTime = {for (final p in pings) p.at: p}; @@ -89,6 +122,181 @@ void main() { WatchColor.fromColor(PingColors.txFail), ); }); + + test('multi-hop-only TX is RX-coloured unless any echo is direct', () { + final multiHopOnly = _tx( + DateTime(2026, 8, 12, 10, 2), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + HeardRepeater( + repeaterId: '5f', + snr: 3, + pathHops: ['8b', '5f'], + ), + ], + ); + final includesDirect = _tx( + DateTime(2026, 8, 12, 10, 1), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + HeardRepeater(repeaterId: '5f', snr: 3), + ], + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: [multiHopOnly, includesDirect], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[multiHopOnly.timestamp]!.kind, 'tx'); + expect( + byTime[multiHopOnly.timestamp]!.color, + WatchColor.fromColor(PingColors.rx), + ); + expect( + byTime[includesDirect.timestamp]!.color, + WatchColor.fromColor(PingColors.txSuccess), + ); + }); + + test('discovery markers use response success and failure colours', () { + final answered = _disc( + DateTime(2026, 8, 12, 10, 1), + discovered: true, + ); + final unanswered = _disc( + DateTime(2026, 8, 12, 10), + discovered: false, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: [answered, unanswered], + traceLogEntries: const [], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[answered.timestamp]!.kind, 'disc'); + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.discSuccess), + ); + expect( + byTime[unanswered.timestamp]!.color, + WatchColor.fromColor(PingColors.discFail), + ); + }); + + test('trace markers use the trace result colours', () { + final answered = _trace( + DateTime(2026, 8, 12, 10, 1), + success: true, + ); + final unanswered = _trace( + DateTime(2026, 8, 12, 10), + success: false, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: [answered, unanswered], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[answered.timestamp]!.kind, 'trace'); + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.traceSuccess), + ); + expect( + byTime[unanswered.timestamp]!.color, + WatchColor.fromColor(PingColors.noResponse), + ); + }); + + test('the cap keeps the newest markers across mixed sources', () { + final base = DateTime(2026, 8, 12, 10); + final oldTx = List.generate( + 60, + (i) => _tx(base.subtract(Duration(minutes: i + 1))), + ); + final latestTx = _tx(base.add(const Duration(seconds: 1))); + final latestRx = _rx(base.add(const Duration(seconds: 2))); + final latestDisc = _disc( + base.add(const Duration(seconds: 3)), + discovered: true, + ); + final latestTrace = _trace( + base.add(const Duration(seconds: 4)), + success: true, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: [...oldTx, latestTx], + rxPings: [latestRx], + discLogEntries: [latestDisc], + traceLogEntries: [latestTrace], + cap: 4, + ); + + expect(pings.map((ping) => ping.kind), ['trace', 'disc', 'rx', 'tx']); + expect(pings.map((ping) => ping.at), [ + latestTrace.timestamp, + latestDisc.timestamp, + latestRx.timestamp, + latestTx.timestamp, + ]); + }); + + test('all four marker types survive a realistic mixed history', () { + final base = DateTime(2026, 8, 12, 10); + + final pings = WatchGeoBuilder.buildPings( + txPings: List.generate( + 12, + (i) => _tx(base.add(Duration(minutes: i * 4))), + ), + rxPings: List.generate( + 8, + (i) => _rx(base.add(Duration(minutes: i * 6 + 1))), + ), + discLogEntries: List.generate( + 6, + (i) => _disc( + base.add(Duration(minutes: i * 8 + 2)), + discovered: i.isEven, + ), + ), + traceLogEntries: List.generate( + 4, + (i) => _trace( + base.add(Duration(minutes: i * 12 + 3)), + success: i.isEven, + ), + ), + ); + + expect(pings.map((ping) => ping.kind).toSet(), { + 'tx', + 'rx', + 'disc', + 'trace', + }); + }); }); group('buildRepeaters', () { From 6a8ba00731d4de53c77b724bbf478aea84116fc8 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 11:38:14 -0700 Subject: [PATCH 194/360] Stop drawing a map at a wrist nobody is looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 71-minute walk cost ~40% of Adam's watch battery — about 34%/hour, which makes the app useless for the long drive it exists for. Two patterns account for the obvious waste, and neither was measured here: watch power cannot be instrumented from this machine, so these are the known-expensive things removed, not a proven culprit. A real walk is the only test. **Always-On was unhandled.** Phase 7 planned it and it was never built, so for most of that walk — wrist down, app frontmost — watchOS was rendering a live MapKit view with annotations. MapKit is the most power-hungry thing on the device, and none of it is legible at reduced luminance. The dimmed state now removes that subtree entirely rather than covering it, and stops driving the camera: no recentring, no corrections, no animations until full luminance returns. **A 1 Hz TimelineView redrew the panel over the live map** for the whole session, compositing translucent material every second. Most of it bought nothing: `Text(timerInterval:)` already updates itself natively, and a depleting bar can be one linear animation over the remaining phase rather than thousands of view updates. The Live Activity's bar had the same timeline and the same fix. No 1 Hz timeline remains anywhere. Always-On also needed its own layout, which only became apparent once it could be seen. Reusing the map's overlay panel left a small card pinned to the bottom of a black screen at map-overlay type size — and then the phase title truncated to "List…" on a 40 mm, hiding the one thing a dimmed glance is for. The dimmed view now spends the space it actually has: title at 18 pt wrapping rather than truncating, countdown at 24 pt beneath it, Top Heard full width below. The progress bar is gone from that state — beside an explicit countdown it was duplicated information and another compositing pass. The countdown reads "<1 min" / "3 min" there, because Always-On updates about once a minute and a seconds figure would be silently up to a minute wrong. `MeshMapperForceDimmed` is kept, not scaffolding: Always-On cannot be entered in the simulator and otherwise needs a wrist-down device, so without it this surface goes back to being unreviewable — which is how it shipped bottom-pinned and truncated in the first place. --- .../MeshMapperLiveActivity.swift | 187 ++++++--- ios/MeshMapperWatch/MapPage.swift | 368 ++++++++++++++---- 2 files changed, 426 insertions(+), 129 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index c3dc2151..b2bf840a 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -199,43 +199,98 @@ private struct MeshMapperPhaseBar: View { let countdownFont: Font let countdownWidth: CGFloat + @State private var remainingFraction: CGFloat + @State private var deadlineLapsed: Bool + + init( + state: MeshMapperActivityAttributes.ContentState, + height: CGFloat, + titleFont: Font, + countdownFont: Font, + countdownWidth: CGFloat + ) { + self.state = state + self.height = height + self.titleFont = titleFont + self.countdownFont = countdownFont + self.countdownWidth = countdownWidth + + let now = Date() + _remainingFraction = State( + initialValue: state.phaseRemainingFraction(at: now) ?? 0 + ) + _deadlineLapsed = State( + initialValue: state.phaseEndsAt.map { $0 <= now } ?? false + ) + } + var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - // Progress says how much time remains. Outcome has quieter, - // dedicated dots elsewhere and must not recolour the whole track. - .fill(MeshMapperPalette.accent) - .frame( - width: geometry.size.width - * (state.phaseRemainingFraction(at: context.date) ?? 0) - ) - } - .overlay { - HStack { - Text(state.phaseTitle) - .font(titleFont) - .foregroundStyle( - .white.opacity(state.deadlineLapsed(at: context.date) ? 0.45 : 1) - ) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 4) - MeshMapperCountdown( - state: state, - at: context.date, - font: countdownFont - ) - .frame(width: countdownWidth, alignment: .trailing) - } - .padding(.horizontal, 7) - .shadow(color: .black.opacity(0.7), radius: 1.5) + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + // Progress says how much time remains. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .fill(MeshMapperPalette.accent) + .frame(width: geometry.size.width * remainingFraction) + } + .overlay { + HStack { + Text(state.phaseTitle) + .font(titleFont) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + MeshMapperCountdown( + state: state, + isActive: !deadlineLapsed, + font: countdownFont + ) + .frame(width: countdownWidth, alignment: .trailing) } + .padding(.horizontal, 7) + .shadow(color: .black.opacity(0.7), radius: 1.5) } } .frame(height: height) + .task(id: state.phaseAnimationKey) { + await runPhaseAnimation() + } + } + + @MainActor + private func runPhaseAnimation() async { + let now = Date() + let fraction = state.phaseRemainingFraction(at: now) ?? 0 + let lapsed = state.phaseEndsAt.map { $0 <= now } ?? false + + // ActivityKit may replace state midway through a phase. Snap to the + // absolute fraction before starting one compositor animation, so no timer + // tick or stale previous endpoint can distort the new bar. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + remainingFraction = fraction + deadlineLapsed = lapsed + } + + guard let endsAt = state.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } + + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true } } @@ -427,12 +482,22 @@ private struct MeshMapperOutcomeDot: View { private struct MeshMapperCompactTrailing: View { let state: MeshMapperActivityAttributes.ContentState + @State private var deadlineLapsed: Bool + + init(state: MeshMapperActivityAttributes.ContentState) { + self.state = state + let now = Date() + _deadlineLapsed = State( + initialValue: state.phaseEndsAt.map { $0 <= now } ?? false + ) + } + var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - if state.hasActiveCountdown(at: context.date) { + Group { + if !deadlineLapsed, state.activeCountdownRange != nil { MeshMapperCountdown( state: state, - at: context.date, + isActive: true, font: .caption2.monospacedDigit().weight(.bold) ) .frame(minWidth: 28) @@ -445,17 +510,31 @@ private struct MeshMapperCompactTrailing: View { MeshMapperOutcomeDot(state: state, diameter: 8) } } + .task(id: state.phaseAnimationKey) { + let now = Date() + deadlineLapsed = state.phaseEndsAt.map { $0 <= now } ?? false + guard let endsAt = state.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true + } } } private struct MeshMapperCountdown: View { let state: MeshMapperActivityAttributes.ContentState - let at: Date + let isActive: Bool let font: Font var body: some View { - if let end = state.phaseEndsAt, end > at { - Text(timerInterval: at...end, countsDown: true, showsHours: false) + if isActive, let range = state.activeCountdownRange { + Text(timerInterval: range, countsDown: true, showsHours: false) .font(font) .lineLimit(1) .accessibilityLabel("Time remaining") @@ -468,7 +547,29 @@ private enum MeshMapperPalette { static let accent = Color.accentColor } +private struct MeshMapperPhaseAnimationKey: Hashable { + let phase: String + let title: String + let endsAt: Date? + let durationMs: Int? +} + extension MeshMapperActivityAttributes.ContentState { + fileprivate var phaseAnimationKey: MeshMapperPhaseAnimationKey { + MeshMapperPhaseAnimationKey( + phase: phase, + title: phaseTitle, + endsAt: phaseEndsAt, + durationMs: phaseDurationMs + ) + } + + fileprivate var activeCountdownRange: ClosedRange? { + let now = Date() + guard let phaseEndsAt, phaseEndsAt > now else { return nil } + return now...phaseEndsAt + } + fileprivate var connectionLabel: String { isConnected ? "Connected" : "Disconnected" } @@ -537,16 +638,6 @@ extension MeshMapperActivityAttributes.ContentState { } } - fileprivate func hasActiveCountdown(at date: Date) -> Bool { - guard let phaseEndsAt else { return false } - return phaseEndsAt > date - } - - fileprivate func deadlineLapsed(at date: Date) -> Bool { - guard let phaseEndsAt else { return false } - return phaseEndsAt <= date - } - /// Fraction remaining in the current countdown, calculated locally so the /// Live Activity does not need a state update every second. fileprivate func phaseRemainingFraction(at date: Date) -> CGFloat? { diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 57be6558..a31aec03 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -12,6 +12,17 @@ import WatchKit struct MapPage: View { @Environment(WatchSessionClient.self) private var client @Environment(WatchSettings.self) private var settings + @Environment(\.isLuminanceReduced) private var environmentLuminanceReduced + + /// Always-On cannot be entered in the simulator, and on hardware it needs a + /// wrist-down device — so the dimmed layout was unreviewable. This lets it be + /// captured headlessly, like the sample-data affordances. + private var isLuminanceReduced: Bool { + #if DEBUG + if UserDefaults.standard.bool(forKey: "MeshMapperForceDimmed") { return true } + #endif + return environmentLuminanceReduced + } @State private var camera: MapCameraPosition = .automatic @@ -136,32 +147,17 @@ struct MapPage: View { private func content(_ proxy: MapProxy) -> some View { ZStack { - map(proxy) - - // One panel carrying phase and Top Heard. - // - // Earlier versions floated the countdown in the opposite corner and drew - // full-width into the safe areas. On real hardware both got clipped by - // the display curvature — the simulator renders a flat rectangle and - // never shows it. This panel enters only the bottom safe area, and only - // after the measured curvature has bought enough horizontal clearance. - VStack(spacing: 0) { - HStack { - Spacer(minLength: 0) - recenterButton(proxy) - } - Spacer(minLength: 0) - statusPanel - .background( - GeometryReader { geo in - Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) - } - ) + if isLuminanceReduced { + // Always-On spends most of a long session with the wrist down. A live + // MapKit renderer and its annotations buy no useful glance information + // at reduced luminance, so remove that subtree rather than merely + // covering it. + Color.black.ignoresSafeArea() + dimmedStatus + } else { + map(proxy) + mapOverlay(proxy) } - .padding(.horizontal, panelHorizontalInset) - .padding(.top, 2) - .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap) - .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } .background( GeometryReader { geo in @@ -197,6 +193,12 @@ struct MapPage: View { .onChange(of: followSuspendedUntil) { _, until in if until == nil { recenterIfFollowing(proxy) } } + .onChange(of: isLuminanceReduced) { _, reduced in + // Camera work is forbidden while dimmed. Returning to full luminance is + // itself a state change, so following can resume immediately even if the + // phone has not produced another GPS fix yet. + if !reduced { recenterIfFollowing(proxy) } + } .onAppear { recenterIfFollowing(proxy) #if DEBUG @@ -209,6 +211,140 @@ struct MapPage: View { } } + /// Map chrome remains an overlay so its measured frame can place the fix in + /// the visible band above it. Always-On has its own hierarchy and therefore + /// cannot accidentally inherit this bottom-pinned card again. + private func mapOverlay(_ proxy: MapProxy) -> some View { + VStack(spacing: 0) { + HStack { + Spacer(minLength: 0) + recenterButton(proxy) + } + Spacer(minLength: 0) + statusPanel + .background( + GeometryReader { geo in + Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) + } + ) + } + // Earlier full-width versions clipped on curved hardware even though the + // simulator looked sound. Enter the bottom safe area only after the circle + // model has bought the matching horizontal clearance. + .padding(.horizontal, panelHorizontalInset) + .padding(.top, 2) + .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap) + .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) + } + + /// A full-screen glance surface for Always-On, not a map card without a map. + /// + /// Its content remains inside the system safe area and also keeps the + /// hardware-tested horizontal clearance. That is intentionally redundant at + /// the bottom corners: two earlier layouts passed in the simulator and + /// clipped on glass, while spare black pixels cost no compositing work. + private var dimmedStatus: some View { + VStack(alignment: .leading, spacing: 0) { + if let snapshot { + dimmedPhase(snapshot) + } else { + Text("Waiting for iPhone") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + + Spacer(minLength: 6) + + VStack(alignment: .leading, spacing: 3) { + Text("TOP HEARD") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.55)) + + if heard.isEmpty { + Text("Nothing heard") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.45)) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(Array(heard.prefix(4))) { node in + dimmedHeardRow(node) + } + } + } + } + .padding(.horizontal, panelHorizontalInset) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .dynamicTypeSize(.small ... .large) + .opacity(client.isStale ? 0.5 : 1.0) + // Snapshot replacement may arrive with an animated transaction from an + // ancestor. Always-On changes in discrete steps; it never interpolates. + .transaction { $0.animation = nil } + } + + /// The two readings an Always-On glance exists to answer, each with its own + /// line instead of competing inside the map overlay's narrow bar. + /// + /// A seconds figure can be nearly a minute wrong while watchOS throttles an + /// Always-On screen. Showing whole minutes (or “<1 min”) makes that cadence + /// honest, while the isolated minute schedule avoids waking the rest of the + /// hierarchy. A static progress fill would repeat that number less precisely + /// and spend both pixels and compositing work, so the dimmed surface omits it. + private func dimmedPhase(_ snapshot: WatchSnapshot) -> some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + let lapsed = snapshot.phaseEndsAt.map { $0 <= context.date } ?? false + + VStack(alignment: .leading, spacing: 2) { + // Wrapping is intentional. The longest real phase names need two lines + // on 40 mm, and preserving every word matters more than uniform height. + Text(snapshot.phaseTitle) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white.opacity(lapsed ? 0.45 : 1)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if let countdown = dimmedCountdown(snapshot, at: context.date) { + Text(countdown) + .font(.system(size: 24, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private func dimmedCountdown(_ snapshot: WatchSnapshot, at date: Date) -> String? { + guard let endsAt = snapshot.phaseEndsAt else { return nil } + let remaining = endsAt.timeIntervalSince(date) + guard remaining > 0 else { return nil } + if remaining < 60 { return "<1 min" } + return "\(Int(ceil(remaining / 60))) min" + } + + /// One full-width row is affordable without a basemap and gives the settled + /// type dot, hex identity and quality figure enough size for arm's-length + /// reading even when a six-character hash forces the overlay into one column. + private func dimmedHeardRow(_ node: WatchHeardNode) -> some View { + HStack(spacing: 5) { + Circle() + .fill(Color(node.typeColor)) + .frame(width: 8, height: 8) + Text(node.id) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + Spacer(minLength: 6) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(node.snrColor.map(Color.init) ?? .white) + .frame(width: 42, alignment: .trailing) + } + } + .lineLimit(1) + } + private func latchBottomSafeAreaInset(_ inset: CGFloat) { guard bottomSafeAreaInset == 0, inset > 0 else { return } bottomSafeAreaInset = inset @@ -252,9 +388,8 @@ struct MapPage: View { .padding(.horizontal, 8) .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) - // Blurred material rather than flat translucency: a 70% black panel - // lets bright basemap labels bleed through and fight the SNR digits. - // Blurring the map behind the panel removes the competing detail. + // Bright basemap labels bleed through flat translucency and fight the + // SNR digits; material removes that competing detail on the map only. .background(.ultraThinMaterial, in: .rect(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) @@ -327,61 +462,11 @@ struct MapPage: View { .foregroundStyle(.orange) .lineLimit(1) } else if let snapshot { - TimelineView(.periodic(from: .now, by: 1)) { context in - track(snapshot, at: context.date) - .overlay { - HStack { - phaseTitle(snapshot, at: context.date) - Spacer(minLength: 4) - countdown(snapshot, at: context.date) - } - // The fill slides under both labels, so a shadow keeps them - // readable against the filled and empty parts of the track. - .shadow(color: .black.opacity(0.7), radius: 1.5) - .padding(.horizontal, 6) - } - } + WatchPhaseBar(snapshot: snapshot) .frame(height: 15) } } - /// The depleting track. Greedy on purpose: the labels overlay it, leaving the - /// whole panel width available to show phase progress. - private func track(_ snapshot: WatchSnapshot, at date: Date) -> some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame(width: geo.size.width * (snapshot.phaseRemainingFraction(at: date) ?? 0)) - } - } - } - - private func phaseTitle(_ snapshot: WatchSnapshot, at date: Date) -> some View { - // A missing deadline describes a durable state. A passed one describes - // only what the phone last reported, so dimming avoids presenting it as a - // live claim while still preserving the useful last-known phase. - let deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= date } ?? false - return Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) - .lineLimit(1) - .truncationMode(.tail) - } - - /// Fixed width because `Text(timerInterval:)` otherwise reserves room for the - /// widest value it might ever show, which would starve the track. - @ViewBuilder - private func countdown(_ snapshot: WatchSnapshot, at date: Date) -> some View { - if let endsAt = snapshot.phaseEndsAt, endsAt > date { - Text(timerInterval: date...endsAt, countsDown: true) - .font(.system(size: 11, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .frame(width: 38, alignment: .trailing) - } - } - /// Largest size allowed by the existing hash-length ladder. Width fitting /// may make it smaller, but never larger than the familiar phone treatment. private var rowFontSizeCap: CGFloat { @@ -509,7 +594,7 @@ struct MapPage: View { // MARK: - Camera private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { - guard force || isFollowing, let fix else { return } + guard !isLuminanceReduced, force || isFollowing, let fix else { return } let center = centerPlacing(fix, proxy: proxy) let isInitialPlacement = programmaticCenter == nil programmaticCenter = center @@ -563,7 +648,7 @@ struct MapPage: View { /// The deadband is what stops it: each pass lands within a couple of points, /// the next sees no error worth fixing, and it settles. private func correctPlacement(_ proxy: MapProxy) { - guard isFollowing, let fix, let targetPoint, + guard !isLuminanceReduced, isFollowing, let fix, let targetPoint, let point = proxy.convert(fix, to: .global) else { return } guard abs(point.y - targetPoint.y) > 6 else { return } @@ -668,6 +753,127 @@ struct MapPage: View { } } +/// A phase-scoped progress animation rather than a one-second render clock. +/// +/// `Text(timerInterval:)` owns its countdown without invalidating this view. +/// The fill is set once from the absolute deadline and animated to zero by the +/// compositor; one sleeping task wakes at the deadline solely to retire the +/// countdown and dim a claim the phone has not refreshed. +private struct WatchPhaseBar: View { + let snapshot: WatchSnapshot + + @State private var remainingFraction: CGFloat + @State private var deadlineLapsed: Bool + + init(snapshot: WatchSnapshot) { + self.snapshot = snapshot + let now = Date() + _remainingFraction = State( + initialValue: CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) + ) + _deadlineLapsed = State( + initialValue: snapshot.phaseEndsAt.map { $0 <= now } ?? false + ) + } + + private var phaseKey: PhaseAnimationKey { + PhaseAnimationKey( + phase: snapshot.phase, + title: snapshot.phaseTitle, + endsAtMs: snapshot.phaseEndsAtMs, + durationMs: snapshot.phaseDurationMs + ) + } + + /// Build the native timer's range at render time so a deadline crossing + /// between the sleeping task and a body update can never form `now...past`. + private var activeCountdownRange: ClosedRange? { + let now = Date() + guard !deadlineLapsed, let endsAt = snapshot.phaseEndsAt, endsAt > now else { + return nil + } + return now...endsAt + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame(width: geo.size.width * remainingFraction) + } + .overlay { + HStack { + // A missing deadline describes a durable state. A passed one is only + // the phone's last claim, so keep it visible but no longer assert it. + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if let range = activeCountdownRange { + // Fixed width because the native timer otherwise reserves room for + // its widest possible value and starves the title. + Text(timerInterval: range, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 38, alignment: .trailing) + } + } + // The fill slides under both labels, so a shadow keeps them readable + // against the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.horizontal, 6) + } + } + .task(id: phaseKey) { + await runPhaseAnimation() + } + } + + @MainActor + private func runPhaseAnimation() async { + let now = Date() + let fraction = CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) + let lapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false + + // A replacement phase must start at its true current fraction, not animate + // from the previous phase's endpoint before beginning its own drain. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + remainingFraction = fraction + deadlineLapsed = lapsed + } + + guard let endsAt = snapshot.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } + + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true + } + + private struct PhaseAnimationKey: Hashable { + let phase: String + let title: String + let endsAtMs: Double? + let durationMs: Int? + } +} + extension Comparable { fileprivate func clamped(to limits: ClosedRange) -> Self { min(max(self, limits.lowerBound), limits.upperBound) From ad826e7760ee249ff60dcd55da8e604edbed832a Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 12:11:44 -0700 Subject: [PATCH 195/360] Key the drain to its deadline, not its caption; widen the panel to be glanceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from real-device use. **The bar stuttered in "ping skipped" mode.** `phaseKey` included the phase and its title, and `.task(id:)` restarted the drain whenever either changed. In skip mode the phase flips between waiting/"Next ping" and skipped/"Ping skipped" while the *same* auto-ping timer runs to an unchanged deadline — so each flip cancelled the animation, snapped the fill back to its true fraction and started again. The bar was reporting a change of wording as a change in time. It keys on the deadline and duration alone now; the title is a caption over the drain, not part of it. **A bar that cannot be refreshed should not pretend to move.** On the iPhone's always-on lock screen, refreshing about once a minute, the animated fill rendered frozen mid-drain — which reads as a stalled session, worse than showing nothing. At reduced luminance the Live Activity now draws the track alone and lets the countdown carry the state, a coarse number being honest where a stopped bar is not. Adam's rule, worth keeping: an element implying continuous motion must not be drawn where the refresh rate cannot deliver it. **The panel's text was too small to glance at.** Raising it buys width, because the curvature clearance needed at a given height falls as the panel moves up the curve, and width buys type size through the existing solver. This re-couples placement to clearance, which `bf6f7ef` deliberately decoupled — that was right when he wanted the panel narrower, and this is right now that he wants it legible. His call both times. 46 mm gap 18.9 inset 8.3 panel 191 font 11.0 45 mm gap 18.4 inset 8.2 panel 182 font 11.0 40 mm gap 10.0 inset 6.3 panel 149 font 8.7 The ladder cap rose a point too, since width alone was no longer the binding constraint. Mirroring the phone's `RepeaterIdChip` sizes was the original reason for it, but a watch is read at arm's length in motion. Worth flagging: the panel is now nearly as wide as it was before he asked for it narrowed, though sitting much higher. The clearance arithmetic says that is safe — at 19 pt up the curve the corner needs only ~4 pt of inset — but the hardware confirmation was taken at the previous geometry, so this specific combination is unverified on glass. --- .../MeshMapperLiveActivity.swift | 41 +++++++++---- ios/MeshMapperWatch/MapPage.swift | 57 +++++++++---------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index b2bf840a..65e2694f 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -193,6 +193,8 @@ private struct MeshMapperSmallActivityContent: View { /// updates. The title and countdown ride over the fill so neither consumes /// track width, and their shadow keeps them legible on both halves. private struct MeshMapperPhaseBar: View { + @Environment(\.isLuminanceReduced) private var isLuminanceReduced + let state: MeshMapperActivityAttributes.ContentState let height: CGFloat let titleFont: Font @@ -228,11 +230,13 @@ private struct MeshMapperPhaseBar: View { GeometryReader { geometry in ZStack(alignment: .leading) { Capsule().fill(.white.opacity(0.16)) - Capsule() - // Progress says how much time remains. Outcome has quieter, - // dedicated dots elsewhere and must not recolour the whole track. - .fill(MeshMapperPalette.accent) - .frame(width: geometry.size.width * remainingFraction) + if !isLuminanceReduced { + Capsule() + // Progress says how much time remains. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .fill(MeshMapperPalette.accent) + .frame(width: geometry.size.width * remainingFraction) + } } .overlay { HStack { @@ -254,13 +258,24 @@ private struct MeshMapperPhaseBar: View { } } .frame(height: height) - .task(id: state.phaseAnimationKey) { - await runPhaseAnimation() + // An element that implies continuous motion must not be drawn where the + // refresh rate cannot deliver it. Luminance participates only to stop and + // resume rendering; among payload fields, deadline and duration alone + // identify the drain, so a caption change cannot restart it. + .task(id: animationTaskKey) { + await runPhaseAnimation(animateFill: !isLuminanceReduced) } } + private var animationTaskKey: MeshMapperPhaseAnimationTaskKey { + MeshMapperPhaseAnimationTaskKey( + drain: state.phaseAnimationKey, + canRenderContinuously: !isLuminanceReduced + ) + } + @MainActor - private func runPhaseAnimation() async { + private func runPhaseAnimation(animateFill: Bool) async { let now = Date() let fraction = state.phaseRemainingFraction(at: now) ?? 0 let lapsed = state.phaseEndsAt.map { $0 <= now } ?? false @@ -275,6 +290,7 @@ private struct MeshMapperPhaseBar: View { deadlineLapsed = lapsed } + guard animateFill else { return } guard let endsAt = state.phaseEndsAt else { return } let remaining = endsAt.timeIntervalSince(now) guard remaining > 0 else { return } @@ -548,17 +564,18 @@ private enum MeshMapperPalette { } private struct MeshMapperPhaseAnimationKey: Hashable { - let phase: String - let title: String let endsAt: Date? let durationMs: Int? } +private struct MeshMapperPhaseAnimationTaskKey: Hashable { + let drain: MeshMapperPhaseAnimationKey + let canRenderContinuously: Bool +} + extension MeshMapperActivityAttributes.ContentState { fileprivate var phaseAnimationKey: MeshMapperPhaseAnimationKey { MeshMapperPhaseAnimationKey( - phase: phase, - title: phaseTitle, endsAt: phaseEndsAt, durationMs: phaseDurationMs ) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index a31aec03..b4419ebb 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -80,17 +80,12 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - /// Both gaps scale with the estimated corner radius, putting every watch at - /// the same relative positions on its curve. The 4/19 and 8/19 ratios are - /// calibrated from the 40 mm watch Adam signed off: at R=19 they reproduce - /// its current clearance and placement by construction. Changing either - /// constant therefore changes the one hardware size already known-good. - private static let curveClearanceGapRatio: CGFloat = 4.0 / 19.0 - private static let panelBottomGapRatio: CGFloat = 8.0 / 19.0 - - private var curveClearanceGap: CGFloat { - bottomSafeAreaInset * Self.curveClearanceGapRatio - } + /// The placement scales with the estimated corner radius, putting every + /// watch at the same relative position on its curve. Ten points at R=19 is a + /// modest lift from the hardware-approved eight; unlike the earlier + /// deliberately narrow treatment, clearance is now evaluated at that real + /// position so the lift buys the glanceable width the wearer requested. + private static let panelBottomGapRatio: CGFloat = 10.0 / 19.0 private var panelBottomGap: CGFloat { bottomSafeAreaInset * Self.panelBottomGapRatio @@ -107,14 +102,14 @@ struct MapPage: View { /// watchOS exposes no screen corner radius, but its bottom safe-area inset is /// the clearance a full-width element needs at zero horizontal inset, making /// it a useful estimate of that radius. The circle/chord intersection gives - /// the inset at the scaled clearance gap. That radius estimate is a lower - /// bound on the glass curvature, so four extra points are cheap insurance - /// against another hardware clip. The rectangle test is conservative in the - /// other direction: the panel's own 12 pt radius pulls its visible corners - /// inward from the square corners protected by this equation. + /// the inset at the panel's actual placement gap. That radius estimate is a + /// lower bound on the glass curvature, so four extra points are cheap + /// insurance against another hardware clip. The rectangle test is + /// conservative in the other direction: the panel's own 12 pt radius pulls + /// its visible corners inward from the square corners protected here. private var curvedPanelHorizontalInset: CGFloat? { let radius = bottomSafeAreaInset - let gap = curveClearanceGap + let gap = panelBottomGap guard radius > 0, gap < radius else { return nil } let inset = radius - sqrt(max(0, 2 * radius * gap - gap * gap)) + 4 guard inset.isFinite else { return nil } @@ -467,13 +462,15 @@ struct MapPage: View { } } - /// Largest size allowed by the existing hash-length ladder. Width fitting - /// may make it smaller, but never larger than the familiar phone treatment. + /// Largest size allowed by the hash-length ladder. The watch now permits one + /// point more than the phone-derived sizes: it is read at arm's length in + /// motion, and the width solver still prevents that legibility gain from + /// making an intrinsic row overflow its column. private var rowFontSizeCap: CGFloat { let widest = heard.map(\.id.count).max() ?? 2 - if widest > 4 { return 9 } - if widest > 2 { return 10 } - return 11 + if widest > 4 { return 10 } + if widest > 2 { return 11 } + return 12 } /// Font size that leaves two intrinsic rows safely inside their columns. @@ -487,13 +484,14 @@ struct MapPage: View { return (columnWidth - 12) / (0.62 * idChars + 3.1) } - /// Seven points is the floor at which two columns remain readable. Falling - /// below it is the one reason to spend the extra height on a single column. - private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 7 } + /// Eight points is the new glanceability floor for two columns. Without it, + /// the extra width makes six-character IDs on 40 mm barely “fit” at about + /// seven points and regress from the clearer single-column treatment. + private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 8 } private var rowFontSize: CGFloat { guard twoHeardColumnsFit else { return rowFontSizeCap } - return min(unconstrainedRowFontSize.clamped(to: 7...11), rowFontSizeCap) + return min(unconstrainedRowFontSize.clamped(to: 8...12), rowFontSizeCap) } private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @@ -778,8 +776,6 @@ private struct WatchPhaseBar: View { private var phaseKey: PhaseAnimationKey { PhaseAnimationKey( - phase: snapshot.phase, - title: snapshot.phaseTitle, endsAtMs: snapshot.phaseEndsAtMs, durationMs: snapshot.phaseDurationMs ) @@ -867,8 +863,9 @@ private struct WatchPhaseBar: View { } private struct PhaseAnimationKey: Hashable { - let phase: String - let title: String + // The drain depicts time, not its caption. Skip mode can change the title + // against the same timer; including it here would cancel, snap and restart + // an otherwise continuous bar every time the label flips. let endsAtMs: Double? let durationMs: Int? } From ac862b7dbe79833e6ccf25506d06c790729f33ba Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 12:18:53 -0700 Subject: [PATCH 196/360] Keep the fix still and move the world under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "On the map our location doesn't [stay] fixed in the center with the map moving around, instead we move on the map and then recenter." Two decisions collided. The phone withholds geo updates until the fix moves 15 m, so nothing moves between snapshots and each one lands as a single large step. And the camera animated over 0.25 s while the fix annotation — anchored to a coordinate — moved the instant the snapshot arrived. So the puck jumped ahead and the map slid after it, which is precisely the sensation of moving across the map and being chased. Worst where he noticed it: at walking pace that is one lurch every ~11 s. At 30 mph the same threshold fires every ~1.2 s and reads as continuous. Automatic follow updates and placement corrections now cut, so the camera moves in the same frame as the fix and the puck stays where it is while the world steps beneath it. An explicit recentre tap still animates: it is rare, the wearer asked for it, and the motion shows what their tap did. Nobody asked for a follow update, so nothing should appear to move except the world. Two alternatives were considered and deliberately not taken, both recorded: lowering the 15 m threshold buys smaller steps with more radio wakeups, and interpolating between fixes would pan continuously but invents position data and reinstates the continuous animation `f15c93e` removed. Either is available if stepping still distracts on a drive — after tomorrow's battery numbers, not before. --- ios/MeshMapperWatch/MapPage.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index b4419ebb..07f123f1 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -594,18 +594,21 @@ struct MapPage: View { private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { guard !isLuminanceReduced, force || isFollowing, let fix else { return } let center = centerPlacing(fix, proxy: proxy) - let isInitialPlacement = programmaticCenter == nil programmaticCenter = center let region = MKCoordinateRegion(center: center, span: currentSpan) - if isInitialPlacement { - // Cutting to the first fix centres and zooms as one operation. Animating - // from `.automatic` would fly through unrelated tiles from MapKit's - // arbitrary opening location before reaching the wearer. - camera = .region(region) - } else { + + if force { + // A tap is a rare, explicit request to move the map, so animation shows + // the wearer what their action changed. Automatic follow is different: + // the fix coordinate has already changed in this frame, and animating + // the camera after it makes the puck wander before the map catches up. withAnimation(.easeInOut(duration: 0.25)) { camera = .region(region) } + } else { + // First placement, GPS steps and placement corrections all cut so the + // puck stays visually fixed while the world moves beneath it. + camera = .region(region) } } From e8027d68808d60058e0558987eee851902a2128c Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 12:32:47 -0700 Subject: [PATCH 197/360] Anchor the Live Activity countdown to the end of its bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The countdown timer in the live [activity] is not positioned at the end of the bar. It would be nice to move it a bit closer to the end of the bar as it is in the watch app." Measured from a render rather than guessed: the number's right edge sat 8.7 pt inside the track on the lock screen and 8.3 in the island. Because the cap is rounded, the eye measures to the curve, which makes that gap read as larger than it is — the number looked adrift in the dark part of the track rather than anchored to its end. The two ends were being inset equally, but they are not symmetric in effect: the title begins against a straight fill edge while the countdown ends against a curve. The inset is asymmetric now, and the gap measures 4.0 pt on the lock screen, 3.7 in the island and 4.0 on the small family. The spacer between title and countdown is untouched — it is what guarantees the title truncates before the two can collide, so buying room from it would trade one defect for a worse one. --- ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 65e2694f..ca4bb43c 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -253,7 +253,13 @@ private struct MeshMapperPhaseBar: View { ) .frame(width: countdownWidth, alignment: .trailing) } - .padding(.horizontal, 7) + // The native timer keeps a small amount of reserved space beyond the + // visible glyphs even inside its trailing-aligned fixed frame. Leaving + // the title at seven points but tucking that reservation toward the + // rounded cap puts the visible digits about four points from the end; + // 2.5 points still clears the curve on the shortest 18 pt track. + .padding(.leading, 7) + .padding(.trailing, 2.5) .shadow(color: .black.opacity(0.7), radius: 1.5) } } From bce27b199514e1036cd1566637cb97c1441f9edd Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 13:21:53 -0700 Subject: [PATCH 198/360] Stop rebuilding the watch payload twice a second, and fix repeater identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the branch review, all traced before being believed. **A synthetic timestamp defeated the dedupe and lied on screen.** Every heard row carried `at: now` — the moment the payload was built — so once Top Heard held anything, each rebuild produced a different fingerprint and the 2 s throttle became the only brake: roughly 2,100 context updates across a 71-minute session where near-zero were intended. Excluding `updatedAtMs` from the fingerprint had achieved nothing. It was also displayed: Node Detail's "Heard" time read as now, always. Rows now carry when their set last changed, tracked separately for Top Heard and the RX slot because multi-hop updates can move one without moving the other. The manual-cooldown deadline serialises the timer's own `endTime` rather than being reconstructed from two `DateTime.now()` reads whose jitter alone changed the fingerprint. **The cheap check ran after the expensive one.** `_flush` built the whole geo payload — merging and sorting up to 2,000 ping candidates, resolving colours and distances, evaluating the twelve-condition ping gate — then serialised it, and only then compared the fingerprint and usually threw it away. Five countdown timers tick at 500 ms into the scheduler, so a quiet session did that twice a second. Urgency is now decided from a small scalar projection first, and a flush inside the throttle window reschedules without building anything. Worse, none of it was gated on owning a watch. `isSupportedPlatform` only asked whether this was iOS; native refused the payload at `isPaired` / `isWatchAppInstalled`, but after Dart had built, encoded and crossed the method channel. Someone with no Apple Watch paid all of that for a payload that was discarded. Native now publishes its availability and the scheduler does nothing without it — and because `sessionWatchStateDidChange` republishes, a watch paired after launch starts working without a restart. **Links compared incompatible identities, so none had ever drawn.** `linkedRepeaterIds` and the heard IDs carry path hashes; `WatchRepeater.id` carried the API database ID and `hexId` never reached the wire. The watch compared the two exactly, so no link line has ever appeared and the current-cycle ring almost never fired. Both identities now travel, and matching resolves a path hash as a unique hex prefix — ambiguous prefixes draw nothing, because a line to the wrong repeater asserts a relationship that does not exist. The RX slot joins the highlight set, which was the item deferred from the ping fix. Nine tests cover the parts that were blind: timestamp stability across rebuilds, throttling before the build, pairing transitions, prefix links, ambiguity, and RX-only highlighting. --- ios/MeshMapperWatch/MapPage.swift | 25 +- ios/MeshMapperWatch/SampleSnapshot.swift | 1 + ios/Runner/WatchSessionManager.swift | 13 ++ ios/Shared/MeshMapperWatchPayload.swift | 6 + lib/providers/app_state_provider.dart | 52 ++++- lib/services/watch/watch_bridge_service.dart | 92 +++++++- lib/services/watch/watch_geo_builder.dart | 58 ++++- lib/services/watch/watch_models.dart | 42 +++- .../watch/watch_geo_builder_test.dart | 69 +++++- .../watch/watch_wire_contract_test.dart | 213 +++++++++++++++++- 10 files changed, 522 insertions(+), 49 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 07f123f1..e37fad21 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -533,8 +533,7 @@ struct MapPage: View { @MapContentBuilder private var linkLines: some MapContent { if settings.showLinks, let fix, let snapshot { - let linked = Set(snapshot.geo.linkedRepeaterIds) - ForEach(snapshot.geo.repeaters.filter { linked.contains($0.id) }) { repeater in + ForEach(linkedRepeaters(in: snapshot.geo)) { repeater in MapPolyline(coordinates: [ fix, CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon), @@ -544,6 +543,28 @@ struct MapPage: View { } } + /// Heard identities are path-hash prefixes, not API database IDs. Resolve + /// against the full hex carried by each pin, and require one match per + /// prefix: a line asserts a real radio path, so an ambiguous line is worse + /// than drawing none. The phone performs the same check against the full + /// catalogue before sending; repeating it here keeps malformed or older + /// payloads from turning ambiguity into a visual claim. + private func linkedRepeaters(in geo: WatchGeo) -> [WatchRepeater] { + var resolved = [WatchRepeater]() + var seen = Set() + + for rawPrefix in geo.linkedRepeaterIds { + let prefix = rawPrefix.uppercased() + guard !prefix.isEmpty else { continue } + let matches = geo.repeaters.filter { + $0.hexId?.uppercased().hasPrefix(prefix) == true + } + guard matches.count == 1, seen.insert(matches[0].id).inserted else { continue } + resolved.append(matches[0]) + } + return resolved + } + @MapContentBuilder private var pingMarkers: some MapContent { if let snapshot { diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 230fd940..474890f1 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -104,6 +104,7 @@ enum SampleSnapshot { ].map { id, name, dLat, dLon, color, heard in WatchRepeater( id: id, + hexId: id, name: name, lat: originLat + dLat, lon: originLon + dLon, diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index 2cf0a802..bae3aab7 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -139,6 +139,17 @@ final class WatchSessionManager: NSObject { ] } + /// Dart performs the expensive geo build only while a real destination + /// exists. Push changes as well as answering its startup query: pairing and + /// app installation can happen after Flutter has been alive for hours, and + /// a launch-time false must never become a permanent gate. + private func publishStatus() { + guard let channel else { return } + DispatchQueue.main.async { + channel.invokeMethod("availabilityChanged", arguments: self.statusDictionary()) + } + } + private func flutterError(_ error: Error, code: String) -> FlutterError { FlutterError( code: code, @@ -189,6 +200,7 @@ extension WatchSessionManager: WCSessionDelegate { if let error { NSLog("[WATCH] Activation failed: \(error.localizedDescription)") } + publishStatus() } func sessionDidBecomeInactive(_ session: WCSession) {} @@ -201,6 +213,7 @@ extension WatchSessionManager: WCSessionDelegate { func sessionWatchStateDidChange(_ session: WCSession) { // A newly installed or newly paired watch has no context yet. lastContextData = nil + publishStatus() } func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 04a1e7e0..f7dd927e 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -69,6 +69,12 @@ struct WatchPing: Codable, Hashable, Identifiable { /// A repeater pin. `heardThisCycle` drives the highlight ring. struct WatchRepeater: Codable, Hashable, Identifiable { let id: String + /// Full repeater hex. Heard path hashes are prefixes of this value; the API + /// database ID above belongs to a different identity domain. + // Optional only for one-version migration: a new watch can receive the + // phone's previously persisted v2 application context before the matching + // app update replaces it. New Dart payloads always provide this field. + let hexId: String? let name: String let lat: Double let lon: Double diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ad7f1f70..0f72ee6d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -244,6 +244,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Top repeaters overlay — updated live on each ping event List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + DateTime? _topRepeatersOverlayUpdatedAt; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -630,6 +631,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList() ..sort((a, b) => b.snr.compareTo(a.snr)); _topRepeatersOverlay = fresh.take(3).toList(); + _topRepeatersOverlayUpdatedAt = DateTime.now(); } void _updateLiveActivityRepeaters( @@ -676,6 +678,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Clear all overlay state (top 3 + RX slot). void _clearOverlayState() { _topRepeatersOverlay = []; + _topRepeatersOverlayUpdatedAt = null; _rxOverlaySlot = null; _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; @@ -1213,8 +1216,31 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } void _scheduleWatchSync({bool immediate = false}) { - if (_isDisposed || !_watchBridge.isSupportedPlatform) return; - _watchBridge.schedule(_buildWatchSnapshot, immediate: immediate); + if (_isDisposed || !_watchBridge.canSync) return; + _watchBridge.schedule( + _buildWatchSnapshot, + urgencyKeyBuilder: _buildWatchUrgencyKey, + immediate: immediate, + ); + } + + /// The bridge needs to decide whether a flush can wait before it builds the + /// geographic payload. Keep this in the wire model's shared formatter so the + /// cheap preflight and the eventual snapshot cannot drift on what is urgent. + String _buildWatchUrgencyKey() { + final phase = _resolveLiveActivityPhase(); + final controls = _buildWatchControls(); + return WatchSnapshot.buildUrgencyKey( + sessionId: _liveActivitySessionId ?? 'idle', + mode: _resolvedWatchSessionModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + isConnected: isConnected, + controls: controls, + cue: _watchCue, + ); } /// Builds the watch payload. @@ -1258,7 +1284,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchSnapshot( core: core, - geo: _buildWatchGeo(now), + geo: _buildWatchGeo(), controls: _buildWatchControls(), pingColor: pingColor, cue: _watchCue, @@ -1289,7 +1315,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } - WatchGeo _buildWatchGeo(DateTime now) { + WatchGeo _buildWatchGeo() { final position = _resolveWatchPosition(); // Repeaters heard during the current cycle get the highlight ring. @@ -1302,6 +1328,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // can never disagree. final top = _topRepeatersOverlay; final rxSlot = _rxOverlaySlot; + if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase()); // Overlay IDs are hex path hashes, so resolve names by prefix at whatever // length this zone actually uses. @@ -1330,13 +1357,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { top: top, rxSlot: rxSlot, repeaterByHex: repeaterByHex, - at: now, + topAt: _topRepeatersOverlayUpdatedAt, + rxAt: _liveActivityRxUpdatedAt, lat: position?.lat, lon: position?.lon, ), linkedRepeaterIds: [ - for (final entry in top) entry.repeaterId, - if (rxSlot != null) rxSlot.repeaterId, + ...WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: _repeaters, + prefixes: heardIds, + ).keys, ], ); } @@ -1460,7 +1490,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, manualCooldownEndsAt: cooldownMs > 0 - ? DateTime.now().add(Duration(milliseconds: cooldownMs)) + ? _manualPingCooldownTimer.endTime : null, // The button already renders its cooldown deadline. The handler still // returns this refusal to a stale tap, but duplicating it as a status @@ -1985,6 +2015,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _watchBridge.attachCommandHandler( _handleWatchCommand, onRefusal: _emitWatchFailure, + // Availability can become true long after provider startup when a + // watch is paired or its app is installed. Push the current state then + // rather than waiting for an unrelated phone-side notification. + onAvailabilityChanged: (available) { + if (available) _scheduleWatchSync(immediate: true); + }, ); } diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 8ed94818..6d5ca207 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -8,6 +8,7 @@ import '../../utils/debug_logger_io.dart'; import 'watch_models.dart'; typedef WatchSnapshotBuilder = WatchSnapshot? Function(); +typedef WatchUrgencyKeyBuilder = String Function(); /// Decides whether a wrist command may begin. Returns null when admitted, or a /// reason when refused; admitted work continues independently of this reply. @@ -15,6 +16,7 @@ typedef WatchSnapshotBuilder = WatchSnapshot? Function(); /// bridge fakes source-compatible without putting the real path behind a wait. typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); typedef WatchCommandRefusalHandler = void Function(String reason); +typedef WatchAvailabilityHandler = void Function(bool available); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -37,14 +39,18 @@ class WatchBridgeService { Timer? _scheduledUpdate; WatchSnapshotBuilder? _pendingSnapshotBuilder; + WatchUrgencyKeyBuilder? _pendingUrgencyKeyBuilder; WatchCommandHandler? _commandHandler; WatchCommandRefusalHandler? _commandRefusalHandler; + WatchAvailabilityHandler? _availabilityHandler; String? _lastPayload; String? _lastUrgencyKey; DateTime? _lastSentAt; + DateTime? _lastBuiltAt; bool _disposed = false; bool _didReconcileNativeState = false; + bool _canSync = false; Future _operationChain = Future.value(); /// Commands already handled, so redelivery can't fire a second transmit. @@ -52,19 +58,27 @@ class WatchBridgeService { bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + bool get canSync => isSupportedPlatform && _canSync; /// Wire up the inbound command path. Safe to call more than once. void attachCommandHandler( WatchCommandHandler handler, { WatchCommandRefusalHandler? onRefusal, + WatchAvailabilityHandler? onAvailabilityChanged, }) { _commandHandler = handler; _commandRefusalHandler = onRefusal; + _availabilityHandler = onAvailabilityChanged; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); + unawaited(_refreshAvailability()); } Future _handleNativeCall(MethodCall call) async { + if (call.method == 'availabilityChanged') { + _applyAvailability(call.arguments, refreshNativeState: true); + return null; + } if (call.method != 'command') return null; final args = call.arguments; @@ -131,6 +145,40 @@ class WatchBridgeService { } } + Future _refreshAvailability() async { + try { + _applyAvailability(await _channel.invokeMethod('status')); + } on MissingPluginException { + // Expected on non-iOS test hosts and older generated projects. + } on PlatformException catch (error) { + debugError('[WATCH] Status failed: ${error.code}: ${error.message}'); + } + } + + void _applyAvailability( + Object? raw, { + bool refreshNativeState = false, + }) { + if (raw is! Map) return; + final available = raw['activated'] == true && + raw['paired'] == true && + raw['installed'] == true; + if (available == _canSync && !refreshNativeState) return; + _canSync = available; + + // Native forgets its application-context cache whenever WatchConnectivity + // reports a state change. Forget ours on the same notification even when + // availability remains true, or an installed replacement watch could wait + // forever for state whose fingerprint Dart still considers delivered. + if (!available || refreshNativeState) { + _lastPayload = null; + _lastUrgencyKey = null; + _lastSentAt = null; + _lastBuiltAt = null; + } + _availabilityHandler?.call(available); + } + void _rememberCommandId(String id) { _handledCommandIds.add(id); // Unbounded growth would leak across a long session. @@ -141,11 +189,13 @@ class WatchBridgeService { void schedule( WatchSnapshotBuilder snapshotBuilder, { + required WatchUrgencyKeyBuilder urgencyKeyBuilder, bool immediate = false, }) { - if (_disposed || !isSupportedPlatform) return; + if (_disposed || !canSync) return; _pendingSnapshotBuilder = snapshotBuilder; + _pendingUrgencyKeyBuilder = urgencyKeyBuilder; _scheduledUpdate?.cancel(); if (immediate) { @@ -168,8 +218,27 @@ class WatchBridgeService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; - if (_disposed || !isSupportedPlatform) return; + if (_disposed || !canSync) return; + + // Urgency is intentionally a small scalar projection of provider state. + // If it has not changed, enforce the radio throttle before constructing, + // sorting, or JSON-encoding any geography. Phase/control/cue transitions + // still differ here and retain their existing immediate path. + final pendingUrgencyKey = _pendingUrgencyKeyBuilder?.call(); + final predictedUrgent = pendingUrgencyKey != _lastUrgencyKey; + final lastBuiltAt = _lastBuiltAt; + if (!predictedUrgent && lastBuiltAt != null) { + final elapsed = DateTime.now().difference(lastBuiltAt); + if (elapsed < _minimumNonUrgentInterval) { + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return; + } + } + _lastBuiltAt = DateTime.now(); final snapshot = _pendingSnapshotBuilder?.call(); if (snapshot == null) { if (_lastPayload == null && _didReconcileNativeState) return; @@ -188,9 +257,9 @@ class WatchBridgeService { if (encoded == _lastPayload) return; final urgent = snapshot.urgencyKey != _lastUrgencyKey; - final lastSentAt = _lastSentAt; - if (!urgent && lastSentAt != null) { - final elapsed = DateTime.now().difference(lastSentAt); + final sentAt = _lastSentAt; + if (!urgent && sentAt != null) { + final elapsed = DateTime.now().difference(sentAt); if (elapsed < _minimumNonUrgentInterval) { _scheduledUpdate = Timer( _minimumNonUrgentInterval - elapsed, @@ -201,10 +270,18 @@ class WatchBridgeService { } try { - await _channel.invokeMethod('sync', { + final delivered = await _channel.invokeMethod('sync', { 'payload': payload, 'urgent': urgent, }); + if (delivered != true) { + // Native can lose availability between status and send. Do not cache + // a payload it refused. Re-query rather than guessing which condition + // failed, so a transient context error cannot permanently close the + // gate while the watch is actually still installed. + await _refreshAvailability(); + return; + } _didReconcileNativeState = true; _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; @@ -232,6 +309,7 @@ class WatchBridgeService { _lastPayload = null; _lastUrgencyKey = null; _lastSentAt = null; + _lastBuiltAt = null; } } @@ -240,7 +318,9 @@ class WatchBridgeService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; _pendingSnapshotBuilder = null; + _pendingUrgencyKeyBuilder = null; _commandHandler = null; _commandRefusalHandler = null; + _availabilityHandler = null; } } diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index aa68bda5..4f12c7a4 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -97,9 +97,8 @@ class WatchGeoBuilder { // The phone draws a multi-hop-only return as RX: it proves the packet // came back through the mesh, but not that any repeater heard us // directly. Keep the TX identity and mirror that evidence colour. - color: hasMultiHopOnly - ? pingColor('rx', true) - : pingColor('tx', success), + color: + hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success), at: tx.timestamp, )); } @@ -157,6 +156,10 @@ class WatchGeoBuilder { int cap = WatchWire.maxRepeaters, }) { final located = repeaters.where((r) => r.hasLocation).toList(); + final heardRepeaters = resolveUniqueHexPrefixes( + repeaters: repeaters, + prefixes: heardThisCycle, + ).values.map((repeater) => repeater.id).toSet(); if (lat != null && lon != null) { // Distance is computed once per repeater rather than inside the @@ -179,17 +182,41 @@ class WatchGeoBuilder { return limited .map((r) => WatchRepeater( id: r.id, + hexId: r.hexId, name: r.name, lat: r.lat, lon: r.lon, color: repeaterColor(r), - heardThisCycle: - heardThisCycle.contains(r.id) || - heardThisCycle.contains(r.hexId), + heardThisCycle: heardRepeaters.contains(r.id), )) .toList(); } + /// Resolve path-hash prefixes only when the full repeater catalogue proves + /// the match unique. Applying this before the wrist's nearest-20 cap matters: + /// a second matching repeater outside that cap still makes a line or ring a + /// guess, and a confidently wrong relationship is worse than none. + static Map resolveUniqueHexPrefixes({ + required List repeaters, + required Iterable prefixes, + }) { + final normalized = prefixes + .map((prefix) => prefix.toUpperCase()) + .where((prefix) => prefix.isNotEmpty) + .toSet(); + final resolved = {}; + + for (final length in normalized.map((prefix) => prefix.length).toSet()) { + final index = indexByHexPrefix(repeaters, length); + for (final prefix + in normalized.where((prefix) => prefix.length == length)) { + final repeater = index[prefix]; + if (repeater != null) resolved[prefix] = repeater; + } + } + return resolved; + } + /// Dot colour for an overlay row, mirroring `_overlayTypeColor` on the map. static WatchColor overlayTypeColor(OverlayPingType type) => switch (type) { OverlayPingType.tx => WatchColor.fromColor(PingColors.txSuccess), @@ -212,31 +239,35 @@ class WatchGeoBuilder { required List<({String repeaterId, double snr, OverlayPingType type})> top, ({String repeaterId, double snr})? rxSlot, required Map repeaterByHex, - required DateTime at, + required DateTime? topAt, + required DateTime? rxAt, double? lat, double? lon, }) { final rows = []; - for (final entry in top) { + // Missing time means the provider cannot truthfully say when this set was + // heard. Omitting such a row is safer than presenting a plausible lie in + // Node Detail; every production mutation records its timestamp atomically. + for (final entry in top.where((_) => topAt != null)) { rows.add(_row( id: entry.repeaterId, snr: entry.snr, type: entry.type, repeaterByHex: repeaterByHex, - at: at, + at: topAt!, lat: lat, lon: lon, )); } - if (rxSlot != null) { + if (rxSlot != null && rxAt != null) { rows.add(_row( id: rxSlot.repeaterId, snr: rxSlot.snr, type: OverlayPingType.rx, repeaterByHex: repeaterByHex, - at: at, + at: rxAt, lat: lat, lon: lon, )); @@ -260,7 +291,10 @@ class WatchGeoBuilder { final repeater = repeaterByHex[hex]; double? distance; - if (lat != null && lon != null && repeater != null && repeater.hasLocation) { + if (lat != null && + lon != null && + repeater != null && + repeater.hasLocation) { distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 34c2a3da..6c1fee1a 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -93,6 +93,7 @@ class WatchPing { class WatchRepeater { const WatchRepeater({ required this.id, + required this.hexId, required this.name, required this.lat, required this.lon, @@ -101,6 +102,7 @@ class WatchRepeater { }); final String id; + final String hexId; final String name; final double lat; final double lon; @@ -109,6 +111,7 @@ class WatchRepeater { Map toMap() => { 'id': id, + 'hexId': hexId, 'name': name, 'lat': lat, 'lon': lon, @@ -298,14 +301,37 @@ class WatchSnapshot { /// /// Deliberately excludes geo: a moving GPS would otherwise mark every /// update urgent and defeat the throttle entirely. - String get urgencyKey => [ - core.sessionId, - core.mode, - core.phase.wireValue, - core.phaseTitle, - core.phaseDetail ?? '', - core.phaseEndsAt?.millisecondsSinceEpoch ?? 0, - core.isConnected, + String get urgencyKey => buildUrgencyKey( + sessionId: core.sessionId, + mode: core.mode, + phase: core.phase, + phaseTitle: core.phaseTitle, + phaseDetail: core.phaseDetail, + phaseEndsAt: core.phaseEndsAt, + isConnected: core.isConnected, + controls: controls, + cue: cue, + ); + + static String buildUrgencyKey({ + required String sessionId, + required String mode, + required LiveActivityPhase phase, + required String phaseTitle, + required String? phaseDetail, + required DateTime? phaseEndsAt, + required bool isConnected, + required WatchControls controls, + required WatchHapticCue? cue, + }) => + [ + sessionId, + mode, + phase.wireValue, + phaseTitle, + phaseDetail ?? '', + phaseEndsAt?.millisecondsSinceEpoch ?? 0, + isConnected, controls.canStartStop, controls.canManualPing, controls.isSessionActive, diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index e5822cb5..fb94aa90 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -2,7 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/models/log_entry.dart'; import 'package:mesh_mapper/models/ping_data.dart'; import 'package:mesh_mapper/models/repeater.dart'; -import 'package:mesh_mapper/providers/app_state_provider.dart' show OverlayPingType; +import 'package:mesh_mapper/providers/app_state_provider.dart' + show OverlayPingType; import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; import 'package:mesh_mapper/services/watch/watch_models.dart'; import 'package:mesh_mapper/utils/ping_colors.dart'; @@ -332,7 +333,7 @@ void main() { expect(built.map((r) => r.id), ['near', 'mid']); }); - test('marks heard-this-cycle by either short id or hex id', () { + test('marks an RX-only repeater heard through its unique hex prefix', () { final repeaters = [ _repeater(id: '01', hexId: 'AA11', lat: 47.6, lon: -122.3), _repeater(id: '02', hexId: 'BB22', lat: 47.6, lon: -122.3), @@ -341,14 +342,28 @@ void main() { final built = WatchGeoBuilder.buildRepeaters( repeaters: repeaters, - heardThisCycle: {'01', 'BB22'}, + // This is the RX slot's path hash; no Top Heard entry is needed for + // the repeater pin to receive its current-cycle ring. + heardThisCycle: {'BB'}, ); expect( {for (final r in built) r.id: r.heardThisCycle}, - {'01': true, '02': true, '03': false}, + {'01': false, '02': true, '03': false}, ); }); + + test('carries the full hex identity separately from the API id', () { + final built = WatchGeoBuilder.buildRepeaters( + repeaters: [ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3), + ], + heardThisCycle: const {}, + ); + + expect(built.single.id, '01'); + expect(built.single.hexId, '4E5D82'); + }); }); group('buildHeard — mirrors the map\'s Top Heard overlay', () { @@ -360,7 +375,8 @@ void main() { ], rxSlot: (repeaterId: 'B914', snr: 9.9), repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12, 0, 1), ); // The RX slot trails even though its SNR is highest — it is a distinct @@ -376,7 +392,8 @@ void main() { ], rxSlot: (repeaterId: 'CC', snr: 1), repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12), ); expect(built[0].typeColor, WatchColor.fromColor(PingColors.txSuccess)); @@ -394,7 +411,8 @@ void main() { ], rxSlot: (repeaterId: 'E', snr: 0), repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12), ); expect(built.length, WatchWire.maxHeard); @@ -405,9 +423,14 @@ void main() { top: const [(repeaterId: '4e5d', snr: 6, type: OverlayPingType.tx)], repeaterByHex: { '4E5D': _repeater( - id: '01', hexId: '4E5D82', name: 'Capitol Hill', lat: 47.61, lon: -122.3), + id: '01', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.61, + lon: -122.3), }, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: null, lat: 47.6, lon: -122.3, ); @@ -421,7 +444,8 @@ void main() { final built = WatchGeoBuilder.buildHeard( top: const [(repeaterId: 'AB', snr: 1, type: OverlayPingType.tx)], repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: null, ); expect(built.single.id, 'AB'); @@ -432,6 +456,31 @@ void main() { }); group('indexByHexPrefix', () { + test('resolves a link from a path-hash prefix to the full hex', () { + final repeater = + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3); + + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [repeater], + prefixes: const ['4E5D'], + ); + + expect(linked['4E5D'], same(repeater)); + }); + + test('an ambiguous link prefix resolves to nothing', () { + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3), + _repeater(id: '02', hexId: '4E99F1', lat: 47.7, lon: -122.3), + ], + prefixes: const ['4E'], + ); + + expect(linked, isEmpty, + reason: 'a line to the wrong repeater is worse than no line'); + }); + test('resolves a prefix owned by exactly one repeater', () { final index = WatchGeoBuilder.indexByHexPrefix([ _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index b3dc8a1c..99c62dc6 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -112,14 +114,73 @@ void main() { expect(map['phaseEndsAtMs'], 1760000000000.0); }); + test('heard time survives a rebuild without changing the fingerprint', () { + const heardAtMs = 1759999980000.0; + WatchSnapshot withBuildTime(int updatedAtMs) => WatchSnapshot( + core: _snapshot().core, + geo: WatchGeo( + pings: const [], + repeaters: const [], + heard: [ + WatchHeardNode( + id: '4E5D', + name: 'Capitol Hill', + snr: 8.5, + at: DateTime.fromMillisecondsSinceEpoch(1759999980000), + typeColor: const WatchColor(0, 1, 0), + ), + ], + linkedRepeaterIds: const [], + ), + controls: _snapshot().controls, + updatedAt: DateTime.fromMillisecondsSinceEpoch(updatedAtMs), + ); + + String fingerprint(WatchSnapshot snapshot) { + final payload = Map.from(snapshot.toMap()) + ..remove('updatedAtMs'); + return jsonEncode(payload); + } + + final first = withBuildTime(1759999999000); + final rebuilt = withBuildTime(1760000000000); + final heard = (first.toMap()['geo'] as Map)['heard'] as List; + expect((heard.first as Map)['atMs'], heardAtMs); + expect(fingerprint(rebuilt), fingerprint(first)); + }); + + test('repeater wire object carries both database and hex identities', () { + const repeater = WatchRepeater( + id: '01', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + color: WatchColor(1, 0, 0), + heardThisCycle: true, + ); + + expect(repeater.toMap().keys.toSet(), { + 'id', + 'hexId', + 'name', + 'lat', + 'lon', + 'color', + 'heardThisCycle', + }); + }); + test('phase duration rides along so the watch can draw its own bar', () { // Deadline plus duration is everything needed to compute the remaining // fraction locally, which is why the bar needs no per-second updates. - expect(_snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); + expect( + _snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); expect(_snapshot().toMap()['phaseDurationMs'], isNull); }); - test('wire version is stamped so the watch can refuse unknown payloads', () { + test('wire version is stamped so the watch can refuse unknown payloads', + () { expect(_snapshot().toMap()['wireVersion'], WatchWire.version); }); @@ -159,6 +220,9 @@ void main() { late WatchBridgeService bridge; late MethodChannel channel; late List handled; + late bool nativeAvailable; + late bool syncSucceeds; + late int syncCalls; setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -166,11 +230,31 @@ void main() { channel = const MethodChannel('meshmapper/watch_test'); bridge = WatchBridgeService(channel: channel); handled = []; + nativeAvailable = true; + syncSucceeds = true; + syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'activated': nativeAvailable, + 'paired': nativeAvailable, + 'installed': nativeAvailable, + }; + } + if (call.method == 'sync') { + syncCalls++; + return syncSucceeds; + } + return null; + }); }); tearDown(() { debugDefaultTargetPlatformOverride = null; bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); }); Future?> sendCommand(String id, String kind) async { @@ -187,6 +271,128 @@ void main() { return channel.codec.decodeEnvelope(result) as Map?; } + test('availability can open after attach without an app restart', () async { + nativeAvailable = false; + final changes = []; + bridge.attachCommandHandler( + (_) => null, + onAvailabilityChanged: changes.add, + ); + await Future.delayed(Duration.zero); + expect(bridge.canSync, isFalse); + var builds = 0; + bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(builds, 0, + reason: 'an iPhone without a watch must not construct geo payloads'); + + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + {'activated': true, 'paired': true, 'installed': true}, + )), + null, + ); + expect(result, isNotNull); + expect(bridge.canSync, isTrue); + expect(changes, [true]); + }); + + test('a native false is not cached as a delivered snapshot', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + syncSucceeds = false; + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2); + expect(syncCalls, 2, + reason: 'native refused both; neither payload was delivered'); + }); + + test('a watch state change resends even when availability stays true', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + {'activated': true, 'paired': true, 'installed': true}, + )), + null, + ); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2); + expect(syncCalls, 2, + reason: 'native cleared its context cache on the state change'); + }); + + test('the nonurgent throttle runs before the snapshot builder', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 1, + reason: 'geo construction waits until the throttle window opens'); + expect(syncCalls, 1); + }); + test('accepted commands reach the handler', () async { bridge.attachCommandHandler((kind) async { handled.add(kind); @@ -229,7 +435,8 @@ void main() { expect(handled, hasLength(2)); }); - test('an unknown command is refused without reaching the handler', () async { + test('an unknown command is refused without reaching the handler', + () async { bridge.attachCommandHandler((kind) async { handled.add(kind); return null; From 6e1112b48253823a4df926b6785bb8e7fa2a1c89 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 13:39:48 -0700 Subject: [PATCH 199/360] Tell the truth about starting, idling, and going stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five remaining review findings. **Stop was silently dropped while a session was starting.** Admission checked `_autoPingEnabled` but not `_autoPingStarting`, and during Start's awaited session check the first is false while the second is true — so a Stop arriving in that window was treated as "already stopped" and discarded, after which the session came up anyway. Start in that window is genuinely idempotent and is still accepted as a no-op; Stop is not, so it now refuses with "Still starting — try Stop again." No deferred queue: a refusal the wearer can act on beats hidden ordering they cannot see. **An idle watch claimed to be preparing a session.** The shared phase resolver maps "no session" onto Starting, which is right for the Live Activity — whose builder only runs during a session — and wrong for the watch, which is always present. A connected, GPS-locked, idle watch said "Preparing session…" indefinitely, including right after Stop. The watch projects that fallback to a new idle phase, "Ready / No session running"; the Live Activity still calls the shared resolver directly and is unchanged. **The outcome colour only ever read TX history**, so a Passive session showed whatever TX last did, and a multi-hop-only TX reported success while the map marker beside it drew RX purple — the same event described two ways. It now follows the newest event across all four histories and applies the map's multi-hop rule. **Staleness never invalidated the view.** `isStale` compared against `receivedAt` with nothing changing at the 90-second boundary, so on a durable phase a dead link could look current indefinitely. The boundary is an event now: one cancellable task per snapshot, not a poll — this is the app whose battery we spent the day cutting. **A failure cue replayed after a watch restart**, because the phone never cleared it and the watch deduped IDs in process memory only. The phone drops a cue once native accepts it, and the watch ignores anything undated or older than 30 seconds. Either half alone leaves the hole open. --- ios/MeshMapperWatch/WatchSessionClient.swift | 41 +++++++-- ios/Shared/MeshMapperWatchPayload.swift | 7 ++ lib/providers/app_state_provider.dart | 68 ++++++++++++--- .../live_activity/live_activity_models.dart | 8 +- lib/services/watch/watch_bridge_service.dart | 6 ++ lib/services/watch/watch_geo_builder.dart | 58 +++++++++++-- lib/services/watch/watch_models.dart | 59 ++++++++++++- .../watch/watch_geo_builder_test.dart | 60 +++++++++++++ .../watch/watch_wire_contract_test.dart | 87 ++++++++++++++++++- 9 files changed, 364 insertions(+), 30 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index f5e54738..7fac894b 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -15,6 +15,11 @@ final class WatchSessionClient: NSObject { /// When the last snapshot arrived — drives the stale badge. private(set) var receivedAt: Date? + /// Stored rather than derived from `Date()`: time passing does not invalidate + /// a SwiftUI observation by itself, so the boundary has to become an event. + private(set) var isStale = true + private var staleBoundaryTask: Task? + /// The phone's explanation for a refused admission or a later failed action. /// Both belong to one short-lived presentation path on the controls page. private(set) var lastRefusal: String? @@ -48,11 +53,8 @@ final class WatchSessionClient: NSObject { /// only sends on real change, so silence is normal — this threshold is /// about "the phone has probably gone away", not "no update recently". static let staleAfter: TimeInterval = 90 - - var isStale: Bool { - guard let receivedAt else { return true } - return Date().timeIntervalSince(receivedAt) > Self.staleAfter - } + private static let cueFreshFor: TimeInterval = 30 + private static let cueClockTolerance: TimeInterval = 5 /// Bring the session up and pull a current snapshot. /// @@ -62,7 +64,7 @@ final class WatchSessionClient: NSObject { #if DEBUG if SampleSnapshot.isEnabled { snapshot = SampleSnapshot.make() - receivedAt = Date() + markSnapshotReceived() return } #endif @@ -149,6 +151,29 @@ final class WatchSessionClient: NSObject { } } + private func markSnapshotReceived(at arrival: Date = Date()) { + staleBoundaryTask?.cancel() + receivedAt = arrival + isStale = false + + // One task per delivery makes the 90-second boundary observable without a + // polling timer. A newer snapshot cancels this task and owns the next one. + staleBoundaryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.staleAfter)) + guard !Task.isCancelled, self?.receivedAt == arrival else { return } + self?.isStale = true + self?.staleBoundaryTask = nil + } + } + + private static func isFresh(_ cue: WatchHapticCue, at arrival: Date) -> Bool { + guard let issuedAt = cue.issuedAt else { return false } + let age = arrival.timeIntervalSince(issuedAt) + // Phone and watch clocks normally agree, but a few seconds of skew must + // not suppress a real failure that has just crossed the radio. + return age >= -cueClockTolerance && age <= cueFreshFor + } + // MARK: - Ingest private func ingest(context: [String: Any]) { @@ -170,15 +195,17 @@ final class WatchSessionClient: NSObject { } Task { @MainActor in + let arrival = Date() self.versionMismatch = false self.snapshot = decoded - self.receivedAt = Date() + self.markSnapshotReceived(at: arrival) // A queued command has no ack. Any subsequent snapshot proves the phone // has resumed communicating; a separate timeout covers the case where // state dedupe means no snapshot follows. self.clearPendingCommand() if let cue = decoded.cue, + Self.isFresh(cue, at: arrival), self.presentedCueIDs.insert(cue.id).inserted { self.presentedCueIDOrder.append(cue.id) diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index f7dd927e..c521a8db 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -142,9 +142,16 @@ struct WatchHapticCue: Codable, Hashable { let id: String /// "success" | "failure" | "notification" let kind: String + /// Optional for migration, but new phones always send it. A watch must not + /// replay an undated cue retained by an older application context. + let issuedAtMs: Double? /// Optional is an additive wire change: v2 payloads without it still decode, /// and the matched phone and watch targets ship the new field together. let message: String? + + var issuedAt: Date? { + issuedAtMs.map { Date(timeIntervalSince1970: $0 / 1000) } + } } // MARK: - Snapshot diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0f72ee6d..d144e37e 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1228,7 +1228,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// geographic payload. Keep this in the wire model's shared formatter so the /// cheap preflight and the eventual snapshot cannot drift on what is urgent. String _buildWatchUrgencyKey() { - final phase = _resolveLiveActivityPhase(); + final phase = _resolveWatchPhase(); final controls = _buildWatchControls(); return WatchSnapshot.buildUrgencyKey( sessionId: _liveActivitySessionId ?? 'idle', @@ -1251,7 +1251,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchSnapshot? _buildWatchSnapshot() { if (_isDisposed) return null; - final phase = _resolveLiveActivityPhase(); + final phase = _resolveWatchPhase(); final repeaterState = _buildLiveActivityRepeaters(); final now = DateTime.now(); final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); @@ -1500,12 +1500,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - /// Colour of the most recent completed ping, matching the map's markers. - WatchColor? _resolveWatchPingColor() { - if (_txPings.isEmpty) return null; - final latest = _txPings.last; - return WatchGeoBuilder.pingColor('tx', latest.heardRepeaters.isNotEmpty); - } + /// Colour of the most recent completed coverage event, matching the marker + /// beside it rather than leaving Passive mode stuck on an old TX result. + WatchColor? _resolveWatchPingColor() => WatchGeoBuilder.latestPingColor( + txPings: _txPings, + rxPings: _rxPings, + discLogEntries: _discLogEntries, + traceLogEntries: _traceLogEntries, + ); /// Decides whether an intent from the wrist may begin. /// @@ -1526,13 +1528,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; case WatchCommandKind.startSession: + final admission = resolveWatchSessionCommandAdmission( + kind: kind, + isSessionActive: _autoPingEnabled, + isSessionStarting: _autoPingStarting, + ); + if (admission.refusal != null) return admission.refusal; + if (!admission.shouldRun) return null; if (!isConnected) return 'Not connected'; - if (_autoPingEnabled) return null; // Already running. unawaited(_runWatchStartSession(_resolvedWatchSessionMode)); return null; case WatchCommandKind.stopSession: - if (!_autoPingEnabled) return null; // Already stopped. + final admission = resolveWatchSessionCommandAdmission( + kind: kind, + isSessionActive: _autoPingEnabled, + isSessionStarting: _autoPingStarting, + ); + if (admission.refusal != null) return admission.refusal; + if (!admission.shouldRun) return null; unawaited(_runWatchStopSession(_resolvedWatchSessionMode)); return null; @@ -1596,11 +1610,44 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _watchCue = WatchHapticCue( id: const Uuid().v4(), kind: 'failure', + issuedAt: DateTime.now(), message: message, ); _scheduleWatchSync(immediate: true); } + void _handleWatchSnapshotDelivered(WatchSnapshot snapshot) { + final deliveredCue = snapshot.cue; + if (deliveredCue != null && _watchCue?.id == deliveredCue.id) { + // Delivery means future snapshots must stop carrying this event. The + // watch independently age-checks the retained application context, so a + // process restart cannot turn it back into a new failure. + _watchCue = null; + } + } + + ({ + LiveActivityPhase phase, + String title, + String? detail, + DateTime? endsAt, + }) _resolveWatchPhase() { + final shared = _resolveLiveActivityPhase(); + final watchPhase = resolveWatchSurfacePhase( + sharedPhase: shared.phase, + isSessionActive: _autoPingEnabled, + isSessionStarting: _autoPingStarting, + ); + if (watchPhase == shared.phase) return shared; + + return ( + phase: LiveActivityPhase.idle, + title: 'Ready', + detail: 'No session running', + endsAt: null, + ); + } + LiveActivitySnapshot? _buildLiveActivitySnapshot() { final sessionId = _liveActivitySessionId; if (!_liveActivitySessionActive || sessionId == null) { @@ -2015,6 +2062,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _watchBridge.attachCommandHandler( _handleWatchCommand, onRefusal: _emitWatchFailure, + onSnapshotDelivered: _handleWatchSnapshotDelivered, // Availability can become true long after provider startup when a // watch is paired or its app is installed. Push the current state then // rather than waiting for an unrelated phone-side notification. diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index 83d5e300..f8b7666b 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -1,7 +1,12 @@ import '../watch/watch_color.dart'; -/// High-level phase shown by the iOS Live Activity. +/// High-level phase shared by native glance surfaces. +/// +/// [idle] is watch-only: the Live Activity builder still uses its session-only +/// resolver directly, while the always-present watch projects that resolver's +/// no-session fallback to this value. enum LiveActivityPhase { + idle, active, starting, sending, @@ -24,6 +29,7 @@ enum LiveActivityPhase { extension LiveActivityPhaseWireValue on LiveActivityPhase { String get wireValue => switch (this) { + LiveActivityPhase.idle => 'idle', LiveActivityPhase.active => 'active', LiveActivityPhase.starting => 'starting', LiveActivityPhase.sending => 'sending', diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 6d5ca207..67284e42 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -17,6 +17,7 @@ typedef WatchUrgencyKeyBuilder = String Function(); typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); typedef WatchCommandRefusalHandler = void Function(String reason); typedef WatchAvailabilityHandler = void Function(bool available); +typedef WatchSnapshotDeliveryHandler = void Function(WatchSnapshot snapshot); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -43,6 +44,7 @@ class WatchBridgeService { WatchCommandHandler? _commandHandler; WatchCommandRefusalHandler? _commandRefusalHandler; WatchAvailabilityHandler? _availabilityHandler; + WatchSnapshotDeliveryHandler? _snapshotDeliveryHandler; String? _lastPayload; String? _lastUrgencyKey; @@ -65,10 +67,12 @@ class WatchBridgeService { WatchCommandHandler handler, { WatchCommandRefusalHandler? onRefusal, WatchAvailabilityHandler? onAvailabilityChanged, + WatchSnapshotDeliveryHandler? onSnapshotDelivered, }) { _commandHandler = handler; _commandRefusalHandler = onRefusal; _availabilityHandler = onAvailabilityChanged; + _snapshotDeliveryHandler = onSnapshotDelivered; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); unawaited(_refreshAvailability()); @@ -286,6 +290,7 @@ class WatchBridgeService { _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; _lastSentAt = DateTime.now(); + _snapshotDeliveryHandler?.call(snapshot); } on MissingPluginException { // Expected on non-iOS hosts and in tests. } on PlatformException catch (error) { @@ -322,5 +327,6 @@ class WatchBridgeService { _commandHandler = null; _commandRefusalHandler = null; _availabilityHandler = null; + _snapshotDeliveryHandler = null; } } diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 4f12c7a4..4c801512 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -60,6 +60,54 @@ class WatchGeoBuilder { } } + static WatchColor _txPingColor(TxPing ping) { + final success = ping.heardRepeaters.isNotEmpty; + final hasDirectEcho = + ping.heardRepeaters.any((repeater) => repeater.pathHops == null); + final hasMultiHopOnly = !hasDirectEcho && success; + // A multi-hop-only return is RX evidence, not proof that a repeater heard + // the transmitter directly. This is the phone map's marker rule. + return hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success); + } + + /// Outcome colour for the newest coverage event of any kind. + /// + /// Scanning the four bounded histories avoids constructing and sorting the + /// map-marker list a second time merely to colour one dot. + static WatchColor? latestPingColor({ + required List txPings, + required List rxPings, + required List discLogEntries, + required List traceLogEntries, + }) { + DateTime? latestAt; + WatchColor? latestColor; + + void consider(DateTime at, WatchColor color) { + if (latestAt == null || at.isAfter(latestAt!)) { + latestAt = at; + latestColor = color; + } + } + + for (final ping in txPings) { + consider(ping.timestamp, _txPingColor(ping)); + } + for (final ping in rxPings) { + consider(ping.timestamp, pingColor('rx', true)); + } + for (final entry in discLogEntries) { + consider( + entry.timestamp, + pingColor('disc', entry.discoveredNodes.isNotEmpty), + ); + } + for (final entry in traceLogEntries) { + consider(entry.timestamp, pingColor('trace', entry.success)); + } + return latestColor; + } + /// Colour for a repeater pin, matching the iOS map's `_repeaterStatusColor`. static WatchColor repeaterColor(Repeater repeater) { if (repeater.isDead) return WatchColor.fromColor(PingColors.repeaterDead); @@ -85,20 +133,12 @@ class WatchGeoBuilder { for (var i = 0; i < txPings.length; i++) { final tx = txPings[i]; - final success = tx.heardRepeaters.isNotEmpty; - final hasDirectEcho = - tx.heardRepeaters.any((repeater) => repeater.pathHops == null); - final hasMultiHopOnly = !hasDirectEcho && success; pings.add(WatchPing( id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', lat: tx.latitude, lon: tx.longitude, kind: 'tx', - // The phone draws a multi-hop-only return as RX: it proves the packet - // came back through the mesh, but not that any repeater heard us - // directly. Keep the TX identity and mirror that evidence colour. - color: - hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success), + color: _txPingColor(tx), at: tx.timestamp, )); } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 6c1fee1a..2d4a9f30 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -228,13 +228,22 @@ class WatchControls { /// Carries an [id] so the watch fires exactly once: diffing state would /// double-fire on redelivery, which WatchConnectivity does routinely. class WatchHapticCue { - const WatchHapticCue({required this.id, required this.kind, this.message}); + const WatchHapticCue({ + required this.id, + required this.kind, + required this.issuedAt, + this.message, + }); final String id; /// 'success' | 'failure' | 'notification' final String kind; + /// Creation time lets a restarted watch distinguish a current failure from + /// an old cue retained in WatchConnectivity's application context. + final DateTime issuedAt; + /// Human-readable detail for an event whose outcome arrived after command /// admission. This additive field is optional, so v2 remains decodable; no /// wire bump is needed while the matched phone and watch targets ship it. @@ -243,6 +252,7 @@ class WatchHapticCue { Map toMap() => { 'id': id, 'kind': kind, + 'issuedAtMs': issuedAt.millisecondsSinceEpoch.toDouble(), 'message': message, }; } @@ -353,3 +363,50 @@ enum WatchCommandKind { return null; } } + +typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); + +/// Resolve the wrist's single Start/Stop control without racing the phone's +/// asynchronous start transaction. A second Start is the same intent and can +/// disappear harmlessly; Stop is the opposite intent, so claiming success +/// before there is a running session would lie to the wearer. +WatchCommandAdmission resolveWatchSessionCommandAdmission({ + required WatchCommandKind kind, + required bool isSessionActive, + required bool isSessionStarting, +}) { + switch (kind) { + case WatchCommandKind.startSession: + return ( + shouldRun: !isSessionActive && !isSessionStarting, + refusal: null, + ); + case WatchCommandKind.stopSession: + if (isSessionStarting && !isSessionActive) { + return ( + shouldRun: false, + refusal: 'Still starting — try Stop again', + ); + } + return (shouldRun: isSessionActive, refusal: null); + case WatchCommandKind.manualPing: + case WatchCommandKind.requestSnapshot: + throw ArgumentError.value(kind, 'kind', 'Expected Start or Stop'); + } +} + +/// The shared resolver's Starting fallback is correct for a Live Activity, +/// which only exists for a session, but the watch also renders while idle. +/// Only the watch calls this projection, keeping the phone surface unchanged. +LiveActivityPhase resolveWatchSurfacePhase({ + required LiveActivityPhase sharedPhase, + required bool isSessionActive, + required bool isSessionStarting, +}) { + if (sharedPhase == LiveActivityPhase.starting && + !isSessionActive && + !isSessionStarting) { + return LiveActivityPhase.idle; + } + return sharedPhase; +} diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index fb94aa90..b2c8a3c5 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -171,6 +171,66 @@ void main() { ); }); + test('outcome colour follows the newest event across every history', () { + final base = DateTime(2026, 8, 12, 10); + final tx = _tx( + base, + heard: const [HeardRepeater(repeaterId: '4e', snr: 6)], + ); + final discovery = _disc( + base.add(const Duration(seconds: 1)), + discovered: true, + ); + final trace = _trace( + base.add(const Duration(seconds: 2)), + success: false, + ); + final rx = _rx(base.add(const Duration(seconds: 3))); + + WatchColor? latest({ + List rxPings = const [], + List traces = const [], + }) => + WatchGeoBuilder.latestPingColor( + txPings: [tx], + rxPings: rxPings, + discLogEntries: [discovery], + traceLogEntries: traces, + ); + + expect(latest(), WatchColor.fromColor(PingColors.discSuccess)); + expect( + latest(traces: [trace]), + WatchColor.fromColor(PingColors.noResponse), + ); + expect( + latest(rxPings: [rx], traces: [trace]), + WatchColor.fromColor(PingColors.rx), + ); + }); + + test('latest outcome uses the marker rule for multi-hop-only TX', () { + final multiHop = _tx( + DateTime(2026, 8, 12, 10), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + ], + ); + + final color = WatchGeoBuilder.latestPingColor( + txPings: [multiHop], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + expect(color, WatchColor.fromColor(PingColors.rx)); + }); + test('discovery markers use response success and failure colours', () { final answered = _disc( DateTime(2026, 8, 12, 10, 1), diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 99c62dc6..e30a32bc 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -171,6 +171,23 @@ void main() { }); }); + test('failure cues carry their creation time for restart filtering', () { + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + expect(cue.toMap(), { + 'id': 'failure-1', + 'kind': 'failure', + 'issuedAtMs': issuedAt.millisecondsSinceEpoch.toDouble(), + 'message': 'Could not start', + }); + }); + test('phase duration rides along so the watch can draw its own bar', () { // Deadline plus duration is everything needed to compute the remaining // fraction locally, which is why the bar needs no per-second updates. @@ -197,8 +214,13 @@ void main() { isNot(base.urgencyKey), ); expect( - _snapshot(cue: const WatchHapticCue(id: 'c1', kind: 'success')) - .urgencyKey, + _snapshot( + cue: WatchHapticCue( + id: 'c1', + kind: 'success', + issuedAt: DateTime.utc(2026, 8, 12), + ), + ).urgencyKey, isNot(base.urgencyKey), ); }); @@ -214,6 +236,46 @@ void main() { test('an unknown command is rejected rather than guessed at', () { expect(WatchCommandKind.fromWire('selfDestruct'), isNull); }); + + test('Start is idempotent while starting but Stop tells the truth', () { + final start = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.startSession, + isSessionActive: false, + isSessionStarting: true, + ); + final stop = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.stopSession, + isSessionActive: false, + isSessionStarting: true, + ); + + expect(start, (shouldRun: false, refusal: null)); + expect(stop.shouldRun, isFalse); + expect(stop.refusal, 'Still starting — try Stop again'); + }); + + test('only the always-present watch projects shared Starting to idle', () { + const sharedLiveActivityPhase = LiveActivityPhase.starting; + + expect( + resolveWatchSurfacePhase( + sharedPhase: sharedLiveActivityPhase, + isSessionActive: false, + isSessionStarting: false, + ), + LiveActivityPhase.idle, + ); + expect(sharedLiveActivityPhase, LiveActivityPhase.starting, + reason: 'the Live Activity consumes the shared resolver directly'); + expect( + resolveWatchSurfacePhase( + sharedPhase: sharedLiveActivityPhase, + isSessionActive: false, + isSessionStarting: true, + ), + LiveActivityPhase.starting, + ); + }); }); group('bridge command handling', () { @@ -393,6 +455,27 @@ void main() { expect(syncCalls, 1); }); + test('successful native delivery reports the exact snapshot once', + () async { + WatchSnapshot? delivered; + bridge.attachCommandHandler( + (_) => null, + onSnapshotDelivered: (snapshot) => delivered = snapshot, + ); + await Future.delayed(Duration.zero); + final snapshot = _snapshot(); + + bridge.schedule( + () => snapshot, + urgencyKeyBuilder: () => snapshot.urgencyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(delivered, same(snapshot)); + expect(syncCalls, 1); + }); + test('accepted commands reach the handler', () async { bridge.attachCommandHandler((kind) async { handled.add(kind); From 10f2e1291c21665764379d78492e8037bec7f4a6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 13:40:01 -0700 Subject: [PATCH 200/360] Retry Live Activities within a session after they become available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherited from the `liveactivities` fork, not from the watch work, and kept separate so it can be reverted on its own. `_unavailableSessionId` suppressed every further attempt for a session ID once native reported Live Activities unavailable. Turning them on in Settings mid-wardrive therefore did nothing until a new session started — and a wardrive is exactly the situation where you cannot conveniently start a new session to pick up a setting you just changed. Availability is re-checked every 30 seconds within the same session, so the activity appears once it is permitted, without retry churn while it is not. --- .../live_activity/live_activity_service.dart | 35 ++++++++++-- .../live_activity_service_test.dart | 54 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 test/services/live_activity/live_activity_service_test.dart diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart index bf5cb278..e1e85f36 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -14,17 +14,26 @@ typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function(); /// Timer ticks are represented by absolute phase deadlines, allowing SwiftUI to /// render the countdown locally without an ActivityKit update every second. class LiveActivityService { - static const MethodChannel _channel = - MethodChannel('meshmapper/live_activity'); + LiveActivityService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting + Duration unavailableRetryDelay = const Duration(seconds: 30), + }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'), + _unavailableRetryDelay = unavailableRetryDelay; + static const Duration _debounceDelay = Duration(milliseconds: 200); static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + final MethodChannel _channel; + final Duration _unavailableRetryDelay; + Timer? _scheduledUpdate; LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; String? _lastPayload; String? _lastUrgencyKey; DateTime? _lastSentAt; String? _unavailableSessionId; + DateTime? _unavailableRetryAt; bool _disposed = false; bool _didReconcileNativeState = false; Future _operationChain = Future.value(); @@ -70,9 +79,21 @@ class LiveActivityService { return; } - if (_unavailableSessionId == snapshot.sessionId) return; - if (_unavailableSessionId != null) { + if (_unavailableSessionId == snapshot.sessionId) { + final retryAt = _unavailableRetryAt; + final now = DateTime.now(); + if (retryAt != null && now.isBefore(retryAt)) { + // Authorization can be enabled in Settings while this session is + // running. One scheduled retry makes that recover without asking + // ActivityKit on every high-frequency provider notification. + _scheduledUpdate = Timer(retryAt.difference(now), _enqueueFlush); + return; + } + _unavailableSessionId = null; + _unavailableRetryAt = null; + } else if (_unavailableSessionId != null) { _unavailableSessionId = null; + _unavailableRetryAt = null; } final payload = snapshot.toMap(); @@ -103,9 +124,13 @@ class LiveActivityService { _didReconcileNativeState = true; if (result == false) { _unavailableSessionId = snapshot.sessionId; + _unavailableRetryAt = DateTime.now().add(_unavailableRetryDelay); + _scheduledUpdate = Timer(_unavailableRetryDelay, _enqueueFlush); debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); return; } + _unavailableSessionId = null; + _unavailableRetryAt = null; _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; _lastSentAt = DateTime.now(); @@ -142,6 +167,7 @@ class LiveActivityService { _lastUrgencyKey = null; _lastSentAt = null; _unavailableSessionId = null; + _unavailableRetryAt = null; } } @@ -150,5 +176,6 @@ class LiveActivityService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; _pendingSnapshotBuilder = null; + _unavailableRetryAt = null; } } diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart new file mode 100644 index 00000000..cca49bba --- /dev/null +++ b/test/services/live_activity/live_activity_service_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_service.dart'; + +LiveActivitySnapshot _snapshot() => LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Passive', + phase: LiveActivityPhase.listeningDiscovery, + phaseTitle: 'Listening…', + isConnected: true, + txCount: 0, + rxCount: 2, + discoveryCount: 1, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: DateTime.utc(2026, 8, 13), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('unavailable Live Activities retry within the same session', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_retry_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + syncCalls++; + return syncCalls > 1; + }); + final service = LiveActivityService( + channel: channel, + unavailableRetryDelay: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule(_snapshot, immediate: true); + await Future.delayed(const Duration(milliseconds: 80)); + + expect(syncCalls, 2, + reason: 'enabling the feature must not require a new session ID'); + }); +} From aef4030bf625217d174240bcbe76e80fccbc3671 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 14:00:04 -0700 Subject: [PATCH 201/360] Offer the readout as a main page, not only when the wrist drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "It makes me wish that was a permanent option alongside the map." Main page is now a choice: Map, or the readout Always-On already used. The readout's layout is untouched — it was approved on hardware, so this makes it reachable deliberately rather than redesigning it. Mode and luminance are separate axes, and only luminance existed before. The mode decides which surface is shown; the refresh rate decides how precisely it can update. So the readout chosen at full brightness shows a precise countdown, while the same readout dimmed keeps "<1 min" — the coarse value exists because Always-On refreshes about once a minute and a seconds figure would be silently wrong, which is not true when the wearer picked this view on a lit screen. That is Adam's own rule about the phone's always-on bar, applied in the other direction. Choosing the readout costs nothing it does not use: no MapReader, no MapKit, no annotations, no camera callbacks, no recentring, no panel measurement. A readout with the map still alive behind a black view would have been pointless, since being the cheapest surface in the app is the entire reason to offer it. The heard list now gets its own page whenever the readout is selected, regardless of the sheet preference. The sheet opens by tapping the map's status panel, which the readout does not have — so honouring "sheet" there would have left the list with no way in at all. A choice of main page must not make a feature unreachable. `MeshMapperForceDimmed` still forces reduced luminance specifically rather than the readout, so Always-On remains reviewable in both modes. --- ios/MeshMapperWatch/ContentView.swift | 6 +- ios/MeshMapperWatch/MapPage.swift | 276 ++++++++++++++++-------- ios/MeshMapperWatch/SettingsPage.swift | 5 + ios/MeshMapperWatch/WatchSettings.swift | 25 +++ 4 files changed, 219 insertions(+), 93 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 38ff172e..265741d6 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -31,7 +31,11 @@ struct ContentView: View { MapPage().tag(0) ControlsPage().tag(1) - if settings.nodeListPlacement == .page { + // The sheet placement is opened by tapping the map's status panel, which + // the readout does not have — so with Readout selected, honouring "sheet" + // would leave the heard list with no way in at all. A choice of main page + // must not make a feature unreachable, so the page appears regardless. + if settings.nodeListPlacement == .page || settings.mainPageContent == .readout { NavigationStack { NodeListView() .navigationTitle("Heard") diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index e37fad21..63a4adf0 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -24,6 +24,13 @@ struct MapPage: View { return environmentLuminanceReduced } + /// Content choice and display cadence are independent. A chosen readout can + /// run at full luminance with a precise timer; reduced luminance selects the + /// same approved surface because MapKit is not worth its Always-On cost. + private var showsMap: Bool { + settings.mainPageContent == .map && !isLuminanceReduced + } + @State private var camera: MapCameraPosition = .automatic /// Centre we last drove the camera to, so a camera change can be attributed @@ -129,29 +136,16 @@ struct MapPage: View { } var body: some View { - // The proxy is the only reliable way to relate a coordinate to a point on - // screen. The map draws outside its own layout frame — it ignores the - // bottom safe area, and on a 46 mm watch the frame SwiftUI reports is - // 159 pt tall against a 248 pt display — so no measurement of the view - // hierarchy predicts where a coordinate will actually land. Asking the map - // sidesteps the whole question. - MapReader { proxy in - content(proxy) - } - } - - private func content(_ proxy: MapProxy) -> some View { - ZStack { - if isLuminanceReduced { - // Always-On spends most of a long session with the wrist down. A live - // MapKit renderer and its annotations buy no useful glance information - // at reduced luminance, so remove that subtree rather than merely - // covering it. - Color.black.ignoresSafeArea() - dimmedStatus + Group { + if showsMap { + // The proxy is the only reliable way to relate a coordinate to a point + // on screen. Keep the reader inside this branch: constructing even map + // infrastructure behind the readout would defeat its battery purpose. + MapReader { proxy in + mapContent(proxy) + } } else { - map(proxy) - mapOverlay(proxy) + readoutContent } } .background( @@ -164,15 +158,10 @@ struct MapPage: View { } // Plain is intentional: `.ignoresSafeArea()` makes this reader report // the insets of its own expanded region, which are zero. The first - // nonzero value is latched while the panel is still in its safe fallback - // placement; later panel geometry depends on it, while the display's - // actual safe area is a device constant that cannot legitimately change. + // nonzero value is latched before any dependent panel geometry can feed + // back into layout. The display's actual safe area is a device constant + // that cannot legitimately change when the selected content does. ) - .onPreferenceChange(PanelFrameKey.self) { frame in - guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } - panelFrame = frame - recenterIfFollowing(proxy) - } .sheet(isPresented: $showingNodes) { NavigationStack { NodeListView() @@ -180,6 +169,27 @@ struct MapPage: View { .navigationBarTitleDisplayMode(.inline) } } + .onAppear { + #if DEBUG + // Lets the sheet layout be captured and iterated on headlessly; the + // simulator has no way to tap the bar. + if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { + showingNodes = true + } + #endif + } + } + + private func mapContent(_ proxy: MapProxy) -> some View { + ZStack { + map(proxy) + mapOverlay(proxy) + } + .onPreferenceChange(PanelFrameKey.self) { frame in + guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } + panelFrame = frame + recenterIfFollowing(proxy) + } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in recenterIfFollowing(proxy) } @@ -188,26 +198,30 @@ struct MapPage: View { .onChange(of: followSuspendedUntil) { _, until in if until == nil { recenterIfFollowing(proxy) } } - .onChange(of: isLuminanceReduced) { _, reduced in - // Camera work is forbidden while dimmed. Returning to full luminance is - // itself a state change, so following can resume immediately even if the - // phone has not produced another GPS fix yet. - if !reduced { recenterIfFollowing(proxy) } - } .onAppear { recenterIfFollowing(proxy) - #if DEBUG - // Lets the sheet layout be captured and iterated on headlessly; the - // simulator has no way to tap the bar. - if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { - showingNodes = true - } - #endif + } + .onDisappear { + // A pan's delayed resume belongs to the map. Leaving for either the + // chosen readout or Always-On must not leave map work pending off-screen. + resumeTask?.cancel() + resumeTask = nil + } + } + + private var readoutContent: some View { + ZStack { + // Always-On spends most of a long session with the wrist down, while a + // wearer may also choose this as the full-luminance main page. In both + // cases a black backing keeps the approved flat layout independent of + // whatever container presents it. + Color.black.ignoresSafeArea() + readoutStatus } } /// Map chrome remains an overlay so its measured frame can place the fix in - /// the visible band above it. Always-On has its own hierarchy and therefore + /// the visible band above it. The readout has its own hierarchy and therefore /// cannot accidentally inherit this bottom-pinned card again. private func mapOverlay(_ proxy: MapProxy) -> some View { VStack(spacing: 0) { @@ -232,16 +246,16 @@ struct MapPage: View { .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } - /// A full-screen glance surface for Always-On, not a map card without a map. + /// A full-screen glance surface, not a map card without a map. /// /// Its content remains inside the system safe area and also keeps the /// hardware-tested horizontal clearance. That is intentionally redundant at /// the bottom corners: two earlier layouts passed in the simulator and /// clipped on glass, while spare black pixels cost no compositing work. - private var dimmedStatus: some View { + private var readoutStatus: some View { VStack(alignment: .leading, spacing: 0) { if let snapshot { - dimmedPhase(snapshot) + ReadoutPhase(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced) } else { Text("Waiting for iPhone") .font(.system(size: 17, weight: .bold)) @@ -263,7 +277,7 @@ struct MapPage: View { .frame(maxWidth: .infinity, alignment: .leading) } else { ForEach(Array(heard.prefix(4))) { node in - dimmedHeardRow(node) + readoutHeardRow(node) } } } @@ -274,54 +288,15 @@ struct MapPage: View { .dynamicTypeSize(.small ... .large) .opacity(client.isStale ? 0.5 : 1.0) // Snapshot replacement may arrive with an animated transaction from an - // ancestor. Always-On changes in discrete steps; it never interpolates. + // ancestor. Readout state changes are discrete; only the native precise + // countdown updates between them at full luminance. .transaction { $0.animation = nil } } - /// The two readings an Always-On glance exists to answer, each with its own - /// line instead of competing inside the map overlay's narrow bar. - /// - /// A seconds figure can be nearly a minute wrong while watchOS throttles an - /// Always-On screen. Showing whole minutes (or “<1 min”) makes that cadence - /// honest, while the isolated minute schedule avoids waking the rest of the - /// hierarchy. A static progress fill would repeat that number less precisely - /// and spend both pixels and compositing work, so the dimmed surface omits it. - private func dimmedPhase(_ snapshot: WatchSnapshot) -> some View { - TimelineView(.periodic(from: .now, by: 60)) { context in - let lapsed = snapshot.phaseEndsAt.map { $0 <= context.date } ?? false - - VStack(alignment: .leading, spacing: 2) { - // Wrapping is intentional. The longest real phase names need two lines - // on 40 mm, and preserving every word matters more than uniform height. - Text(snapshot.phaseTitle) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(.white.opacity(lapsed ? 0.45 : 1)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if let countdown = dimmedCountdown(snapshot, at: context.date) { - Text(countdown) - .font(.system(size: 24, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - } - - private func dimmedCountdown(_ snapshot: WatchSnapshot, at date: Date) -> String? { - guard let endsAt = snapshot.phaseEndsAt else { return nil } - let remaining = endsAt.timeIntervalSince(date) - guard remaining > 0 else { return nil } - if remaining < 60 { return "<1 min" } - return "\(Int(ceil(remaining / 60))) min" - } - /// One full-width row is affordable without a basemap and gives the settled /// type dot, hex identity and quality figure enough size for arm's-length /// reading even when a six-character hash forces the overlay into one column. - private func dimmedHeardRow(_ node: WatchHeardNode) -> some View { + private func readoutHeardRow(_ node: WatchHeardNode) -> some View { HStack(spacing: 5) { Circle() .fill(Color(node.typeColor)) @@ -613,7 +588,7 @@ struct MapPage: View { // MARK: - Camera private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { - guard !isLuminanceReduced, force || isFollowing, let fix else { return } + guard showsMap, force || isFollowing, let fix else { return } let center = centerPlacing(fix, proxy: proxy) programmaticCenter = center let region = MKCoordinateRegion(center: center, span: currentSpan) @@ -670,7 +645,7 @@ struct MapPage: View { /// The deadband is what stops it: each pass lands within a couple of points, /// the next sees no error worth fixing, and it settles. private func correctPlacement(_ proxy: MapProxy) { - guard !isLuminanceReduced, isFollowing, let fix, let targetPoint, + guard showsMap, isFollowing, let fix, let targetPoint, let point = proxy.convert(fix, to: .global) else { return } guard abs(point.y - targetPoint.y) > 6 else { return } @@ -901,6 +876,123 @@ extension Comparable { } } +/// The approved full-screen phase treatment with cadence chosen independently +/// from its layout. Always-On updates too slowly to promise seconds, while a +/// wearer-selected readout at full luminance can let the native timer provide +/// a precise countdown without a one-second SwiftUI timeline. +private struct ReadoutPhase: View { + let snapshot: WatchSnapshot + let isLuminanceReduced: Bool + + @State private var deadlineLapsed: Bool + + init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) { + self.snapshot = snapshot + self.isLuminanceReduced = isLuminanceReduced + _deadlineLapsed = State( + initialValue: snapshot.phaseEndsAt.map { $0 <= Date() } ?? false + ) + } + + private var phaseKey: PhaseKey { + PhaseKey( + endsAtMs: snapshot.phaseEndsAtMs, + isLuminanceReduced: isLuminanceReduced + ) + } + + var body: some View { + Group { + if isLuminanceReduced { + // A seconds figure can be nearly a minute wrong while watchOS throttles + // Always-On. Match that cadence explicitly instead of showing false + // precision, and isolate the minute wake-up to this small subtree. + TimelineView(.periodic(from: .now, by: 60)) { context in + phase(at: context.date, usesCoarseCountdown: true) + } + } else { + phase(at: Date(), usesCoarseCountdown: false) + } + } + .task(id: phaseKey) { + await trackDeadline() + } + } + + @ViewBuilder + private func phase(at date: Date, usesCoarseCountdown: Bool) -> some View { + let lapsed = usesCoarseCountdown + ? snapshot.phaseEndsAt.map { $0 <= date } ?? false + : deadlineLapsed + + VStack(alignment: .leading, spacing: 2) { + // Wrapping is intentional. The longest real phase names need two lines + // on 40 mm, and preserving every word matters more than uniform height. + Text(snapshot.phaseTitle) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white.opacity(lapsed ? 0.45 : 1)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if usesCoarseCountdown { + if let countdown = coarseCountdown(at: date) { + countdownText(countdown) + } + } else if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date { + Text(timerInterval: date...endsAt, countsDown: true) + .font(.system(size: 24, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func countdownText(_ value: String) -> some View { + Text(value) + .font(.system(size: 24, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func coarseCountdown(at date: Date) -> String? { + guard let endsAt = snapshot.phaseEndsAt else { return nil } + let remaining = endsAt.timeIntervalSince(date) + guard remaining > 0 else { return nil } + if remaining < 60 { return "<1 min" } + return "\(Int(ceil(remaining / 60))) min" + } + + @MainActor + private func trackDeadline() async { + let now = Date() + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false + } + + // The minute timeline owns this boundary under Always-On. Sleeping for an + // exact second there would imply precision the display cannot present. + guard !isLuminanceReduced, let endsAt = snapshot.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true + } + + private struct PhaseKey: Hashable { + let endsAtMs: Double? + let isLuminanceReduced: Bool + } +} + // MARK: - Markers private struct FixPuck: View { diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 99b0d235..12becf05 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -18,6 +18,11 @@ struct SettingsPage: View { } Section("Layout") { + Picker("Main page", selection: $settings.mainPageContent) { + ForEach(WatchSettings.MainPageContent.allCases) { content in + Text(content.label).tag(content) + } + } Picker("Node list", selection: $settings.nodeListPlacement) { ForEach(WatchSettings.NodeListPlacement.allCases) { placement in Text(placement.label).tag(placement) diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 9a4f52bf..e09b4659 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -13,6 +13,7 @@ final class WatchSettings { static let showLinks = "map.showLinks" static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" + static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" } @@ -40,6 +41,24 @@ final class WatchSettings { } } + /// What occupies the app's first page at full luminance. + /// + /// Reduced luminance remains a separate system condition: either choice can + /// still enter the power-frugal readout when the wrist drops. + enum MainPageContent: String, CaseIterable, Identifiable { + case map + case readout + + var id: String { rawValue } + + var label: String { + switch self { + case .map: return "Map" + case .readout: return "Readout" + } + } + } + private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { @@ -56,6 +75,8 @@ final class WatchSettings { defaults.object(forKey: Key.mapLatitudeDelta) as? Double ?? Self.defaultMapLatitudeDelta ) + mainPageContent = (defaults.string(forKey: Key.mainPageContent)) + .flatMap(MainPageContent.init(rawValue:)) ?? .map nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) .flatMap(NodeListPlacement.init(rawValue:)) ?? .page } @@ -92,6 +113,10 @@ final class WatchSettings { } } + var mainPageContent: MainPageContent { + didSet { defaults.set(mainPageContent.rawValue, forKey: Key.mainPageContent) } + } + var nodeListPlacement: NodeListPlacement { didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } } From 001812ac81e6ef22381ace3ce7ea9968a6f7d018 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 14:09:33 -0700 Subject: [PATCH 202/360] Say the smallest true thing the dimmed countdown can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "<1 min" was the only sub-minute value, which is accurate and nearly contentless: it reads the same at fifty seconds as at five. The ladder is now "<15 sec", "<30 sec", "<1 min", with the existing minute display above. This adds precision without costing honesty, which is worth stating because it looks like the opposite trade. These are upper bounds, and an upper bound never becomes false as time passes — remaining time only decreases, so a "<15 sec" rendered just before Always-On stops refreshing is still true a minute later. The old value was equally true then, just less useful. The bound is resolved from the live deadline on every render rather than carried forward, because a new and longer phase starting under a stale display is the one case where a retained bound could be wrong. Verified on a 40 mm: "<30 sec" renders alongside the phase title with room to spare, and the tighter rung is a shorter string. Only the reduced-luminance presentation changes — the readout chosen at full brightness keeps its precise countdown, and the map panel is untouched. Worth knowing when testing this: the dimmed readout re-renders once a minute by design, so the rung only tightens when Always-On actually refreshes. Watching a running countdown will not show it step down. --- ios/MeshMapperWatch/MapPage.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 63a4adf0..c93189b0 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -960,6 +960,13 @@ private struct ReadoutPhase: View { guard let endsAt = snapshot.phaseEndsAt else { return nil } let remaining = endsAt.timeIntervalSince(date) guard remaining > 0 else { return nil } + // These are upper bounds, not estimates. Remaining time only decreases, + // so a tight statement rendered just before Always-On stops refreshing + // stays true afterward. Resolve it from this phase's live deadline on each + // render; retaining a previous phase's bound could make that guarantee + // false when a new, longer countdown begins. + if remaining < 15 { return "<15 sec" } + if remaining < 30 { return "<30 sec" } if remaining < 60 { return "<1 min" } return "\(Int(ceil(remaining / 60))) min" } From 20bc491c590ef35ce3adf9671f731fc0cf36f32d Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 14:58:14 -0700 Subject: [PATCH 203/360] Put the map/readout switch in the system's corner, not ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-bar button, so the wearer can leave the map for the low-power readout without going to Settings. The glyph names the destination and the tap writes `mainPageContent` directly, so there is one source of truth and the choice survives a relaunch. It is absent at reduced luminance, where the readout is forced anyway and a control would be inert chrome on the one surface whose value is drawing almost nothing. Placing it by hand in the top-left was the obvious route and the wrong one: that corner is curved glass, and every hand-placed element there has cost us clearance math and clipped on hardware twice. The system draws a `.topBarLeading` item in that corner itself. Doing so needs a navigation host, and watchOS is particular about where it lives. A `.verticalPage` TabView already hosts each page in a navigation controller, so a `NavigationStack` inside a page nests wrapped controllers and aborts in `PUICStackedNavigationBar layoutSubviews` — a launch crash, not a layout glitch. It reproduced on both watch sizes whenever the readout was the selected surface. The map appeared to survive only because it draws over the top safe area, so the bar never took a visible layout pass; giving the toolbar a stable host made both surfaces crash and exposed that. Without any stack the toolbar is legal but renders nothing. One stack hoisted around the whole TabView satisfies both rules, so the Heard page loses its own — it keeps its title, which now resolves against the hoisted host. Panel geometry measured pixel-identical to HEAD on 40 mm and 46 mm: same edges, same bottom gap, same size. The latched safe-area inset and every clearance derived from it are untouched. --- ios/MeshMapperWatch/ContentView.swift | 33 ++++++---- ios/MeshMapperWatch/MapPage.swift | 95 ++++++++++++++++++--------- 2 files changed, 83 insertions(+), 45 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 265741d6..d5a01bc9 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -27,26 +27,31 @@ struct ContentView: View { } var body: some View { - TabView(selection: $selection) { - MapPage().tag(0) - ControlsPage().tag(1) + // A vertical-page TabView already installs watchOS's per-page navigation + // hosting. Putting another NavigationStack inside one of those pages nests + // wrapped controllers and aborts in PUICStackedNavigationBar.layoutSubviews. + // Keep the app's one stack outside the pager so toolbar items have a host + // without making any individual page create a second one. + NavigationStack { + TabView(selection: $selection) { + MapPage().tag(0) + ControlsPage().tag(1) - // The sheet placement is opened by tapping the map's status panel, which - // the readout does not have — so with Readout selected, honouring "sheet" - // would leave the heard list with no way in at all. A choice of main page - // must not make a feature unreachable, so the page appears regardless. - if settings.nodeListPlacement == .page || settings.mainPageContent == .readout { - NavigationStack { + // The sheet placement is opened by tapping the map's status panel, which + // the readout does not have — so with Readout selected, honouring "sheet" + // would leave the heard list with no way in at all. A choice of main page + // must not make a feature unreachable, so the page appears regardless. + if settings.nodeListPlacement == .page || settings.mainPageContent == .readout { NodeListView() .navigationTitle("Heard") .navigationBarTitleDisplayMode(.inline) + .tag(2) } - .tag(2) - } - DebugPage().tag(3) - SettingsPage().tag(4) + DebugPage().tag(3) + SettingsPage().tag(4) + } + .tabViewStyle(.verticalPage) } - .tabViewStyle(.verticalPage) } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index c93189b0..3ec0e4ea 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -136,7 +136,48 @@ struct MapPage: View { } var body: some View { - Group { + pageContent + .toolbar { + if !isLuminanceReduced { + ToolbarItem(placement: .topBarLeading) { + mainPageToggle + } + } + } + .background( + GeometryReader { geo in + Color.clear + .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } + .onChange(of: geo.safeAreaInsets.bottom) { _, inset in + latchBottomSafeAreaInset(inset) + } + } + // Plain is intentional: `.ignoresSafeArea()` makes this reader report + // the insets of its own expanded region, which are zero. The first + // nonzero value is latched before any dependent panel geometry can feed + // back into layout. The display's actual safe area is a device constant + // that cannot legitimately change when the selected content does. + ) + .sheet(isPresented: $showingNodes) { + NavigationStack { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + } + } + .onAppear { + #if DEBUG + // Lets the sheet layout be captured and iterated on headlessly; the + // simulator has no way to tap the bar. + if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { + showingNodes = true + } + #endif + } + } + + private var pageContent: some View { + ZStack { if showsMap { // The proxy is the only reliable way to relate a coordinate to a point // on screen. Keep the reader inside this branch: constructing even map @@ -148,36 +189,24 @@ struct MapPage: View { readoutContent } } - .background( - GeometryReader { geo in - Color.clear - .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } - .onChange(of: geo.safeAreaInsets.bottom) { _, inset in - latchBottomSafeAreaInset(inset) - } - } - // Plain is intentional: `.ignoresSafeArea()` makes this reader report - // the insets of its own expanded region, which are zero. The first - // nonzero value is latched before any dependent panel geometry can feed - // back into layout. The display's actual safe area is a device constant - // that cannot legitimately change when the selected content does. - ) - .sheet(isPresented: $showingNodes) { - NavigationStack { - NodeListView() - .navigationTitle("Heard") - .navigationBarTitleDisplayMode(.inline) - } - } - .onAppear { - #if DEBUG - // Lets the sheet layout be captured and iterated on headlessly; the - // simulator has no way to tap the bar. - if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { - showingNodes = true - } - #endif + } + + private var mainPageToggle: some View { + Button { + settings.mainPageContent = settings.mainPageContent == .map + ? .readout + : .map + } label: { + // The glyph names the destination, following the convention for a + // two-state corner control. The framed list survives toolbar scaling on + // 40 mm better than bare bullets and still reads as the full readout. + Image(systemName: settings.mainPageContent == .map + ? "list.bullet.rectangle" + : "map.fill") } + .accessibilityLabel(settings.mainPageContent == .map + ? "Show readout" + : "Show map") } private func mapContent(_ proxy: MapProxy) -> some View { @@ -502,7 +531,11 @@ struct MapPage: View { noteCameraChange(context.region.center) correctPlacement(proxy) } - .ignoresSafeArea(edges: .bottom) + // The shell's navigation host supplies the system toolbar placement but + // must not buy it by shortening the basemap. Only MapKit extends under that + // top chrome; the overlay remains in the safe content region, keeping its + // transient trailing recentre button away from the leading toolbar control. + .ignoresSafeArea(edges: [.top, .bottom]) } @MapContentBuilder From 5ad2c8935eed9ccd67730d1f00fd9230ded334d6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 15:29:43 -0700 Subject: [PATCH 204/360] Hold the readout still when the wrist drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At full luminance the readout sits below the navigation bar that hosts the map/readout toggle. Hardware hides that bar in Always-On, so the same surface rendered several points higher — the shift Adam saw when comparing the two. Rather than encode which way it moves, the readout now measures the full-luminance bar and restores only what disappears: `max(0, latched - current)`. Equal insets give zero and nothing moves; a missing reference is deliberately a no-op, the same stance the panel takes when its own measurement is absent. Two details that are easy to get wrong and were both measured, not reasoned. The inset settles *upward* — 28.0 then 35.5 on 40 mm, 44.5 then 59.75 on 46 mm — as the bar grows to fit its toolbar item, so the reference is the largest value seen, not the first. First-nonzero would have under-corrected by 7.5 pt and 15.25 pt respectively, which reads as "nearly fixed" rather than as a bug. And the map reports a different inset again (47.5 and 62.0) because it extends beneath the top chrome, so only the readout may set the reference; borrowing the map's would push the readout down past where it belongs. The top rule therefore differs from the bottom's first-nonzero latch on purpose. The bottom value drives padding and can feed back into the measurement that produced it; this one drives a visual offset that cannot, so tracking a maximum is safe here and not there. Full-luminance readout measured unmoved to 0.00 pt on both sizes. The Always-On half is verifiable only on hardware. --- ios/MeshMapperWatch/MapPage.swift | 59 ++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 3ec0e4ea..19171eac 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -67,6 +67,8 @@ struct MapPage: View { /// The panel's frame in global coordinates, so the camera can keep the fix /// out from behind it. @State private var panelFrame: CGRect = .zero + @State private var latchedTopSafeAreaInset: CGFloat = 0 + @State private var currentTopSafeAreaInset: CGFloat = 0 @State private var bottomSafeAreaInset: CGFloat = 0 /// Only one subtree sets this, but every *other* subtree still contributes @@ -147,16 +149,37 @@ struct MapPage: View { .background( GeometryReader { geo in Color.clear - .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } + .onAppear { + noteTopSafeAreaInset(geo.safeAreaInsets.top) + latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) + } + .onChange(of: geo.safeAreaInsets.top) { _, inset in + noteTopSafeAreaInset(inset) + } .onChange(of: geo.safeAreaInsets.bottom) { _, inset in latchBottomSafeAreaInset(inset) } + .onChange(of: isLuminanceReduced) { _, reduced in + // If the view first appeared while dimmed, returning to full + // luminance on the readout is the first valid opportunity to + // establish the reference even when the inset did not change. + if !reduced && !showsMap { + noteTopSafeAreaInset(geo.safeAreaInsets.top) + } + } + .onChange(of: showsMap) { _, mapIsShowing in + // Switching from the map supplies a readout reference even if + // both surfaces initially report the same transient inset. + if !mapIsShowing && !isLuminanceReduced { + noteTopSafeAreaInset(geo.safeAreaInsets.top) + } + } } // Plain is intentional: `.ignoresSafeArea()` makes this reader report - // the insets of its own expanded region, which are zero. The first - // nonzero value is latched before any dependent panel geometry can feed - // back into layout. The display's actual safe area is a device constant - // that cannot legitimately change when the selected content does. + // the insets of its own expanded region, which are zero. The bottom's + // first nonzero value is latched before its dependent panel geometry can + // feed back into layout; the top follows the separate visual-only rule + // in `noteTopSafeAreaInset`. ) .sheet(isPresented: $showingNodes) { NavigationStack { @@ -246,9 +269,21 @@ struct MapPage: View { // whatever container presents it. Color.black.ignoresSafeArea() readoutStatus + // Hardware may collapse the navigation bar in Always-On even though + // the simulator's force-dimmed path cannot. Restore only an inset that + // disappeared relative to the full-luminance reference: equal insets + // produce zero, while a missing measurement is deliberately a no-op. + // A visual offset keeps this correction out of the geometry proposal + // being measured above, closing the layout feedback loop. + .offset(y: readoutTopOffset) } } + private var readoutTopOffset: CGFloat { + guard latchedTopSafeAreaInset > 0 else { return 0 } + return max(0, latchedTopSafeAreaInset - currentTopSafeAreaInset) + } + /// Map chrome remains an overlay so its measured frame can place the fix in /// the visible band above it. The readout has its own hierarchy and therefore /// cannot accidentally inherit this bottom-pinned card again. @@ -349,6 +384,20 @@ struct MapPage: View { bottomSafeAreaInset = inset } + private func noteTopSafeAreaInset(_ inset: CGFloat) { + guard inset.isFinite else { return } + currentTopSafeAreaInset = max(0, inset) + + // Only the full-luminance readout is the approved reference; the map extends + // beneath top chrome and reports a different inset. Keep its largest value + // because the navigation bar settles upward after installing its toolbar. + // This intentionally differs from the bottom inset's first-nonzero latch: + // the bottom value drives padding and can feed back into its measurement, + // while this value drives a visual offset that cannot affect geometry. + guard !isLuminanceReduced, !showsMap, inset > 0 else { return } + latchedTopSafeAreaInset = max(latchedTopSafeAreaInset, inset) + } + /// Phase and Top Heard in one panel. /// /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity, From 00759dc86bb33ba0b730a5127a34b773aa8ea037 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 15:45:12 -0700 Subject: [PATCH 205/360] Take back the height Always-On returns, not just the position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous correction moved the readout with `.offset(y:)`, which repositions without consuming space. Always-On hands back the hidden navigation bar's height, so the container grew by that much and the `Spacer(minLength: 6)` between the phase block and Top Heard absorbed the slack. The top-anchored phase block landed correctly and Top Heard was pushed down — which is exactly what Adam saw: headers matching, Top Heard low. Applying the same value as `.padding(.top,)` consumes the returned height as well as setting the origin, so the content box matches the full-luminance reference in size and position. The spacer then has the same slack it has at full luminance, which is none, and Top Heard packs where it does there. Padding a descendant cannot disturb the measurement: the reader takes its safe-area inset from the navigation host, which our padding does not feed. That is also what still permits the top reference to track a maximum while the bottom latches its first nonzero value. Full-luminance readout measured unmoved to 0.00 pt on both sizes again. --- ios/MeshMapperWatch/MapPage.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 19171eac..bd2249b2 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -273,9 +273,11 @@ struct MapPage: View { // the simulator's force-dimmed path cannot. Restore only an inset that // disappeared relative to the full-luminance reference: equal insets // produce zero, while a missing measurement is deliberately a no-op. - // A visual offset keeps this correction out of the geometry proposal - // being measured above, closing the layout feedback loop. - .offset(y: readoutTopOffset) + // Layout padding also consumes the height the collapsed bar returned, + // keeping the flexible middle spacer identical to the reference. The + // reader above receives its safe-area inset from the navigation host; + // padding this descendant cannot alter that system-supplied value. + .padding(.top, readoutTopOffset) } } @@ -393,7 +395,8 @@ struct MapPage: View { // because the navigation bar settles upward after installing its toolbar. // This intentionally differs from the bottom inset's first-nonzero latch: // the bottom value drives padding and can feed back into its measurement, - // while this value drives a visual offset that cannot affect geometry. + // while the top value comes from the parent navigation host and drives + // padding only inside its readout child, which cannot change that inset. guard !isLuminanceReduced, !showsMap, inset > 0 else { return } latchedTopSafeAreaInset = max(latchedTopSafeAreaInset, inset) } From 48aa208b3961fef6cfe6187c3dbc4ca12882924a Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 16:09:33 -0700 Subject: [PATCH 206/360] Start the map at 250 m, and stop MapKit overwriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam asked for a much tighter starting zoom. The default drops from 0.0045 to 0.00225 degrees latitude — about 250 m north-south — with a one-time `map.zoomDefaultsVersion` migration, because the wearer's zoom is remembered and every watch that has shown the map already has a stored value that would otherwise win. The constant alone would not have worked. `noteRenderedRegion` guarded only on `programmaticCenter != nil`, which proves a region was requested, not that MapKit rendered it. MapKit reports its `.automatic` annotation fit for several callbacks after our first assignment, and those spans were persisted as though the wearer had chosen them. A fresh install stored 0.136 degrees on 40 mm and 0.0875 on 46 mm — 15 km and 9.7 km — and read that back on the next launch, so the map started wide and stayed there. That is almost certainly the behaviour being reported, and it would have eaten the new default within seconds. The centre had already been given this treatment: nothing counts as a pan until MapKit confirms a centre we asked for. The span now gets the same handshake. Traced on a fresh install: three automatic callbacks at 0.136 rejected at 59x the request, then a rendered 0.00225 confirms, after which callbacks reach the normal write-back and decline only because the value is unchanged. The confirmation tolerance is deliberately loose at 25%. It separates our request from a fit dozens of times wider, and an exact test could wait forever if MapKit adjusted a span for display geometry, silently disabling zoom memory — a quieter failure than the one being fixed. Fresh install and warm relaunch both hold 0.00225 on 40 mm and 46 mm. --- ios/MeshMapperWatch/MapPage.swift | 42 +++++++++++++++++++------ ios/MeshMapperWatch/WatchSettings.swift | 30 ++++++++++++------ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index bd2249b2..51077883 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -43,9 +43,20 @@ struct MapPage: View { /// `noteCameraChange`. @State private var hasConfirmedRequestedCenter = false + /// Span counterpart to the centre handshake: MapKit can report its old + /// `.automatic` fit after we assign a region, so assignment alone is not + /// evidence that a rendered zoom came from us. + @State private var hasConfirmedRequestedSpan = false + /// Metres of disagreement before a camera change counts as a real pan. private static let panTolerance: CLLocationDistance = 40 + /// Initial confirmation only needs to distinguish our request from the much + /// wider automatic annotation fit. MapKit may adjust a requested region for + /// display geometry, so a generous tolerance avoids quietly disabling Crown + /// persistence by waiting forever for an exact span. + private static let spanConfirmationTolerance = 0.25 + /// How long a pan pauses following before the map drifts back to the fix. private static let resumeFollowAfter: TimeInterval = 8 @@ -738,8 +749,9 @@ struct MapPage: View { } /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one - /// independent zoom value. A fresh install starts at 0.0045 degrees, about - /// 500 m north-south, and later launches reuse the wearer's Crown setting. + /// independent zoom value. After the one-time defaults migration it starts + /// at 0.00225 degrees, about 250 m north-south, and later launches reuse the + /// wearer's Crown setting. private var currentSpan: MKCoordinateSpan { MKCoordinateSpan( latitudeDelta: settings.mapLatitudeDelta, @@ -756,19 +768,29 @@ struct MapPage: View { /// starting point. private func noteRenderedRegion(_ region: MKCoordinateRegion) { renderedCenter = region.center - // Before our first camera update this region belongs to `.automatic`, - // which fits every annotation and can span a continent. It is not a wearer - // choice and must never become the remembered zoom. Once we have driven - // the camera, rendered spans include our requested value and later Digital - // Crown changes, both of which should persist. + // `.automatic` can report its annotation fit even after we assign our first + // region, so `programmaticCenter != nil` proves only that a request was + // made, not that MapKit rendered it. Persist nothing until the rendered + // span confirms the request; later deviations are Crown zooms and remain + // eligible for the normal write-back below. guard programmaticCenter != nil else { return } + let rendered = region.span.latitudeDelta + let requested = settings.mapLatitudeDelta + guard rendered.isFinite, requested.isFinite, requested > 0 else { return } + + guard hasConfirmedRequestedSpan else { + let relativeDifference = abs(rendered - requested) / requested + if relativeDifference <= Self.spanConfirmationTolerance { + hasConfirmedRequestedSpan = true + } + return + } + // Only on a real change. Every follow update produces a camera change, and // writing an identical value would persist and invalidate on each one, // re-rendering the map for nothing. - let rendered = region.span.latitudeDelta - let stored = settings.mapLatitudeDelta - guard stored > 0, abs(rendered - stored) / stored > 0.01 else { return } + guard abs(rendered - requested) / requested > 0.01 else { return } settings.mapLatitudeDelta = rendered } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index e09b4659..497acbc2 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -13,13 +13,15 @@ final class WatchSettings { static let showLinks = "map.showLinks" static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" + static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion" static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" } - /// Roughly 500 m north-south: one degree of latitude is about 111 km. - static let defaultMapLatitudeDelta = 0.0045 + /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. + static let defaultMapLatitudeDelta = 0.00225 private static let mapLatitudeDeltaLimits = 0.0005...0.5 + private static let mapZoomDefaultsVersion = 1 /// Where the recently-responded list lives. /// @@ -68,13 +70,23 @@ final class WatchSettings { // Following the fix is the useful default while driving; absent any // stored value `bool(forKey:)` returns false, so invert an explicit flag. follow = defaults.object(forKey: Key.follow) as? Bool ?? true - // `double(forKey:)` turns absence into zero, which would silently select - // the minimum zoom. Preserve the distinction so fresh installs get the - // deliberate ~500 m default instead. - mapLatitudeDelta = Self.clampedMapLatitudeDelta( - defaults.object(forKey: Key.mapLatitudeDelta) as? Double - ?? Self.defaultMapLatitudeDelta - ) + // Rendered MapKit spans drift from the region requested, so an equality + // test cannot identify the old default reliably. A versioned migration + // deliberately resets every existing install once; after recording the + // version, ordinary Crown write-back owns the value again. + if defaults.integer(forKey: Key.mapZoomDefaultsVersion) < Self.mapZoomDefaultsVersion { + mapLatitudeDelta = Self.defaultMapLatitudeDelta + defaults.set(Self.defaultMapLatitudeDelta, forKey: Key.mapLatitudeDelta) + defaults.set(Self.mapZoomDefaultsVersion, forKey: Key.mapZoomDefaultsVersion) + } else { + // `double(forKey:)` turns absence into zero, which would silently select + // the minimum zoom. Preserve the distinction so an absent value gets the + // deliberate ~250 m default instead. + mapLatitudeDelta = Self.clampedMapLatitudeDelta( + defaults.object(forKey: Key.mapLatitudeDelta) as? Double + ?? Self.defaultMapLatitudeDelta + ) + } mainPageContent = (defaults.string(forKey: Key.mainPageContent)) .flatMap(MainPageContent.init(rawValue:)) ?? .map nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) From 590e5b7dbce933fb0b41293a7e19a751d59b3022 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 16:53:19 -0700 Subject: [PATCH 207/360] Put session control in the watch's second corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top bar holds exactly one item per side — a second leading item is silently dropped, which I confirmed by rendering it — so the trailing slot is the only one left, and the system moves the clock to centre to make room. It now carries session control. Which control owns the slot follows stable facts, never the live ping gate: no session -> Start running, ping applicable + opted in -> Ping running, otherwise -> Stop That distinction is the point. `canManualPing` flickers with cooldowns, RX windows and discovery windows, so keying the slot on it would change the button's identity under a moving thumb and turn a ping into a stop. The phone now publishes `manualPingApplicable` — connected, TX permitted by the region, and no Active, Hybrid or Trace session running — which holds still for the duration of a session. Live availability only enables or disables, so a cooldown greys the ping button rather than replacing it. Ping cannot fire during Active or Hybrid at all; `isTxModeRunning` gates it in the shared availability rule and again in the phone's own button. So the slot correctly shows Stop there rather than a permanently dead ping. Stop and Ping take two taps within three seconds, reusing the arming pattern manual ping already had, because this button sits under the thumb on a surface that is also panned. Start does not confirm; starting is recoverable and stopping loses the session. Start now sends an explicit mode. The snapshot advertises which the zone permits, the wrist picks its default from those, and the phone revalidates and refuses rather than downgrading. Settings gains Default start mode, defaulting to Passive with Hybrid offered only where it is permitted, and states plainly when a stored Hybrid cannot be honoured. Main page and Node list move above Map, so the choice that decides whether the map toggles matter is no longer below them. All six slot states driven through sample data on both watch sizes. `effectiveStartMode` is currently computed in two places; consolidating it is the first item of the next change. --- ios/MeshMapperWatch/MapPage.swift | 152 ++++++++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 40 ++++- ios/MeshMapperWatch/SettingsPage.swift | 52 +++++- ios/MeshMapperWatch/WatchSessionClient.swift | 3 +- ios/MeshMapperWatch/WatchSettings.swift | 47 ++++++ ios/Shared/MeshMapperWatchPayload.swift | 138 ++++++++++++++++ lib/providers/app_state_provider.dart | 31 +++- lib/services/watch/watch_bridge_service.dart | 7 +- lib/services/watch/watch_models.dart | 72 +++++++++ .../watch/watch_wire_contract_test.dart | 126 +++++++++++++-- 10 files changed, 641 insertions(+), 27 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 51077883..8cb88710 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -74,6 +74,22 @@ struct MapPage: View { } @State private var showingNodes = false + @State private var armedToolbarControl: TrailingToolbarControl? + @State private var disarmToolbarTask: Task? + + private enum TrailingToolbarControl: Equatable { + case start + case stop + case ping + + var command: WatchCommand.Kind { + switch self { + case .start: return .startSession + case .stop: return .stopSession + case .ping: return .manualPing + } + } + } /// The panel's frame in global coordinates, so the camera can keep the fix /// out from behind it. @@ -155,6 +171,9 @@ struct MapPage: View { ToolbarItem(placement: .topBarLeading) { mainPageToggle } + ToolbarItem(placement: .topBarTrailing) { + trailingToolbarButton + } } } .background( @@ -208,6 +227,18 @@ struct MapPage: View { } #endif } + .onChange(of: trailingToolbarControl) { _, _ in + // Stable facts own the slot, but a session transition still changes + // its meaning. Never carry an armed confirmation into a new action. + disarmToolbarControl() + } + .onChange(of: trailingToolbarControlIsEnabled) { _, enabled in + if !enabled { disarmToolbarControl() } + } + .onChange(of: isLuminanceReduced) { _, reduced in + if reduced { disarmToolbarControl() } + } + .onDisappear { disarmToolbarControl() } } private var pageContent: some View { @@ -243,6 +274,127 @@ struct MapPage: View { : "Show map") } + /// Slot identity follows stable session and regional facts, never the live + /// ping gate. A cooldown may disable Ping, but cannot replace it with Stop + /// while a finger is already moving toward the corner. + private var trailingToolbarControl: TrailingToolbarControl { + guard client.snapshot?.controls.isSessionActive == true else { return .start } + if settings.showPingWhenAvailable, + client.snapshot?.controls.manualPingApplicable == true + { + return .ping + } + return .stop + } + + private var trailingToolbarControlIsEnabled: Bool { + guard client.pendingCommand != trailingToolbarControl.command else { return false } + switch trailingToolbarControl { + case .start, .stop: + return client.snapshot?.controls.canStartStop == true + case .ping: + return client.snapshot?.controls.canManualPing == true + } + } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + let available = client.snapshot?.availableStartModes ?? ["passive"] + return available.contains(settings.defaultStartMode.rawValue) + ? settings.defaultStartMode + : .passive + } + + private var trailingToolbarButton: some View { + let control = trailingToolbarControl + let pending = client.pendingCommand == control.command + let armed = armedToolbarControl == control + + return Button { + handleToolbarControl(control) + } label: { + ZStack { + if pending { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: toolbarIcon(for: control, armed: armed)) + } + } + .frame(width: 18, height: 18) + } + .tint(toolbarTint(for: control, armed: armed)) + .disabled(!trailingToolbarControlIsEnabled) + .accessibilityLabel(toolbarAccessibilityLabel(for: control, armed: armed)) + } + + private func handleToolbarControl(_ control: TrailingToolbarControl) { + switch control { + case .start: + disarmToolbarControl() + client.send(.startSession, mode: effectiveStartMode.rawValue) + case .stop, .ping: + guard armedToolbarControl == control else { + armToolbarControl(control) + return + } + disarmToolbarControl() + client.send(control.command) + } + } + + private func toolbarIcon( + for control: TrailingToolbarControl, + armed: Bool + ) -> String { + if armed { return "checkmark" } + switch control { + case .start: return "play.fill" + case .stop: return "stop.fill" + case .ping: return "dot.radiowaves.left.and.right" + } + } + + private func toolbarTint( + for control: TrailingToolbarControl, + armed: Bool + ) -> Color { + guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } + if armed { return WatchPalette.armed } + switch control { + case .start: return WatchPalette.start + case .stop: return WatchPalette.stop + case .ping: return WatchPalette.ping + } + } + + private func toolbarAccessibilityLabel( + for control: TrailingToolbarControl, + armed: Bool + ) -> String { + switch control { + case .start: return "Start \(effectiveStartMode.label) session" + case .stop: return armed ? "Confirm stop session" : "Stop session" + case .ping: return armed ? "Confirm manual ping" : "Manual ping" + } + } + + private func armToolbarControl(_ control: TrailingToolbarControl) { + disarmToolbarTask?.cancel() + armedToolbarControl = control + disarmToolbarTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled, armedToolbarControl == control else { return } + armedToolbarControl = nil + disarmToolbarTask = nil + } + } + + private func disarmToolbarControl() { + disarmToolbarTask?.cancel() + disarmToolbarTask = nil + armedToolbarControl = nil + } + private func mapContent(_ proxy: MapProxy) -> some View { ZStack { map(proxy) diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 474890f1..5dcca5ce 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -12,8 +12,10 @@ import Foundation /// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live /// countdown, the between-cycle wait, or a deadline the phone did not replace. /// Listening is the default so existing capture commands keep their behaviour. -/// Pass `-MeshMapperSampleControls active|idle|blocked|cooldown` to review each -/// controls page state; active is the default. +/// Pass +/// `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive` +/// to review Start, live Ping, disabled-but-stable Ping, and both reasons Stop +/// keeps the slot; active is the default. /// /// DEBUG-only, and never reached unless that argument is passed, so it cannot /// leak into a shipping build or mask a real transport failure. @@ -40,13 +42,17 @@ enum SampleSnapshot { samplePhase = ("listening", "Listening…", now + 42_000, 60_000) } + let sampleControlState = UserDefaults.standard.string( + forKey: "MeshMapperSampleControls" + ) let sampleControls: WatchControls - switch UserDefaults.standard.string(forKey: "MeshMapperSampleControls") { + switch sampleControlState { case "idle": sampleControls = WatchControls( canStartStop: true, canManualPing: false, isSessionActive: false, + manualPingApplicable: true, manualCooldownEndsAtMs: nil, blockedReason: nil ) @@ -55,6 +61,7 @@ enum SampleSnapshot { canStartStop: false, canManualPing: false, isSessionActive: false, + manualPingApplicable: false, manualCooldownEndsAtMs: nil, blockedReason: "This zone is currently passive-only" ) @@ -64,14 +71,34 @@ enum SampleSnapshot { canStartStop: true, canManualPing: false, isSessionActive: true, + manualPingApplicable: true, manualCooldownEndsAtMs: now + 12_000, blockedReason: nil ) + case "passiveOnly": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: "Passive Only" + ) + case "txActive": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) default: sampleControls = WatchControls( canStartStop: true, canManualPing: true, isSessionActive: true, + manualPingApplicable: true, manualCooldownEndsAtMs: nil, blockedReason: nil ) @@ -144,7 +171,9 @@ enum SampleSnapshot { return WatchSnapshot( wireVersion: MeshMapperWatchWire.version, sessionId: "sample", - mode: "Active", + // Ping can own the active-session slot only during Passive monitoring; + // Active and Hybrid are TX modes and keep Stop reachable instead. + mode: sampleControlState == "txActive" ? "Hybrid" : "Passive", phase: samplePhase.name, phaseTitle: samplePhase.title, phaseDetail: "Waiting for echoes", @@ -158,6 +187,9 @@ enum SampleSnapshot { traceCount: 0, queueSize: 2, pingColor: green, + availableStartModes: sampleControlState == "passiveOnly" + ? ["passive"] + : ["passive", "hybrid"], geo: WatchGeo( you: WatchPosition( lat: originLat + 0.006, diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 12becf05..6d899ffa 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -6,18 +6,26 @@ import SwiftUI /// which owns the radio and the guards. struct SettingsPage: View { @Environment(WatchSettings.self) private var settings + @Environment(WatchSessionClient.self) private var client + + private var availableStartModes: [WatchSettings.DefaultStartMode] { + let available = client.snapshot?.availableStartModes ?? ["passive"] + return WatchSettings.DefaultStartMode.allCases.filter { + $0 == .passive || available.contains($0.rawValue) + } + } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + availableStartModes.contains(settings.defaultStartMode) + ? settings.defaultStartMode + : .passive + } var body: some View { @Bindable var settings = settings List { - Section("Map") { - Toggle("Satellite", isOn: $settings.satellite) - Toggle("Follow position", isOn: $settings.follow) - Toggle("Lines to repeaters", isOn: $settings.showLinks) - } - - Section("Layout") { + Section("Display") { Picker("Main page", selection: $settings.mainPageContent) { ForEach(WatchSettings.MainPageContent.allCases) { content in Text(content.label).tag(content) @@ -29,6 +37,36 @@ struct SettingsPage: View { } } } + + Section("Map") { + Toggle("Satellite", isOn: $settings.satellite) + Toggle("Follow position", isOn: $settings.follow) + Toggle("Lines to repeaters", isOn: $settings.showLinks) + } + + Section("Controls") { + Picker( + "Default start mode", + selection: Binding( + get: { effectiveStartMode }, + set: { settings.defaultStartMode = $0 } + ) + ) { + ForEach(availableStartModes) { mode in + Text(mode.label).tag(mode) + } + } + + if settings.defaultStartMode != effectiveStartMode { + Text("Hybrid unavailable here; Start uses Passive") + .foregroundStyle(WatchPalette.tertiary) + } + + Toggle( + "When available, show ping option", + isOn: $settings.showPingWhenAvailable + ) + } } .font(.caption) } diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 7fac894b..a00ee65e 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -94,7 +94,7 @@ final class WatchSessionClient: NSObject { /// where `sendMessage` can execute the command yet fail its reply as /// undeliverable. One queued path avoids both that false failure and the /// duplicate-transmit risk of retrying an ambiguously delivered message. - func send(_ kind: WatchCommand.Kind, silent: Bool = false) { + func send(_ kind: WatchCommand.Kind, mode: String? = nil, silent: Bool = false) { guard let session, session.activationState == .activated else { if !silent { setLastRefusal("Not connected to iPhone") } return @@ -102,6 +102,7 @@ final class WatchSessionClient: NSObject { let command = WatchCommand( kind: kind, + mode: mode, id: UUID().uuidString, issuedAtMs: Date().timeIntervalSince1970 * 1000 ) diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 497acbc2..a47b952f 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -16,6 +16,8 @@ final class WatchSettings { static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion" static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" + static let defaultStartMode = "controls.defaultStartMode" + static let showPingWhenAvailable = "controls.showPingWhenAvailable" } /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. @@ -61,6 +63,22 @@ final class WatchSettings { } } + /// The explicit mode a wrist Start will request. The phone remains the final + /// authority and can refuse Hybrid if zone policy changed after the snapshot. + enum DefaultStartMode: String, CaseIterable, Identifiable { + case passive + case hybrid + + var id: String { rawValue } + + var label: String { + switch self { + case .passive: return "Passive" + case .hybrid: return "Hybrid" + } + } + } + private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { @@ -91,6 +109,11 @@ final class WatchSettings { .flatMap(MainPageContent.init(rawValue:)) ?? .map nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) .flatMap(NodeListPlacement.init(rawValue:)) ?? .page + defaultStartMode = (defaults.string(forKey: Key.defaultStartMode)) + .flatMap(DefaultStartMode.init(rawValue:)) ?? .passive + showPingWhenAvailable = defaults.object( + forKey: Key.showPingWhenAvailable + ) as? Bool ?? false } /// Apple imagery rather than the standard basemap. Mirrors the iOS app's @@ -133,12 +156,36 @@ final class WatchSettings { didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } } + var defaultStartMode: DefaultStartMode { + didSet { defaults.set(defaultStartMode.rawValue, forKey: Key.defaultStartMode) } + } + + var showPingWhenAvailable: Bool { + didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } + } + private static func clampedMapLatitudeDelta(_ value: Double) -> Double { guard value.isFinite else { return defaultMapLatitudeDelta } return min(max(value, mapLatitudeDeltaLimits.lowerBound), mapLatitudeDeltaLimits.upperBound) } } +/// Shared control chrome, lifted from the phone rather than accumulating +/// unrelated system tints across wrist surfaces. Ping and repeater data colours +/// are different: those arrive resolved through the phone's colour-vision +/// palette, while these action semantics remain fixed just as today's red and +/// green buttons do. Revisit this boundary if watch controls become palette-aware. +enum WatchPalette { + static let start = Color(red: 34 / 255, green: 197 / 255, blue: 94 / 255) + static let stop = Color(red: 189 / 255, green: 33 / 255, blue: 48 / 255) + static let ping = Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) + static let armed = Color(red: 245 / 255, green: 158 / 255, blue: 11 / 255) + static let disabled = Color(red: 51 / 255, green: 65 / 255, blue: 85 / 255) + static let secondary = Color(red: 71 / 255, green: 85 / 255, blue: 105 / 255) + static let tertiary = Color(red: 148 / 255, green: 163 / 255, blue: 184 / 255) + static let cornerRadius: CGFloat = 12 +} + extension Color { /// Colours arrive already resolved from the phone's colour-vision palette, /// so the watch never needs to know which palette is active. diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index c521a8db..02633f59 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -24,6 +24,11 @@ enum MeshMapperWatchWire { /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and /// ping-type colour — instead of richer per-echo data. Hop counts are gone: /// the overlay is fed direct repeaters only. + /// + /// Additive optional fields stay on v2: this decoder defaults an older + /// phone's missing mode list to Passive and missing Ping applicability to + /// false, while an older phone safely ignores a command's new mode field. + /// Rejecting that pair would add no protection. static let version = 2 /// Caps, mirrored in Dart. Enforced on send *and* validated on receive. @@ -126,10 +131,51 @@ struct WatchControls: Codable, Hashable { let canStartStop: Bool let canManualPing: Bool let isSessionActive: Bool + /// Stable slot ownership, separate from transient ping enablement. + let manualPingApplicable: Bool /// Absolute deadline for the 15 s manual cooldown, if one is running. let manualCooldownEndsAtMs: Double? /// Human-readable reason a control is unavailable ("Not connected"). let blockedReason: String? + + init( + canStartStop: Bool, + canManualPing: Bool, + isSessionActive: Bool, + manualPingApplicable: Bool = false, + manualCooldownEndsAtMs: Double?, + blockedReason: String? + ) { + self.canStartStop = canStartStop + self.canManualPing = canManualPing + self.isSessionActive = isSessionActive + self.manualPingApplicable = manualPingApplicable + self.manualCooldownEndsAtMs = manualCooldownEndsAtMs + self.blockedReason = blockedReason + } + + private enum CodingKeys: String, CodingKey { + case canStartStop, canManualPing, isSessionActive + case manualPingApplicable, manualCooldownEndsAtMs, blockedReason + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + canStartStop = try values.decode(Bool.self, forKey: .canStartStop) + canManualPing = try values.decode(Bool.self, forKey: .canManualPing) + isSessionActive = try values.decode(Bool.self, forKey: .isSessionActive) + // Additive v2 field. An older phone cannot promise stable Ping ownership, + // so preserving the established Stop slot is the conservative default. + manualPingApplicable = try values.decodeIfPresent( + Bool.self, + forKey: .manualPingApplicable + ) ?? false + manualCooldownEndsAtMs = try values.decodeIfPresent( + Double.self, + forKey: .manualCooldownEndsAtMs + ) + blockedReason = try values.decodeIfPresent(String.self, forKey: .blockedReason) + } } // MARK: - Haptics @@ -181,11 +227,100 @@ struct WatchSnapshot: Codable, Hashable { /// Colour of the most recent completed ping result. let pingColor: WatchColor? + /// Lowercase mode names the phone currently permits for a wrist start. + /// This is policy resolved by the phone, not enough raw state for the watch + /// to derive policy independently. + let availableStartModes: [String] + let geo: WatchGeo let controls: WatchControls let cue: WatchHapticCue? let updatedAtMs: Double + init( + wireVersion: Int, + sessionId: String, + mode: String, + phase: String, + phaseTitle: String, + phaseDetail: String?, + phaseEndsAtMs: Double?, + phaseDurationMs: Int?, + isConnected: Bool, + zoneCode: String?, + txCount: Int, + rxCount: Int, + discoveryCount: Int, + traceCount: Int, + queueSize: Int, + pingColor: WatchColor?, + availableStartModes: [String] = ["passive"], + geo: WatchGeo, + controls: WatchControls, + cue: WatchHapticCue?, + updatedAtMs: Double + ) { + self.wireVersion = wireVersion + self.sessionId = sessionId + self.mode = mode + self.phase = phase + self.phaseTitle = phaseTitle + self.phaseDetail = phaseDetail + self.phaseEndsAtMs = phaseEndsAtMs + self.phaseDurationMs = phaseDurationMs + self.isConnected = isConnected + self.zoneCode = zoneCode + self.txCount = txCount + self.rxCount = rxCount + self.discoveryCount = discoveryCount + self.traceCount = traceCount + self.queueSize = queueSize + self.pingColor = pingColor + self.availableStartModes = availableStartModes + self.geo = geo + self.controls = controls + self.cue = cue + self.updatedAtMs = updatedAtMs + } + + private enum CodingKeys: String, CodingKey { + case wireVersion, sessionId, mode, phase, phaseTitle, phaseDetail + case phaseEndsAtMs, phaseDurationMs, isConnected, zoneCode + case txCount, rxCount, discoveryCount, traceCount, queueSize, pingColor + case availableStartModes, geo, controls, cue, updatedAtMs + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + wireVersion = try values.decode(Int.self, forKey: .wireVersion) + sessionId = try values.decode(String.self, forKey: .sessionId) + mode = try values.decode(String.self, forKey: .mode) + phase = try values.decode(String.self, forKey: .phase) + phaseTitle = try values.decode(String.self, forKey: .phaseTitle) + phaseDetail = try values.decodeIfPresent(String.self, forKey: .phaseDetail) + phaseEndsAtMs = try values.decodeIfPresent(Double.self, forKey: .phaseEndsAtMs) + phaseDurationMs = try values.decodeIfPresent(Int.self, forKey: .phaseDurationMs) + isConnected = try values.decode(Bool.self, forKey: .isConnected) + zoneCode = try values.decodeIfPresent(String.self, forKey: .zoneCode) + txCount = try values.decode(Int.self, forKey: .txCount) + rxCount = try values.decode(Int.self, forKey: .rxCount) + discoveryCount = try values.decode(Int.self, forKey: .discoveryCount) + traceCount = try values.decode(Int.self, forKey: .traceCount) + queueSize = try values.decode(Int.self, forKey: .queueSize) + pingColor = try values.decodeIfPresent(WatchColor.self, forKey: .pingColor) + // Additive v2 field: an older phone omits it, and Passive is the only mode + // safe to promise without current phone-resolved zone policy. Keeping v2 + // avoids stranding otherwise compatible phone/watch pairs. + availableStartModes = try values.decodeIfPresent( + [String].self, + forKey: .availableStartModes + ) ?? ["passive"] + geo = try values.decode(WatchGeo.self, forKey: .geo) + controls = try values.decode(WatchControls.self, forKey: .controls) + cue = try values.decodeIfPresent(WatchHapticCue.self, forKey: .cue) + updatedAtMs = try values.decode(Double.self, forKey: .updatedAtMs) + } + /// True when this payload came from a wire version the app understands. var isSupportedVersion: Bool { wireVersion == MeshMapperWatchWire.version } @@ -223,6 +358,9 @@ struct WatchCommand: Codable, Hashable { } let kind: Kind + /// Optional additive field. Older phones ignore it and retain their safe + /// mode resolver; new phones revalidate it instead of silently downgrading. + let mode: String? /// Client-generated, so the phone can dedupe redelivered commands. let id: String /// Queued delivery can outlive the place where a transmit was requested. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d144e37e..457cb337 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1286,6 +1286,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { core: core, geo: _buildWatchGeo(), controls: _buildWatchControls(), + availableStartModes: _availableWatchStartModes, pingColor: pingColor, cue: _watchCue, phaseDurationMs: phaseDurationMs, @@ -1473,6 +1474,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return _autoMode; } + List get _availableWatchStartModes => [ + WatchStartMode.passive, + if (isConnected && txAllowed) WatchStartMode.hybrid, + ]; + String get _resolvedWatchSessionModeTitle => switch (_resolvedWatchSessionMode) { AutoMode.active => 'Active', @@ -1489,6 +1495,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { canStartStop: isConnected, canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, + // Slot ownership must not follow the live manual-ping gate: cooldowns + // and receive windows would otherwise replace Ping with Stop beneath a + // thumb. TX sessions cannot manually ping for their entire lifetime, so + // they keep Stop available even in a region where TX is permitted. + manualPingApplicable: isConnected && + txAllowed && + !isTxModeRunning && + !isTargetedModeRunning, manualCooldownEndsAt: cooldownMs > 0 ? _manualPingCooldownTimer.endTime : null, @@ -1519,9 +1533,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// WatchConnectivity cannot wait for BLE or server work. Successful outcomes /// already surface through session, phase, and ping-colour snapshots; a late /// failure gets its own cue so dropping completion from the ack loses nothing. - String? _handleWatchCommand(WatchCommandKind kind) { + String? _handleWatchCommand(WatchCommand command) { if (_isDisposed) return 'App closing'; + final kind = command.kind; + switch (kind) { case WatchCommandKind.requestSnapshot: _scheduleWatchSync(immediate: true); @@ -1536,7 +1552,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (admission.refusal != null) return admission.refusal; if (!admission.shouldRun) return null; if (!isConnected) return 'Not connected'; - unawaited(_runWatchStartSession(_resolvedWatchSessionMode)); + final requested = resolveWatchRequestedStartMode( + requestedMode: command.mode, + isConnected: isConnected, + txAllowed: txAllowed, + ); + if (requested.refusal != null) return requested.refusal; + final mode = switch (requested.mode) { + WatchStartMode.passive => AutoMode.passive, + WatchStartMode.hybrid => AutoMode.hybrid, + null => _resolvedWatchSessionMode, + }; + unawaited(_runWatchStartSession(mode)); return null; case WatchCommandKind.stopSession: diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 67284e42..ee6211ff 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -14,7 +14,7 @@ typedef WatchUrgencyKeyBuilder = String Function(); /// reason when refused; admitted work continues independently of this reply. /// Production handlers must decide synchronously; FutureOr keeps existing /// bridge fakes source-compatible without putting the real path behind a wait. -typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); +typedef WatchCommandHandler = FutureOr Function(WatchCommand command); typedef WatchCommandRefusalHandler = void Function(String reason); typedef WatchAvailabilityHandler = void Function(bool available); typedef WatchSnapshotDeliveryHandler = void Function(WatchSnapshot snapshot); @@ -130,7 +130,10 @@ class WatchBridgeService { // what makes the MethodChannel response fit inside WatchConnectivity's // short reply window; the admitted action reports its later outcome via // normal snapshots and one-shot cues. - final admission = handler(kind); + final admission = handler(WatchCommand( + kind: kind, + mode: args['mode'] as String?, + )); final refusal = admission is Future ? await admission : admission; if (refusal != null) { diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 2d4a9f30..2e9df65f 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -23,6 +23,12 @@ class WatchWire { /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and /// ping-type colour — instead of the richer per-echo data. Hop counts are /// gone: the overlay is fed `directRepeaters` only. + /// + /// Additive optional fields do not bump this version. A new watch defaults + /// an absent start-mode list to Passive and absent Ping applicability to + /// false, while an older phone ignores the optional mode on a command and + /// retains its existing safe resolver. A bump would therefore strand + /// compatible pairs without preventing a bad decode. static const int version = 2; static const int maxPings = 60; @@ -37,6 +43,23 @@ class WatchWire { static const double minMoveMeters = 15.0; } +/// Start modes the phone may explicitly offer to the wrist. +/// +/// Active remains a phone-only choice. The wrist setting deliberately stays +/// small: Passive is safe everywhere, while Hybrid is advertised only when +/// current zone policy permits transmission. +enum WatchStartMode { + passive, + hybrid; + + static WatchStartMode? fromWire(String value) { + for (final mode in WatchStartMode.values) { + if (mode.name == value) return mode; + } + return null; + } +} + class WatchPosition { const WatchPosition({ required this.lat, @@ -203,6 +226,7 @@ class WatchControls { required this.canStartStop, required this.canManualPing, required this.isSessionActive, + this.manualPingApplicable = false, this.manualCooldownEndsAt, this.blockedReason, }); @@ -210,6 +234,11 @@ class WatchControls { final bool canStartStop; final bool canManualPing; final bool isSessionActive; + + /// Stable ownership for the corner slot. Unlike [canManualPing], this does + /// not flicker during cooldowns or receive windows; those only disable the + /// ping control that already owns the slot. + final bool manualPingApplicable; final DateTime? manualCooldownEndsAt; final String? blockedReason; @@ -217,6 +246,7 @@ class WatchControls { 'canStartStop': canStartStop, 'canManualPing': canManualPing, 'isSessionActive': isSessionActive, + 'manualPingApplicable': manualPingApplicable, 'manualCooldownEndsAtMs': manualCooldownEndsAt?.millisecondsSinceEpoch.toDouble(), 'blockedReason': blockedReason, @@ -264,6 +294,7 @@ class WatchSnapshot { required this.geo, required this.controls, required this.updatedAt, + this.availableStartModes = const [WatchStartMode.passive], this.pingColor, this.cue, this.phaseDurationMs, @@ -273,6 +304,7 @@ class WatchSnapshot { final LiveActivitySnapshot core; final WatchGeo geo; final WatchControls controls; + final List availableStartModes; final WatchColor? pingColor; final WatchHapticCue? cue; final DateTime updatedAt; @@ -301,6 +333,8 @@ class WatchSnapshot { 'traceCount': core.traceCount, 'queueSize': core.queueSize, 'pingColor': pingColor?.toMap(), + 'availableStartModes': + availableStartModes.map((mode) => mode.name).toList(), 'geo': geo.toMap(), 'controls': controls.toMap(), 'cue': cue?.toMap(), @@ -345,6 +379,7 @@ class WatchSnapshot { controls.canStartStop, controls.canManualPing, controls.isSessionActive, + controls.manualPingApplicable, cue?.id ?? '', ].join('|'); } @@ -364,6 +399,43 @@ enum WatchCommandKind { } } +/// Decoded wrist intent. [mode] stays raw until phone-side admission so an +/// unknown value can be refused rather than mistaken for an omitted mode. +class WatchCommand { + const WatchCommand({required this.kind, this.mode}); + + final WatchCommandKind kind; + final String? mode; +} + +typedef WatchRequestedStartModeResolution = ({ + WatchStartMode? mode, + String? refusal, +}); + +/// Revalidate an explicit wrist mode against current phone state. +/// +/// A null result means an older watch omitted the field and the provider must +/// retain its established `_resolvedWatchSessionMode` fallback. An unsupported +/// or newly-forbidden request is never downgraded silently. +WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ + required String? requestedMode, + required bool isConnected, + required bool txAllowed, +}) { + if (requestedMode == null) return (mode: null, refusal: null); + + final mode = WatchStartMode.fromWire(requestedMode); + if (mode == null) return (mode: null, refusal: 'Unsupported start mode'); + if (mode == WatchStartMode.hybrid && !isConnected) { + return (mode: null, refusal: 'Not connected'); + } + if (mode == WatchStartMode.hybrid && !txAllowed) { + return (mode: null, refusal: 'Passive Only'); + } + return (mode: mode, refusal: null); +} + typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); /// Resolve the wrist's single Start/Stop control without racing the phone's diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index e30a32bc..d0a67cd9 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -18,6 +18,7 @@ WatchSnapshot _snapshot({ String phaseTitle = 'Listening', bool isConnected = true, int? phaseDurationMs, + List availableStartModes = const [WatchStartMode.passive], }) => WatchSnapshot( core: LiveActivitySnapshot( @@ -50,6 +51,7 @@ WatchSnapshot _snapshot({ canManualPing: false, isSessionActive: true, ), + availableStartModes: availableStartModes, pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, cue: cue, @@ -80,6 +82,7 @@ void main() { 'traceCount', 'queueSize', 'pingColor', + 'availableStartModes', 'geo', 'controls', 'cue', @@ -101,6 +104,7 @@ void main() { 'canStartStop', 'canManualPing', 'isSessionActive', + 'manualPingApplicable', 'manualCooldownEndsAtMs', 'blockedReason', }, @@ -196,6 +200,40 @@ void main() { expect(_snapshot().toMap()['phaseDurationMs'], isNull); }); + test('snapshot carries phone-resolved start modes', () { + expect( + _snapshot( + availableStartModes: const [ + WatchStartMode.passive, + WatchStartMode.hybrid, + ], + ).toMap()['availableStartModes'], + ['passive', 'hybrid'], + ); + }); + + test('old controls default manual ping slot ownership to false', () { + const controls = WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + ); + + expect(controls.manualPingApplicable, isFalse); + expect(controls.toMap()['manualPingApplicable'], isFalse); + }); + + test('controls serialize stable manual ping slot ownership', () { + const controls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: true, + ); + + expect(controls.toMap()['manualPingApplicable'], isTrue); + }); + test('wire version is stamped so the watch can refuse unknown payloads', () { expect(_snapshot().toMap()['wireVersion'], WatchWire.version); @@ -276,6 +314,27 @@ void main() { LiveActivityPhase.starting, ); }); + + test('Hybrid is refused when current zone policy forbids TX', () { + final result = resolveWatchRequestedStartMode( + requestedMode: 'hybrid', + isConnected: true, + txAllowed: false, + ); + + expect(result.mode, isNull); + expect(result.refusal, 'Passive Only'); + }); + + test('an omitted start mode preserves the established phone fallback', () { + final result = resolveWatchRequestedStartMode( + requestedMode: null, + isConnected: true, + txAllowed: false, + ); + + expect(result, (mode: null, refusal: null)); + }); }); group('bridge command handling', () { @@ -319,13 +378,21 @@ void main() { .setMockMethodCallHandler(channel, null); }); - Future?> sendCommand(String id, String kind) async { + Future?> sendCommand( + String id, + String kind, { + String? mode, + }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - MethodCall('command', {'id': id, 'kind': kind}), + MethodCall('command', { + 'id': id, + 'kind': kind, + if (mode != null) 'mode': mode, + }), ), null, ); @@ -477,8 +544,8 @@ void main() { }); test('accepted commands reach the handler', () async { - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return null; }); @@ -489,8 +556,8 @@ void main() { }); test('a redelivered command does not transmit twice', () async { - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return null; }); @@ -503,8 +570,8 @@ void main() { test('a refused command may be retried once conditions change', () async { var refuse = true; - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return refuse ? 'Not connected' : null; }); @@ -520,8 +587,8 @@ void main() { test('an unknown command is refused without reaching the handler', () async { - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return null; }); @@ -533,12 +600,49 @@ void main() { test('a handler that throws refuses rather than crashing the bridge', () async { - bridge.attachCommandHandler((kind) async => throw StateError('boom')); + bridge.attachCommandHandler((command) async => throw StateError('boom')); final reply = await sendCommand('cmd-4', 'manualPing'); expect(reply?['accepted'], isFalse); expect(reply?['reason'], 'Command failed'); }); + + test('a start command carries its requested mode to admission', () async { + WatchCommand? received; + bridge.attachCommandHandler((command) { + received = command; + return null; + }); + + final reply = await sendCommand( + 'cmd-mode', + 'startSession', + mode: 'hybrid', + ); + + expect(reply?['accepted'], isTrue); + expect(received?.kind, WatchCommandKind.startSession); + expect(received?.mode, 'hybrid'); + }); + + test('a forbidden requested mode returns the phone refusal', () async { + bridge.attachCommandHandler((command) { + return resolveWatchRequestedStartMode( + requestedMode: command.mode, + isConnected: true, + txAllowed: false, + ).refusal; + }); + + final reply = await sendCommand( + 'cmd-forbidden-mode', + 'startSession', + mode: 'hybrid', + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Passive Only'); + }); }); } From e1adacbbfcab69ea64c81b3d85efc75c8edf1ce9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 17:19:42 -0700 Subject: [PATCH 208/360] Make the controls page look like the rest of MeshMapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It used raw system tints and a bare stack of two buttons, which is most of why it read as a page from a different app. It now uses the phone's own vocabulary: slate surfaces, green #22C55E, the app's red #BD2130, indigo #6366F1, a 12 pt material card with a hairline border matching the map's status panel, and the readout's approved type scale. Three substantive changes beyond paint. A compact mode and phase header, because the page previously gave no way to see what was running while looking at the controls for it. `blockedReason` and refusals now sit directly beneath the control they explain rather than floating at the bottom as two grey strings. And the phase shrinks to fit rather than truncating — on 40 mm it rendered "Listenin…", and since the titles the phone sends already end in an ellipsis, that read as a rendering fault. The controls were 69.5 pt tall against a 44 pt ergonomic floor, which pushed the last one to within 5 pt of the 46 mm display bottom. At the card's 14 pt margin and that watch's ~37 pt corner radius the curve needs about 8 pt, so its corners were clipped — the failure mode this project has hit repeatedly, and again invisible in a flat simulator rectangle until measured. Moving the 44 pt guarantee to the styled button rather than its label brings them to 52 pt on 46 mm and 44.5 pt on 40 mm. Every sample state now fits both displays without scrolling, with 40 pt and 16 pt of clearance in the tightest one. `effectiveStartMode` is now single-sourced on `WatchSettings`; the map and settings copies had already begun to diverge in form. The toolbar's trailing control drops its capsule fill for a coloured glyph on the default glass, matching the leading toggle it sits opposite. Only the armed state stays filled: the confirm window is the one state with a consequence attached, so it is the one that should be loud. One defect fixed along the way. Associating refusals with their originating control had routed phone-originated cues through the same path, so an unrelated cue inherited whatever the wrist last tapped and could report a failure under Manual ping for a ping that succeeded. `WatchHapticCue` carries no correlation to a command, so attribution is now explicit per call site and cues are deliberately unattributed. --- ios/MeshMapperWatch/ControlsPage.swift | 168 ++++++++++++++++--- ios/MeshMapperWatch/MapPage.swift | 39 +++-- ios/MeshMapperWatch/SettingsPage.swift | 6 +- ios/MeshMapperWatch/WatchSessionClient.swift | 28 +++- ios/MeshMapperWatch/WatchSettings.swift | 13 ++ 5 files changed, 208 insertions(+), 46 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index d891f69d..66a89f00 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -11,6 +11,9 @@ struct ControlsPage: View { @State private var pingArmed = false @State private var disarmPingTask: Task? + private static let minimumTapHeight: CGFloat = 44 + private static let compactLabelHeight: CGFloat = 24 + private var controls: WatchControls? { client.snapshot?.controls } /// Manual-ping cooldown deadline, if one is still ahead of us. @@ -24,27 +27,37 @@ struct ControlsPage: View { ScrollView { // No page title. The buttons name themselves, and on a 40 mm screen a // header pushed `blockedReason` — the one thing that explains a dead - // button — below the fold, which is the opposite of what it is for. - VStack(spacing: 10) { - startStopButton - manualPingButton - - if let reason = controls?.blockedReason { - Text(reason) - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } + // button — below the fold, which is the opposite of what it is for. The + // compact in-card context adds meaning without spending a navigation row. + VStack(spacing: 5) { + sessionHeader + startStopControl + manualPingControl - if let refusal = client.lastRefusal { - Text(refusal) - .font(.caption2) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) + // A fresh cue retained across a watch-process restart has no local tap + // to name. Keep that rare but valid feedback visible without guessing + // which control owns it. + if client.lastRefusalCommand == nil, + let refusal = client.lastRefusal + { + refusalMessage(refusal) } } - .padding(.horizontal, 8) - .padding(.top, 4) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background( + .ultraThinMaterial, + in: .rect(cornerRadius: WatchPalette.cornerRadius, style: .continuous) + ) + .overlay( + RoundedRectangle( + cornerRadius: WatchPalette.cornerRadius, + style: .continuous + ) + .stroke(.white.opacity(0.12), lineWidth: 0.5) + ) + .padding(.horizontal, 6) + .padding(.top, 2) // The last line must not finish against the bottom edge: the display // curves there, and the simulator's flat rectangle has hidden exactly // this twice before. @@ -60,6 +73,97 @@ struct ControlsPage: View { .onDisappear { disarmPing() } } + private var sessionHeader: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + // The mode doubles as the compact section label. A separate "SESSION" + // row would spend the exact vertical space that keeps a two-line blocked + // reason visible on the 40 mm display. + Text(controls?.isSessionActive == true + ? (client.snapshot?.mode.uppercased() ?? "SESSION") + : "IDLE") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + + Spacer(minLength: 4) + + Text(client.snapshot?.phaseTitle ?? "Waiting for iPhone") + // Phase titles are prose, not an identity or numeric value. The + // proportional face plus bounded scaling keeps even "Device + // disconnected" and "Listening for trace…" intact on 40 mm without + // buying that width by pushing the blocked reason below the fold. + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var startStopControl: some View { + VStack(alignment: .leading, spacing: 2) { + startStopButton + if controls?.canStartStop != true, + let reason = controls?.blockedReason + { + controlReason(reason) + } + if let refusal = startStopRefusal { + refusalMessage(refusal) + } + } + } + + private var manualPingControl: some View { + VStack(alignment: .leading, spacing: 2) { + manualPingButton + // When Start/Stop is also unavailable, its action owns the shared reason + // above. Otherwise keep the explanation immediately under Manual ping. + if controls?.canStartStop == true, + controls?.canManualPing != true, + let reason = controls?.blockedReason + { + controlReason(reason) + } + if client.lastRefusalCommand == .manualPing, + let refusal = client.lastRefusal + { + refusalMessage(refusal) + } + } + } + + private var startStopRefusal: String? { + switch client.lastRefusalCommand { + case .startSession, .stopSession: + return client.lastRefusal + default: + return nil + } + } + + private func controlReason(_ reason: String) -> some View { + Text(reason) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.45)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + + private func refusalMessage(_ refusal: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "exclamationmark.circle.fill") + Text(refusal) + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(WatchPalette.armed) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + private var startStopButton: some View { let isActive = controls?.isSessionActive ?? false let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession @@ -74,10 +178,10 @@ struct ControlsPage: View { client.send(kind) } label: { Text(buttonTitle) - .font(.headline) + .font(.system(size: 13, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) - .frame(maxWidth: .infinity, minHeight: 44) + .frame(maxWidth: .infinity, minHeight: Self.compactLabelHeight) // A ProgressView accepts the horizontal slack offered by a stack. As // an overlay it can appear without participating in the label's // centring, so feedback never makes the action jump under a thumb. @@ -91,9 +195,15 @@ struct ControlsPage: View { } } .buttonStyle(.borderedProminent) - // Grey when unavailable rather than a desaturated tint: a disabled green - // renders pale enough to read as a live button worth tapping. - .tint(isEnabled ? (isActive ? .red : .green) : .gray) + // The prominent watch style supplies substantial chrome outside its + // label. Giving the label the full ergonomic floor made the finished + // target 69.5 pt tall; applying that floor to the styled Button preserves + // the moving-vehicle tap target without paying for it twice. + .frame(minHeight: Self.minimumTapHeight) + .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius)) + .tint(isEnabled + ? (isActive ? WatchPalette.stop : WatchPalette.start) + : WatchPalette.disabled) .disabled(!isEnabled) } @@ -110,7 +220,7 @@ struct ControlsPage: View { } } label: { pingLabel(isPending: isPending) - .frame(maxWidth: .infinity, minHeight: 44) + .frame(maxWidth: .infinity, minHeight: Self.compactLabelHeight) // Keep this identical to Start/Stop: pending feedback belongs at the // edge of the target, not in the row that determines its label's centre. .overlay(alignment: .leading) { @@ -123,7 +233,11 @@ struct ControlsPage: View { } } .buttonStyle(.borderedProminent) - .tint(isEnabled ? (pingArmed ? .orange : .accentColor) : .gray) + .frame(minHeight: Self.minimumTapHeight) + .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius)) + .tint(isEnabled + ? (pingArmed ? WatchPalette.armed : WatchPalette.ping) + : WatchPalette.disabled) .disabled(!isEnabled) } @@ -147,10 +261,10 @@ struct ControlsPage: View { .monospacedDigit() .frame(width: 38, alignment: .leading) } - .font(.headline) + .font(.system(size: 13, weight: .semibold)) } else { Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) - .font(.headline) + .font(.system(size: 13, weight: .semibold)) } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 8cb88710..9711022e 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -298,33 +298,44 @@ struct MapPage: View { } private var effectiveStartMode: WatchSettings.DefaultStartMode { - let available = client.snapshot?.availableStartModes ?? ["passive"] - return available.contains(settings.defaultStartMode.rawValue) - ? settings.defaultStartMode - : .passive + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) } + @ViewBuilder private var trailingToolbarButton: some View { let control = trailingToolbarControl let pending = client.pendingCommand == control.command let armed = armedToolbarControl == control - return Button { + let button = Button { handleToolbarControl(control) } label: { ZStack { if pending { ProgressView() .controlSize(.mini) + .tint(toolbarActionColor(for: control)) } else { Image(systemName: toolbarIcon(for: control, armed: armed)) + .foregroundStyle(toolbarGlyphColor(for: control, armed: armed)) } } .frame(width: 18, height: 18) } - .tint(toolbarTint(for: control, armed: armed)) .disabled(!trailingToolbarControlIsEnabled) .accessibilityLabel(toolbarAccessibilityLabel(for: control, armed: armed)) + + if armed { + // Resting actions use the same quiet glass container as the display + // toggle. The three-second confirmation window is deliberately the only + // filled state: its amber capsule signals that the next tap has a + // consequence, rather than merely decorating a persistent control. + button.tint(WatchPalette.armed) + } else { + button + } } private func handleToolbarControl(_ control: TrailingToolbarControl) { @@ -354,12 +365,9 @@ struct MapPage: View { } } - private func toolbarTint( - for control: TrailingToolbarControl, - armed: Bool + private func toolbarActionColor( + for control: TrailingToolbarControl ) -> Color { - guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } - if armed { return WatchPalette.armed } switch control { case .start: return WatchPalette.start case .stop: return WatchPalette.stop @@ -367,6 +375,15 @@ struct MapPage: View { } } + private func toolbarGlyphColor( + for control: TrailingToolbarControl, + armed: Bool + ) -> Color { + guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } + if armed { return .white } + return toolbarActionColor(for: control) + } + private func toolbarAccessibilityLabel( for control: TrailingToolbarControl, armed: Bool diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 6d899ffa..929a3333 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -16,9 +16,9 @@ struct SettingsPage: View { } private var effectiveStartMode: WatchSettings.DefaultStartMode { - availableStartModes.contains(settings.defaultStartMode) - ? settings.defaultStartMode - : .passive + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) } var body: some View { diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index a00ee65e..d11fa2b2 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -24,6 +24,11 @@ final class WatchSessionClient: NSObject { /// Both belong to one short-lived presentation path on the controls page. private(set) var lastRefusal: String? + /// Explicit attribution lets Controls place feedback beside the action it + /// explains without guessing that an unrelated phone event belongs to the + /// last thing tapped. It never affects admission, transport, or the command. + private(set) var lastRefusalCommand: WatchCommand.Kind? + /// A refusal explains one completed tap, not the current transport state. /// Restarting its lifetime on replacement prevents an older expiry from /// erasing newer feedback that happens to arrive near the same moment. @@ -96,7 +101,9 @@ final class WatchSessionClient: NSObject { /// duplicate-transmit risk of retrying an ambiguously delivered message. func send(_ kind: WatchCommand.Kind, mode: String? = nil, silent: Bool = false) { guard let session, session.activationState == .activated else { - if !silent { setLastRefusal("Not connected to iPhone") } + if !silent { + setLastRefusal("Not connected to iPhone", from: kind) + } return } @@ -109,12 +116,14 @@ final class WatchSessionClient: NSObject { guard let data = try? MeshMapperWatchWire.encoder.encode(command), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - if !silent { setLastRefusal("Could not encode command") } + if !silent { + setLastRefusal("Could not encode command", from: kind) + } return } if !silent { - setLastRefusal(nil) + setLastRefusal(nil, from: kind) beginPending(kind) } @@ -138,16 +147,21 @@ final class WatchSessionClient: NSObject { pendingCommand = nil } - private func setLastRefusal(_ refusal: String?) { + private func setLastRefusal( + _ refusal: String?, + from command: WatchCommand.Kind? + ) { refusalExpiryTask?.cancel() refusalExpiryTask = nil lastRefusal = refusal + lastRefusalCommand = refusal == nil ? nil : command guard refusal != nil else { return } refusalExpiryTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(6)) guard !Task.isCancelled else { return } self?.lastRefusal = nil + self?.lastRefusalCommand = nil self?.refusalExpiryTask = nil } } @@ -216,7 +230,11 @@ final class WatchSessionClient: NSObject { self.presentedCueIDs.remove(self.presentedCueIDOrder.removeFirst()) } if let message = cue.message, !message.isEmpty { - self.setLastRefusal(message) + // A cue may be a late wrist-command failure or an unrelated phone + // event; the current wire cannot distinguish them, so attribution + // here would be a guess. Correlating it later requires carrying the + // originating command on `WatchHapticCue` across the wire. + self.setLastRefusal(message, from: nil) } } } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index a47b952f..28cb3d45 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -164,6 +164,19 @@ final class WatchSettings { didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } } + /// One promise shared by Settings and the explicit-mode Start control. If + /// these surfaces resolve independently, Settings can claim Hybrid while a + /// tap silently requests Passive — precisely the kind of mode ambiguity the + /// explicit command field exists to prevent. + func effectiveStartMode( + availableStartModes: [String]? + ) -> DefaultStartMode { + let advertised = availableStartModes ?? [DefaultStartMode.passive.rawValue] + return advertised.contains(defaultStartMode.rawValue) + ? defaultStartMode + : .passive + } + private static func clampedMapLatitudeDelta(_ value: Double) -> Double { guard value.isFinite else { return defaultMapLatitudeDelta } return min(max(value, mapLatitudeDeltaLimits.lowerBound), mapLatitudeDeltaLimits.upperBound) From 3b9b89513830943d1cab5bac8fc91ea6efbc6e9e Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 17:56:04 -0700 Subject: [PATCH 209/360] Let the map page be a page: swipe navigates, crown zooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from Adam's walk. The trailing control looked empty with nothing connected. It was drawn correctly — a disabled play glyph in slate on glass — but slate over Apple's darkest basemap is invisible, so it read as an empty circle. Disconnection now has its own glyph rather than a dimmed Start, and the disabled colour moves to the brighter slate so that no disabled control reads as empty. The accessibility label still distinguishes a missing phone from a missing device even though the glyph cannot. Satellite did nothing. `.mapStyle(.imagery)` renders pixel-identically to `.standard` on watchOS: a diff of the two states across the whole basemap band found zero differing pixels, and it behaved the same on Adam's watch. The toggle is gone rather than left as a control that silently does nothing, and the header comment records it alongside the other watchOS MapKit limits. The map claimed both gestures. `interactionModes` included `.pan`, so vertical swipes dragged the basemap instead of paging and the status panel was the only place a swipe could change pages. Zoom-only gives the page back its swipe while leaving the crown as the deliberate zoom control. That removes the pan-suspension machinery entirely, and deliberately rather than incidentally. Its purpose was to stop fighting a wearer who had dragged the map. With drag gone, the only remaining source of centre drift is MapKit settling and crown zoom — neither of which is a wearer moving the map — so a distance heuristic could no longer identify a pan truthfully, only misfire and suspend follow for eight seconds. Keeping it would have introduced the bug this change was meant to avoid. `programmaticCenter` stays: it is now solely the first half of the span handshake that keeps `.automatic` from becoming the remembered zoom. Fresh installs still settle at 250 m on both watch sizes. --- ios/MeshMapperWatch/MapPage.swift | 121 ++++++------------------- ios/MeshMapperWatch/SettingsPage.swift | 1 - 2 files changed, 26 insertions(+), 96 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 9711022e..dac44a44 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -9,6 +9,9 @@ import WatchKit /// `API_UNAVAILABLE(watchos)`, so the OpenFreeMap styles, the ArcGIS satellite /// raster, and the coverage vector tiles have no route onto the wrist. Only /// the data layer — ping colours, repeater pins, the fix — is MeshMapper's. +/// Apple's `.imagery` also renders pixel-identically to `.standard` here, +/// verified by pixel diff on device and simulator, so the watch exposes no +/// satellite mode. struct MapPage: View { @Environment(WatchSessionClient.self) private var client @Environment(WatchSettings.self) private var settings @@ -33,33 +36,22 @@ struct MapPage: View { @State private var camera: MapCameraPosition = .automatic - /// Centre we last drove the camera to, so a camera change can be attributed - /// to the wearer rather than to our own follow updates. + /// Non-nil once we have driven the camera. Assignment is not proof that + /// MapKit rendered the request, but it is the first half of the span + /// handshake that keeps `.automatic` from becoming the remembered zoom. @State private var programmaticCenter: CLLocationCoordinate2D? - @State private var followSuspendedUntil: Date? - - /// Whether MapKit has ever reported back a centre we asked for. Until it has, - /// a disagreeing centre is `.automatic` settling, not the wearer — see - /// `noteCameraChange`. - @State private var hasConfirmedRequestedCenter = false /// Span counterpart to the centre handshake: MapKit can report its old /// `.automatic` fit after we assign a region, so assignment alone is not /// evidence that a rendered zoom came from us. @State private var hasConfirmedRequestedSpan = false - /// Metres of disagreement before a camera change counts as a real pan. - private static let panTolerance: CLLocationDistance = 40 - /// Initial confirmation only needs to distinguish our request from the much /// wider automatic annotation fit. MapKit may adjust a requested region for /// display geometry, so a generous tolerance avoids quietly disabling Crown /// persistence by waiting forever for an exact span. private static let spanConfirmationTolerance = 0.25 - /// How long a pan pauses following before the map drifts back to the fix. - private static let resumeFollowAfter: TimeInterval = 8 - private var snapshot: WatchSnapshot? { client.snapshot } private var fix: CLLocationCoordinate2D? { @@ -68,9 +60,7 @@ struct MapPage: View { } private var isFollowing: Bool { - guard settings.follow else { return false } - if let until = followSuspendedUntil, until > Date() { return false } - return true + settings.follow } @State private var showingNodes = false @@ -357,6 +347,12 @@ struct MapPage: View { for control: TrailingToolbarControl, armed: Bool ) -> String { + // With no usable connection, a dim play symbol falsely suggests that the + // empty-looking glass control is a Start affordance. Name the unavailable + // prerequisite instead; enablement remains entirely phone-owned. + if snapshot?.isConnected != true { + return "antenna.radiowaves.left.and.right.slash" + } if armed { return "checkmark" } switch control { case .start: return "play.fill" @@ -379,7 +375,10 @@ struct MapPage: View { for control: TrailingToolbarControl, armed: Bool ) -> Color { - guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } + // The darker disabled slate vanished against Apple's darkest basemap. + // System disabled dimming still distinguishes this brighter slate from an + // enabled action without making the glass circle look empty. + guard trailingToolbarControlIsEnabled else { return WatchPalette.tertiary } if armed { return .white } return toolbarActionColor(for: control) } @@ -388,6 +387,8 @@ struct MapPage: View { for control: TrailingToolbarControl, armed: Bool ) -> String { + guard let snapshot else { return "Waiting for iPhone" } + guard snapshot.isConnected else { return "Device disconnected" } switch control { case .start: return "Start \(effectiveStartMode.label) session" case .stop: return armed ? "Confirm stop session" : "Stop session" @@ -425,20 +426,9 @@ struct MapPage: View { .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in recenterIfFollowing(proxy) } - // The delayed resume in `scheduleFollowResume` only clears the suspension; - // recentring happens here, where a live proxy is in scope. - .onChange(of: followSuspendedUntil) { _, until in - if until == nil { recenterIfFollowing(proxy) } - } .onAppear { recenterIfFollowing(proxy) } - .onDisappear { - // A pan's delayed resume belongs to the map. Leaving for either the - // chosen readout or Always-On must not leave map work pending off-screen. - resumeTask?.cancel() - resumeTask = nil - } } private var readoutContent: some View { @@ -736,7 +726,6 @@ struct MapPage: View { private func recenterButton(_ proxy: MapProxy) -> some View { if !isFollowing, fix != nil { Button { - followSuspendedUntil = nil recenterIfFollowing(proxy, force: true) } label: { Image(systemName: "location.fill") @@ -751,22 +740,25 @@ struct MapPage: View { // MARK: - Map private func map(_ proxy: MapProxy) -> some View { - Map(position: $camera, interactionModes: [.pan, .zoom]) { + // Pan consumes the vertical gesture the page shell needs, while the Crown + // remains the deliberate zoom control. Once drag input is absent, centre + // drift cannot honestly identify a pan — MapKit and Crown zoom can both + // produce it — so follow has no distance-based suspension path to misfire. + Map(position: $camera, interactionModes: [.zoom]) { linkLines pingMarkers repeaterPins fixMarker } - .mapStyle(settings.satellite ? .imagery : .standard) + .mapStyle(.standard) .onMapCameraChange(frequency: .onEnd) { context in noteRenderedRegion(context.region) - noteCameraChange(context.region.center) correctPlacement(proxy) } // The shell's navigation host supplies the system toolbar placement but // must not buy it by shortening the basemap. Only MapKit extends under that // top chrome; the overlay remains in the safe content region, keeping its - // transient trailing recentre button away from the leading toolbar control. + // inset recentre button below the system toolbar controls. .ignoresSafeArea(edges: [.top, .bottom]) } @@ -963,67 +955,6 @@ struct MapPage: View { settings.mapLatitudeDelta = rendered } - private func noteCameraChange(_ center: CLLocationCoordinate2D) { - guard let expected = programmaticCenter else { - // We have never driven the camera, so this is `.automatic` settling on - // launch rather than a pan. Treating it as one would suspend following - // before the first fix even arrives. - return - } - - let drift = distance(center, expected) - - // `.automatic` settles *after* our first request, centred on the annotation - // cloud — measured 372 m from the fix — and that disagreement is not a pan. - // Reading it as one suspended following for eight seconds at every launch, - // and overwrote the expectation, so our own region landing then looked like - // a second pan. Nothing counts as a pan until MapKit has confirmed a centre - // we actually asked for. - guard hasConfirmedRequestedCenter else { - if drift <= Self.panTolerance { hasConfirmedRequestedCenter = true } - return - } - - guard drift > Self.panTolerance else { - // Our own follow update landing. - return - } - - // The wearer moved the map. Stop fighting them, and drift back shortly. - programmaticCenter = center - let deadline = Date().addingTimeInterval(Self.resumeFollowAfter) - followSuspendedUntil = deadline - scheduleFollowResume(at: deadline) - } - - /// Re-evaluate when the suspension lapses. - /// - /// `followSuspendedUntil` is only read during a render, and a stationary - /// phone sends no updates to trigger one — without this the map would stay - /// unfollowed indefinitely after a single pan. - private func scheduleFollowResume(at deadline: Date) { - resumeTask?.cancel() - resumeTask = Task { @MainActor in - let seconds = deadline.timeIntervalSinceNow - if seconds > 0 { - try? await Task.sleep(for: .seconds(seconds)) - } - guard !Task.isCancelled, followSuspendedUntil == deadline else { return } - // Clearing this drives the recentre, via `onChange` where a proxy is in - // scope. - followSuspendedUntil = nil - } - } - - @State private var resumeTask: Task? - - private func distance( - _ a: CLLocationCoordinate2D, - _ b: CLLocationCoordinate2D - ) -> CLLocationDistance { - CLLocation(latitude: a.latitude, longitude: a.longitude) - .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude)) - } } /// A phase-scoped progress animation rather than a one-second render clock. diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 929a3333..75972f2a 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -39,7 +39,6 @@ struct SettingsPage: View { } Section("Map") { - Toggle("Satellite", isOn: $settings.satellite) Toggle("Follow position", isOn: $settings.follow) Toggle("Lines to repeaters", isOn: $settings.showLinks) } From 4a461da73161fb8047c31387aa82c0e36271e382 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 19:09:18 -0700 Subject: [PATCH 210/360] Stop the wrist from starting a session the phone would refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-beta review round one: four correctness defects, none cosmetic. The wrist could start a session the phone's own button would have refused. `canStartStop` checked connection and nothing else, while the phone gates Start on nine conditions. Starting Active from the wrist while a manual ping was in flight made `sendTxPing` return early on `_pingInProgress`, so the first automatic ping neither transmitted nor scheduled the next cycle: a session reporting itself active that never advanced. A tester could have walked an hour and come back with nothing. Start admission is now one rule, mirroring how `_manualPingAvailability` is already the single copy of the ping gate, and consumed by both the offered button and the radio admission. It is mode-aware on purpose: Passive does not transmit, so TX cooldowns, receive windows and zone TX policy must not block it — refusing a Passive start because a manual ping was in flight would be the same bug pointing the other way. `ping_controls.dart` keeps its own copy for now; its enablement is entangled with running-mode toggles and labels, and restructuring the phone UI days before a beta buys a worse risk than it removes. `DebugPage` shipped in Release with a single-tap manual ping, bypassing the confirmation the real surfaces have precisely because that action transmits. It is now DEBUG-only, and while it remains there its ping confirms and its Start sends the configured mode. Command failures had no presentation on the map. The toolbar is now the primary way to start and stop a session, but refusals only reached `ControlsPage`, so a wrist Start could fail, clear its spinner, and explain nothing on the surface being looked at. Both surfaces now show a transient toast on the existing six-second lifetime, layout-neutral and absent at reduced luminance. The readout anchors it to the safe-area child rather than to the black backing: that sibling ignores the safe area and would have placed a bottom-aligned overlay off-screen, the third time this file has been bitten by geometry read from an expanded region. Sized to clear the corner radius so it drops to 2.5 pt off the physical edge, it sits below Top Heard entirely on 46 mm. Controls sent no mode while the toolbar sent an explicit one, so the two made different promises — and on a fresh phone session in a TX region, ambient `_autoMode` can be Active while the wrist's stored default is Passive. Both now send the same single-sourced effective mode. One known limit, not worth wire churn before the beta: enablement is computed for Passive because the chosen mode is watch-local, so a blocked Hybrid start shows an enabled button and an explained refusal rather than a disabled one. Publishing availability per mode would fix it properly. `-MeshMapperForceRefusal` joins the DEBUG launch arguments, since the banner is otherwise unreachable in a simulator with no way to tap. --- ios/MeshMapperWatch/ContentView.swift | 4 + ios/MeshMapperWatch/ControlsPage.swift | 15 ++- ios/MeshMapperWatch/DebugPage.swift | 49 +++++++- ios/MeshMapperWatch/MapPage.swift | 118 ++++++++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 25 ++-- ios/MeshMapperWatch/WatchSessionClient.swift | 13 ++ lib/providers/app_state_provider.dart | 52 +++++++- lib/services/watch/watch_models.dart | 52 ++++++++ .../watch/watch_wire_contract_test.dart | 70 +++++++++++ 9 files changed, 380 insertions(+), 18 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index d5a01bc9..f524c704 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -48,7 +48,11 @@ struct ContentView: View { .tag(2) } + #if DEBUG + // This page exposes raw, low-friction verification controls. Keeping + // it out of Release is a transmit-safety boundary, not page polish. DebugPage().tag(3) + #endif SettingsPage().tag(4) } .tabViewStyle(.verticalPage) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 66a89f00..585bd929 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -7,6 +7,7 @@ import SwiftUI /// to the phone, which revalidates it and returns the reason when refused. struct ControlsPage: View { @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings @State private var pingArmed = false @State private var disarmPingTask: Task? @@ -16,6 +17,12 @@ struct ControlsPage: View { private var controls: WatchControls? { client.snapshot?.controls } + private var effectiveStartMode: WatchSettings.DefaultStartMode { + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) + } + /// Manual-ping cooldown deadline, if one is still ahead of us. private var cooldownEndsAt: Date? { guard let ms = controls?.manualCooldownEndsAtMs else { return nil } @@ -169,13 +176,17 @@ struct ControlsPage: View { let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession let isPending = client.pendingCommand == kind let isEnabled = controls?.canStartStop == true && !isPending - let startTitle = "Start \(client.snapshot?.mode ?? "Session")" + let startTitle = "Start \(effectiveStartMode.label)" let buttonTitle = isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : startTitle) return Button { - client.send(kind) + if kind == .startSession { + client.send(kind, mode: effectiveStartMode.rawValue) + } else { + client.send(kind) + } } label: { Text(buttonTitle) .font(.system(size: 13, weight: .semibold)) diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift index d48957aa..e94a6140 100644 --- a/ios/MeshMapperWatch/DebugPage.swift +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -1,5 +1,6 @@ import SwiftUI +#if DEBUG /// Raw state dump and command buttons. /// /// Kept from Phase 2 as a development surface while the real UI is built out. @@ -7,6 +8,10 @@ import SwiftUI /// away once the node list and controls carry their own verification. struct DebugPage: View { @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var pingArmed = false + @State private var disarmPingTask: Task? var body: some View { ScrollView { @@ -41,6 +46,7 @@ struct DebugPage: View { .padding(.horizontal, 4) .opacity(client.isStale ? 0.45 : 1.0) } + .onDisappear { disarmPing() } } private var header: some View { @@ -130,12 +136,23 @@ struct DebugPage: View { private var commandButtons: some View { VStack(spacing: 4) { - Button(sessionActive ? "Stop" : "Start") { - client.send(sessionActive ? .stopSession : .startSession) + Button(sessionActive ? "Stop" : "Start \(effectiveStartMode.label)") { + if sessionActive { + client.send(.stopSession) + } else { + client.send(.startSession, mode: effectiveStartMode.rawValue) + } } .disabled(!(client.snapshot?.controls.canStartStop ?? false)) - Button("Manual ping") { client.send(.manualPing) } + Button(pingArmed ? "Send ping?" : "Manual ping") { + if pingArmed { + disarmPing() + client.send(.manualPing) + } else { + armPing() + } + } .disabled(!(client.snapshot?.controls.canManualPing ?? false)) Button("Refresh") { client.send(.requestSnapshot) } @@ -147,4 +164,30 @@ struct DebugPage: View { private var sessionActive: Bool { client.snapshot?.controls.isSessionActive ?? false } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) + } + + /// DEBUG still reaches physical development watches, so its raw-state page + /// keeps the same deliberate second tap as every shipping ping surface. + private func armPing() { + disarmPingTask?.cancel() + pingArmed = true + disarmPingTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + pingArmed = false + disarmPingTask = nil + } + } + + private func disarmPing() { + disarmPingTask?.cancel() + disarmPingTask = nil + pingArmed = false + } } +#endif diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index dac44a44..4b2461f5 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -112,6 +112,7 @@ struct MapPage: View { /// deliberately narrow treatment, clearance is now evaluated at that real /// position so the lift buys the glanceable width the wearer requested. private static let panelBottomGapRatio: CGFloat = 10.0 / 19.0 + private static let readoutFailureBannerBottomGap: CGFloat = 3 private var panelBottomGap: CGFloat { bottomSafeAreaInset * Self.panelBottomGapRatio @@ -154,6 +155,17 @@ struct MapPage: View { max(0, screenWidth - 2 * panelHorizontalInset - 16) } + /// A centred toast with one radius of margin on each side is entirely + /// inboard of the bottom curve, so its vertical position no longer needs the + /// panel's chord calculation. Absence remains distinct from zero: without a + /// measured radius the banner must stay in the safe area. + private var readoutFailureBannerMaxWidth: CGFloat? { + guard bottomSafeAreaInset > 0 else { return nil } + let width = screenWidth - 2 * bottomSafeAreaInset + guard width.isFinite, width > 0 else { return nil } + return width + } + var body: some View { pageContent .toolbar { @@ -215,6 +227,12 @@ struct MapPage: View { if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { showingNodes = true } + // A refusal normally needs a real command round-trip, which makes this + // transient impossible to capture headlessly. It still expires through + // the production six-second path, so screenshots must be prompt. + if let forced = UserDefaults.standard.string(forKey: "MeshMapperForceRefusal") { + client.debugForceRefusal(forced) + } #endif } .onChange(of: trailingToolbarControl) { _, _ in @@ -246,6 +264,63 @@ struct MapPage: View { } } + private var commandFailure: String? { + guard !isLuminanceReduced, let refusal = client.lastRefusal else { + return nil + } + switch client.lastRefusalCommand { + case nil, .startSession, .stopSession, .manualPing: + return refusal + case .requestSnapshot: + return nil + } + } + + @ViewBuilder + private var commandFailureBanner: some View { + failureBanner(fillsAvailableWidth: true) + } + + @ViewBuilder + private var compactCommandFailureBanner: some View { + failureBanner(fillsAvailableWidth: false) + } + + @ViewBuilder + private func failureBanner(fillsAvailableWidth: Bool) -> some View { + if let commandFailure { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "exclamationmark.circle.fill") + Text(commandFailure) + .lineLimit(2) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(WatchPalette.armed) + .multilineTextAlignment(.leading) + .frame( + maxWidth: fillsAvailableWidth ? .infinity : nil, + alignment: .leading + ) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background( + .ultraThinMaterial, + in: .rect(cornerRadius: WatchPalette.cornerRadius, style: .continuous) + ) + .overlay( + RoundedRectangle( + cornerRadius: WatchPalette.cornerRadius, + style: .continuous + ) + .stroke(.white.opacity(0.12), lineWidth: 0.5) + ) + // Placement belongs to each surface's overlay. The banner itself owns no + // layout lifetime or geometry; the client still expires it after six + // seconds, and reduced luminance suppresses it in `commandFailure`. + .allowsHitTesting(false) + } + } + private var mainPageToggle: some View { Button { settings.mainPageContent = settings.mainPageContent == .map @@ -448,6 +523,38 @@ struct MapPage: View { // reader above receives its safe-area inset from the navigation host; // padding this descendant cannot alter that system-supplied value. .padding(.top, readoutTopOffset) + // Anchor to this safe-area-respecting child, not `readoutContent`. + // Its black sibling ignores the safe area and expands the enclosing + // ZStack past the display bottom, which would place a bottom-aligned + // banner off-screen — the same zero-inset trap this file has hit when + // geometry was read from an expanded region. + .overlay { + if let maxWidth = readoutFailureBannerMaxWidth { + VStack(spacing: 0) { + Spacer(minLength: 0) + compactCommandFailureBanner + // The transparent frame supplies a wrapping proposal and + // centres the toast; its material background remains on the + // intrinsic content rather than expanding permanent-looking + // chrome to the cap. + .frame(maxWidth: maxWidth) + } + // Three points keep the capsule's antialiasing visibly off the + // physical edge. Curvature needs no further allowance once both + // horizontal margins are at least the measured radius. + .padding(.bottom, Self.readoutFailureBannerBottomGap) + .ignoresSafeArea(edges: .bottom) + } else { + VStack(spacing: 0) { + Spacer(minLength: 0) + commandFailureBanner + } + // No measured radius means no permission to enter the bottom safe + // area; preserve the last known-safe placement exactly. + .padding(.horizontal, panelHorizontalInset) + .padding(.bottom, 4) + } + } } } @@ -467,6 +574,17 @@ struct MapPage: View { } Spacer(minLength: 0) statusPanel + .overlay(alignment: .top) { + commandFailureBanner + .padding(.horizontal, 4) + // Aligning a guide below the banner with the panel's top puts the + // transient immediately above the measured card. Unlike adding a + // VStack row, an overlay contributes no size, so neither the + // panel's signed-off placement nor PanelFrameKey can move. + .alignmentGuide(.top) { dimensions in + dimensions[.bottom] + 3 + } + } .background( GeometryReader { geo in Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 5dcca5ce..a998edb4 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -9,16 +9,23 @@ import Foundation /// /// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES /// -/// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live -/// countdown, the between-cycle wait, or a deadline the phone did not replace. -/// Listening is the default so existing capture commands keep their behaviour. -/// Pass -/// `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive` -/// to review Start, live Ping, disabled-but-stable Ping, and both reasons Stop -/// keeps the slot; active is the default. +/// Other DEBUG launch arguments used by the capture harness: /// -/// DEBUG-only, and never reached unless that argument is passed, so it cannot -/// leak into a shipping build or mask a real transport failure. +/// - `-MeshMapperSamplePhase listen|wait|lapsed` exercises phase timing. +/// - `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive` +/// exercises every toolbar slot state. +/// - `-MeshMapperLongIds YES` exercises six-character path hashes. +/// - `-MeshMapperShowNodeSheet YES` opens the heard-node sheet. +/// - `-MeshMapperInitialPage ` opens a specific vertical page. +/// - `-MeshMapperForceDimmed YES` renders the reduced-luminance readout. +/// - `-MeshMapperForceRefusal ` presents the failure banner; capture +/// within six seconds because it deliberately uses the production expiry. +/// +/// Listening and active remain the defaults so existing capture commands keep +/// their behaviour. +/// +/// Every affordance is DEBUG-only and requires its explicit argument, so none +/// can leak into a shipping build or mask a real transport failure. enum SampleSnapshot { static var isEnabled: Bool { UserDefaults.standard.bool(forKey: "MeshMapperSampleData") diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index d11fa2b2..d0662655 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -147,6 +147,19 @@ final class WatchSessionClient: NSObject { pendingCommand = nil } + #if DEBUG + /// Seed a refusal so the failure banner can be captured headlessly. + /// + /// The banner is otherwise unreachable in the simulator: producing one needs + /// a command to be refused, and there is no way to tap a control there. It + /// expires on the usual six-second schedule, so a capture must be taken + /// inside that window — a screenshot at eight seconds shows an empty screen + /// and reads exactly like a broken banner. + func debugForceRefusal(_ message: String) { + setLastRefusal(message, from: .startSession) + } + #endif + private func setLastRefusal( _ refusal: String?, from command: WatchCommand.Kind? diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 457cb337..bff141e7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1490,9 +1490,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchControls _buildWatchControls() { final cooldownMs = _manualPingCooldownTimer.remainingMs; final manualPing = _manualPingAvailability; + final passiveStart = sessionStartAvailability(AutoMode.passive); + // The wire has one Start/Stop bit while the preferred start mode lives on + // the watch. Passive is always advertised and is the safe fallback, so this + // bit answers whether at least that start is currently possible; the + // command handler applies the same rule again to the mode actually asked + // for. An active session uses the bit for Stop instead. + final canStartOrStop = + _autoPingEnabled ? isConnected : passiveStart.allowed; return WatchControls( - canStartStop: isConnected, + canStartStop: canStartOrStop, canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, // Slot ownership must not follow the live manual-ping gate: cooldowns @@ -1509,8 +1517,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // The button already renders its cooldown deadline. The handler still // returns this refusal to a stale tap, but duplicating it as a status // line would spend wrist space without adding an explanation. - blockedReason: - manualPing.reason == 'Cooling down' ? null : manualPing.reason, + blockedReason: !_autoPingEnabled && !passiveStart.allowed + ? passiveStart.reason + : (manualPing.reason == 'Cooling down' ? null : manualPing.reason), ); } @@ -1523,6 +1532,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { traceLogEntries: _traceLogEntries, ); + /// Whether [mode] may begin now, with the reason the wearer should see. + /// + /// This is the sole provider-side start gate. One caller publishes wrist + /// enablement and the other admits the command that mutates session state; + /// separate copies let a stale or racing wrist action enter a state the phone + /// button itself would never offer. + SessionStartAvailability sessionStartAvailability(AutoMode mode) { + final isTransmitMode = mode != AutoMode.passive; + final validation = + isTransmitMode ? autoModeValidation : PingValidation.valid; + final powerConfigured = _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; + + return resolveSessionStartAvailability( + isTransmitMode: isTransmitMode, + isConnected: isConnected, + antennaConfigured: _preferences.externalAntennaSet, + powerConfigured: powerConfigured, + isPendingDisable: isPendingDisable, + isTargetedRunning: isTargetedModeRunning, + isAutoStarting: isAutoPingStarting, + cooldownActive: _cooldownTimer.isRunning, + isPingSending: isPingSending, + rxWindowActive: _rxWindowTimer.isRunning, + txBlockedByOffline: offlineMode && isConnected, + txNotAllowed: isConnected && !txAllowed, + transmitValidationReason: + validation == PingValidation.valid ? null : validation.message, + ); + } + /// Decides whether an intent from the wrist may begin. /// /// Returns null when accepted, or a reason to show on the watch. Every guard @@ -1551,7 +1592,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (admission.refusal != null) return admission.refusal; if (!admission.shouldRun) return null; - if (!isConnected) return 'Not connected'; final requested = resolveWatchRequestedStartMode( requestedMode: command.mode, isConnected: isConnected, @@ -1563,6 +1603,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchStartMode.hybrid => AutoMode.hybrid, null => _resolvedWatchSessionMode, }; + final availability = sessionStartAvailability(mode); + if (!availability.allowed) { + return availability.reason ?? 'Could not start'; + } unawaited(_runWatchStartSession(mode)); return null; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 2e9df65f..d2d55aca 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -438,6 +438,58 @@ WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); +typedef SessionStartAvailability = ({bool allowed, String? reason}); + +/// One start-admission rule shared by the wrist snapshot and command handler. +/// +/// Passive monitoring does not transmit, so a manual TX, its receive window, +/// TX cooldown, offline TX policy, and zone TX policy must not block it. The +/// setup and transition guards still apply to every mode. Keeping that split +/// here prevents the offered button and the radio admission from drifting back +/// into separate policy copies. +SessionStartAvailability resolveSessionStartAvailability({ + required bool isTransmitMode, + required bool isConnected, + required bool antennaConfigured, + required bool powerConfigured, + required bool isPendingDisable, + required bool isTargetedRunning, + required bool isAutoStarting, + required bool cooldownActive, + required bool isPingSending, + required bool rxWindowActive, + required bool txBlockedByOffline, + required bool txNotAllowed, + required String? transmitValidationReason, +}) { + if (!isConnected) return (allowed: false, reason: 'Not connected'); + if (isPendingDisable) return (allowed: false, reason: 'Still stopping'); + if (isTargetedRunning) { + return (allowed: false, reason: 'Trace session active'); + } + if (isAutoStarting) return (allowed: false, reason: 'Already starting'); + if (!antennaConfigured) { + return (allowed: false, reason: 'Select antenna option'); + } + if (!powerConfigured) { + return (allowed: false, reason: 'Select power level'); + } + + if (!isTransmitMode) return (allowed: true, reason: null); + + if (txBlockedByOffline) return (allowed: false, reason: 'Offline Mode'); + if (txNotAllowed) return (allowed: false, reason: 'Passive Only'); + if (cooldownActive) return (allowed: false, reason: 'Cooling down'); + if (isPingSending) return (allowed: false, reason: 'Ping in progress'); + if (rxWindowActive) { + return (allowed: false, reason: 'Listening for ping response'); + } + if (transmitValidationReason != null) { + return (allowed: false, reason: transmitValidationReason); + } + return (allowed: true, reason: null); +} + /// Resolve the wrist's single Start/Stop control without racing the phone's /// asynchronous start transaction. A second Start is the same intent and can /// disappear harmlessly; Stop is the opposite intent, so claiming success diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index d0a67cd9..c103260e 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -265,6 +265,37 @@ void main() { }); group('command kinds', () { + SessionStartAvailability startAvailability({ + bool isTransmitMode = true, + bool isConnected = true, + bool antennaConfigured = true, + bool powerConfigured = true, + bool isPendingDisable = false, + bool isTargetedRunning = false, + bool isAutoStarting = false, + bool cooldownActive = false, + bool isPingSending = false, + bool rxWindowActive = false, + bool txBlockedByOffline = false, + bool txNotAllowed = false, + String? transmitValidationReason, + }) => + resolveSessionStartAvailability( + isTransmitMode: isTransmitMode, + isConnected: isConnected, + antennaConfigured: antennaConfigured, + powerConfigured: powerConfigured, + isPendingDisable: isPendingDisable, + isTargetedRunning: isTargetedRunning, + isAutoStarting: isAutoStarting, + cooldownActive: cooldownActive, + isPingSending: isPingSending, + rxWindowActive: rxWindowActive, + txBlockedByOffline: txBlockedByOffline, + txNotAllowed: txNotAllowed, + transmitValidationReason: transmitValidationReason, + ); + test('round-trip through the wire names Swift sends', () { for (final kind in WatchCommandKind.values) { expect(WatchCommandKind.fromWire(kind.name), kind); @@ -335,6 +366,45 @@ void main() { expect(result, (mode: null, refusal: null)); }); + + test('start admission names every shared blocked precondition', () { + final blocked = { + 'Not connected': startAvailability(isConnected: false), + 'Still stopping': startAvailability(isPendingDisable: true), + 'Trace session active': startAvailability(isTargetedRunning: true), + 'Already starting': startAvailability(isAutoStarting: true), + 'Select antenna option': startAvailability(antennaConfigured: false), + 'Select power level': startAvailability(powerConfigured: false), + 'Offline Mode': startAvailability(txBlockedByOffline: true), + 'Passive Only': startAvailability(txNotAllowed: true), + 'Cooling down': startAvailability(cooldownActive: true), + 'Ping in progress': startAvailability(isPingSending: true), + 'Listening for ping response': startAvailability(rxWindowActive: true), + 'Waiting for GPS lock': startAvailability( + transmitValidationReason: 'Waiting for GPS lock', + ), + }; + + for (final entry in blocked.entries) { + expect(entry.value.allowed, isFalse, reason: entry.key); + expect(entry.value.reason, entry.key); + } + }); + + test('Passive ignores blockers that only constrain a transmitting start', + () { + final passive = startAvailability( + isTransmitMode: false, + cooldownActive: true, + isPingSending: true, + rxWindowActive: true, + txBlockedByOffline: true, + txNotAllowed: true, + transmitValidationReason: 'Waiting for GPS lock', + ); + + expect(passive, (allowed: true, reason: null)); + }); }); group('bridge command handling', () { From df145cff00823a5145c62181c691971d669fb251 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 19:38:47 -0700 Subject: [PATCH 211/360] Stop shipping map geography to a watch that isn't showing a map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a maximal payload — 60 pings, 20 repeaters, four heard rows, encoded exactly as the wire does: full 11,609 bytes suppressed 1,443 bytes (87.6% smaller) Geography was built unconditionally while the phone had no idea what the watch was displaying; it knew only reachability and activation. So every ping coordinate, repeater name and colour was sorted, encoded, transmitted, decoded and discarded whenever the wearer sat on the readout, on Controls or Settings, or — most of a walk — had the wrist down in Always-On, where the readout draws none of it. The wrist now reports whether its current surface needs geography, over the queued command path that already existed. The phone skips building the markers as well as encoding them, so the wasted sort of up to two thousand ping candidates down to sixty goes with them. Every uncertainty resolves toward sending geography, because the failure modes are not symmetric: extra bytes cost battery, an empty map costs the feature. Suppression is a lease the wrist renews rather than a latch it sets, so a watch that stops reporting returns to full payloads by itself. Launch assumes geography is needed until the surface says otherwise. Returning to the map requests a full snapshot immediately instead of waiting for the next scheduled push, and a suppressed payload arriving after the map became visible triggers a throttled replacement rather than leaving half-stale markers on screen. Suppression waits fifteen seconds so an ordinary glance does not enqueue a false-then-true pair, which would trade one kind of waste for another. Wire stays at version 2: both fields are additive, an old phone ignores the claim and keeps sending everything, an absent flag decodes as included. --- ios/MeshMapperWatch/ContentView.swift | 2 +- ios/MeshMapperWatch/MapPage.swift | 11 ++ ios/MeshMapperWatch/WatchSessionClient.swift | 96 +++++++++- ios/Shared/MeshMapperWatchPayload.swift | 16 +- lib/providers/app_state_provider.dart | 50 +++-- lib/services/watch/watch_bridge_service.dart | 50 ++++- lib/services/watch/watch_models.dart | 41 +++- .../watch/watch_wire_contract_test.dart | 175 +++++++++++++++++- 8 files changed, 401 insertions(+), 40 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index f524c704..f3454e9d 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -34,7 +34,7 @@ struct ContentView: View { // without making any individual page create a second one. NavigationStack { TabView(selection: $selection) { - MapPage().tag(0) + MapPage(isSelected: selection == 0).tag(0) ControlsPage().tag(1) // The sheet placement is opened by tapping the map's status panel, which diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 4b2461f5..cca80e99 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -13,6 +13,11 @@ import WatchKit /// verified by pixel diff on device and simulator, so the watch exposes no /// satellite mode. struct MapPage: View { + /// TabView may retain neighbouring pages. The selected tag is the reliable + /// signal that this page is actually visible; view lifecycle alone cannot + /// tell the phone whether its marker payload is useful. + let isSelected: Bool + @Environment(WatchSessionClient.self) private var client @Environment(WatchSettings.self) private var settings @Environment(\.isLuminanceReduced) private var environmentLuminanceReduced @@ -34,6 +39,8 @@ struct MapPage: View { settings.mainPageContent == .map && !isLuminanceReduced } + private var needsMapGeo: Bool { showsMap && isSelected } + @State private var camera: MapCameraPosition = .automatic /// Non-nil once we have driven the camera. Assignment is not proof that @@ -234,6 +241,10 @@ struct MapPage: View { client.debugForceRefusal(forced) } #endif + client.setMapGeoNeeded(needsMapGeo) + } + .onChange(of: needsMapGeo) { _, needed in + client.setMapGeoNeeded(needed) } .onChange(of: trailingToolbarControl) { _, _ in // Stable facts own the slot, but a session transition still changes diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index d0662655..44b0a2f5 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -48,6 +48,15 @@ final class WatchSessionClient: NSObject { /// Set when a payload arrives from a wire version this build predates. private(set) var versionMismatch = false + /// Whether the currently rendered surface needs map-only geography. + /// Launches begin true: an unnecessary full payload is preferable to a map + /// that opens blank before this process has described its current surface. + private var mapGeoNeeded = true + private var lastSentMapGeoNeeded: Bool? + private var mapGeoSuppressionTask: Task? + private var mapGeoRenewalTask: Task? + private var lastMapGeoRecoveryRequestAt: Date? + private var session: WCSession? { WCSession.isSupported() ? WCSession.default : nil } @@ -60,6 +69,9 @@ final class WatchSessionClient: NSObject { static let staleAfter: TimeInterval = 90 private static let cueFreshFor: TimeInterval = 30 private static let cueClockTolerance: TimeInterval = 5 + private static let mapGeoSuppressionDelay: TimeInterval = 15 + private static let mapGeoRenewalInterval: TimeInterval = 5 * 60 + private static let mapGeoRecoveryThrottle: TimeInterval = 3 /// Bring the session up and pull a current snapshot. /// @@ -79,7 +91,8 @@ final class WatchSessionClient: NSObject { if session.activationState == .activated { ingest(context: session.receivedApplicationContext) - send(.requestSnapshot, silent: true) + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } return } @@ -99,7 +112,12 @@ final class WatchSessionClient: NSObject { /// where `sendMessage` can execute the command yet fail its reply as /// undeliverable. One queued path avoids both that false failure and the /// duplicate-transmit risk of retrying an ambiguously delivered message. - func send(_ kind: WatchCommand.Kind, mode: String? = nil, silent: Bool = false) { + func send( + _ kind: WatchCommand.Kind, + mode: String? = nil, + mapGeoNeeded: Bool? = nil, + silent: Bool = false + ) { guard let session, session.activationState == .activated else { if !silent { setLastRefusal("Not connected to iPhone", from: kind) @@ -110,6 +128,7 @@ final class WatchSessionClient: NSObject { let command = WatchCommand( kind: kind, mode: mode, + mapGeoNeeded: mapGeoNeeded, id: UUID().uuidString, issuedAtMs: Date().timeIntervalSince1970 * 1000 ) @@ -130,6 +149,61 @@ final class WatchSessionClient: NSObject { session.transferUserInfo([MeshMapperWatchWire.commandKey: dict]) } + /// Report whether the current surface needs its expensive marker payload. + /// Returning to the map is immediate; suppression waits out short wrist-down + /// transitions so ordinary glances do not enqueue a false/true pair. + func setMapGeoNeeded(_ needed: Bool) { + mapGeoNeeded = needed + mapGeoSuppressionTask?.cancel() + mapGeoSuppressionTask = nil + + if needed { + mapGeoRenewalTask?.cancel() + mapGeoRenewalTask = nil + sendMapGeoPreference(true) + } else { + scheduleMapGeoSuppression() + } + } + + private func scheduleMapGeoSuppression() { + mapGeoSuppressionTask?.cancel() + mapGeoSuppressionTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.mapGeoSuppressionDelay)) + guard !Task.isCancelled, let self, !self.mapGeoNeeded else { return } + self.mapGeoSuppressionTask = nil + self.sendMapGeoPreference(false) + self.scheduleMapGeoRenewal() + } + } + + private func scheduleMapGeoRenewal() { + mapGeoRenewalTask?.cancel() + mapGeoRenewalTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.mapGeoRenewalInterval)) + guard !Task.isCancelled, let self, !self.mapGeoNeeded else { return } + self.mapGeoRenewalTask = nil + // The phone treats suppression as a lease. Renewal keeps a long + // Always-On session cheap; if this task stops, the lease expires back + // to full geography rather than leaving a future map blank. + self.sendMapGeoPreference(false, force: true) + self.scheduleMapGeoRenewal() + } + } + + private func sendMapGeoPreference(_ needed: Bool, force: Bool = false) { + guard force || lastSentMapGeoNeeded != needed else { return } + guard let session, session.activationState == .activated else { return } + lastSentMapGeoNeeded = needed + send(.requestSnapshot, mapGeoNeeded: needed, silent: true) + } + + private func requestFullSnapshot() { + // Activation always starts from the safe assumption even if a retained + // application context says the previous process had suppressed its map. + sendMapGeoPreference(true, force: true) + } + private func beginPending(_ kind: WatchCommand.Kind) { pendingCommandTimeoutTask?.cancel() pendingCommand = kind @@ -232,6 +306,21 @@ final class WatchSessionClient: NSObject { // state dedupe means no snapshot follows. self.clearPendingCommand() + if self.mapGeoNeeded && !decoded.mapGeoIncluded { + // updateApplicationContext is latest-state-wins, but a context sent + // just before the map reappeared may still win the delivery race. The + // empty arrays are rendered honestly, then a full replacement is + // requested immediately rather than mixing old markers with new state. + let lastRequest = self.lastMapGeoRecoveryRequestAt + if lastRequest == nil || + arrival.timeIntervalSince(lastRequest ?? .distantPast) >= + Self.mapGeoRecoveryThrottle + { + self.lastMapGeoRecoveryRequestAt = arrival + self.sendMapGeoPreference(true, force: true) + } + } + if let cue = decoded.cue, Self.isFresh(cue, at: arrival), self.presentedCueIDs.insert(cue.id).inserted @@ -266,7 +355,8 @@ extension WatchSessionClient: WCSessionDelegate { ingest(context: session.receivedApplicationContext) if pendingRefresh { pendingRefresh = false - send(.requestSnapshot, silent: true) + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } } } } diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 02633f59..a61f440c 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -232,6 +232,9 @@ struct WatchSnapshot: Codable, Hashable { /// to derive policy independently. let availableStartModes: [String] + /// False means the map-only arrays were intentionally cleared. Missing on + /// older additive-v2 payloads means full geography, the fail-safe default. + let mapGeoIncluded: Bool let geo: WatchGeo let controls: WatchControls let cue: WatchHapticCue? @@ -255,6 +258,7 @@ struct WatchSnapshot: Codable, Hashable { queueSize: Int, pingColor: WatchColor?, availableStartModes: [String] = ["passive"], + mapGeoIncluded: Bool = true, geo: WatchGeo, controls: WatchControls, cue: WatchHapticCue?, @@ -277,6 +281,7 @@ struct WatchSnapshot: Codable, Hashable { self.queueSize = queueSize self.pingColor = pingColor self.availableStartModes = availableStartModes + self.mapGeoIncluded = mapGeoIncluded self.geo = geo self.controls = controls self.cue = cue @@ -287,7 +292,7 @@ struct WatchSnapshot: Codable, Hashable { case wireVersion, sessionId, mode, phase, phaseTitle, phaseDetail case phaseEndsAtMs, phaseDurationMs, isConnected, zoneCode case txCount, rxCount, discoveryCount, traceCount, queueSize, pingColor - case availableStartModes, geo, controls, cue, updatedAtMs + case availableStartModes, mapGeoIncluded, geo, controls, cue, updatedAtMs } init(from decoder: Decoder) throws { @@ -315,6 +320,12 @@ struct WatchSnapshot: Codable, Hashable { [String].self, forKey: .availableStartModes ) ?? ["passive"] + // Additive v2 field. An old phone always sends full geography, so absence + // must mean included; treating it as suppressed could blank a real map. + mapGeoIncluded = try values.decodeIfPresent( + Bool.self, + forKey: .mapGeoIncluded + ) ?? true geo = try values.decode(WatchGeo.self, forKey: .geo) controls = try values.decode(WatchControls.self, forKey: .controls) cue = try values.decodeIfPresent(WatchHapticCue.self, forKey: .cue) @@ -361,6 +372,9 @@ struct WatchCommand: Codable, Hashable { /// Optional additive field. Older phones ignore it and retain their safe /// mode resolver; new phones revalidate it instead of silently downgrading. let mode: String? + /// Optional map-demand state carried only by requestSnapshot. An old phone + /// ignores it and keeps sending full geography, so wire v2 remains safe. + let mapGeoNeeded: Bool? /// Client-generated, so the phone can dedupe redelivered commands. let id: String /// Queued delivery can outlive the place where a transmit was requested. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index bff141e7..65c3389c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1240,6 +1240,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { isConnected: isConnected, controls: controls, cue: _watchCue, + mapGeoIncluded: _watchBridge.shouldIncludeMapGeo, ); } @@ -1256,6 +1257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final now = DateTime.now(); final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); final pingColor = _resolveWatchPingColor(); + final includeMapGeo = _watchBridge.shouldIncludeMapGeo; final core = LiveActivitySnapshot( sessionId: _liveActivitySessionId ?? 'idle', @@ -1284,8 +1286,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchSnapshot( core: core, - geo: _buildWatchGeo(), + geo: _buildWatchGeo(includeMapGeo: includeMapGeo), controls: _buildWatchControls(), + mapGeoIncluded: includeMapGeo, availableStartModes: _availableWatchStartModes, pingColor: pingColor, cue: _watchCue, @@ -1316,20 +1319,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } - WatchGeo _buildWatchGeo() { + WatchGeo _buildWatchGeo({required bool includeMapGeo}) { final position = _resolveWatchPosition(); - // Repeaters heard during the current cycle get the highlight ring. - final heardIds = _topRepeatersOverlay - .map((r) => r.repeaterId.toUpperCase()) - .toSet(); - // The wrist mirrors the map's "Top Heard" overlay: the latest ping's top // three by SNR plus the current RX slot. Same source, so the two surfaces // can never disagree. final top = _topRepeatersOverlay; final rxSlot = _rxOverlaySlot; - if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase()); // Overlay IDs are hex path hashes, so resolve names by prefix at whatever // length this zone actually uses. @@ -1339,6 +1336,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final repeaterByHex = hexLength > 0 ? WatchGeoBuilder.indexByHexPrefix(_repeaters, hexLength) : const {}; + final heard = WatchGeoBuilder.buildHeard( + top: top, + rxSlot: rxSlot, + repeaterByHex: repeaterByHex, + topAt: _topRepeatersOverlayUpdatedAt, + rxAt: _liveActivityRxUpdatedAt, + lat: position?.lat, + lon: position?.lon, + ); + + // The readout still needs the fix and Top Heard, but none of the arrays + // below. Return before merging and sorting four ping histories or sorting + // the repeater catalogue: suppression is meant to save phone work as well + // as radio bytes. + if (!includeMapGeo) { + return WatchGeo( + you: position, + pings: const [], + repeaters: const [], + heard: heard, + linkedRepeaterIds: const [], + ); + } + + // Repeaters heard during the current cycle get the highlight ring. + final heardIds = top.map((r) => r.repeaterId.toUpperCase()).toSet(); + if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase()); return WatchGeo( you: position, @@ -1354,15 +1378,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat: position?.lat, lon: position?.lon, ), - heard: WatchGeoBuilder.buildHeard( - top: top, - rxSlot: rxSlot, - repeaterByHex: repeaterByHex, - topAt: _topRepeatersOverlayUpdatedAt, - rxAt: _liveActivityRxUpdatedAt, - lat: position?.lat, - lon: position?.lon, - ), + heard: heard, linkedRepeaterIds: [ ...WatchGeoBuilder.resolveUniqueHexPrefixes( repeaters: _repeaters, diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index ee6211ff..90833955 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -35,6 +35,8 @@ class WatchBridgeService { static const Duration _debounceDelay = Duration(milliseconds: 200); static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); static const Duration _maximumCommandAge = Duration(seconds: 30); + static const Duration _mapGeoClaimFreshFor = Duration(minutes: 10); + static const Duration _clockTolerance = Duration(seconds: 5); final MethodChannel _channel; @@ -53,6 +55,8 @@ class WatchBridgeService { bool _disposed = false; bool _didReconcileNativeState = false; bool _canSync = false; + DateTime? _mapGeoSuppressedAt; + double? _lastMapGeoClaimIssuedAtMs; Future _operationChain = Future.value(); /// Commands already handled, so redelivery can't fire a second transmit. @@ -62,6 +66,17 @@ class WatchBridgeService { !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; bool get canSync => isSupportedPlatform && _canSync; + /// Whether the next payload must carry map-only geography. + /// + /// Suppression is leased rather than latched. If the wrist stops renewing + /// its claim, the phone returns to full geo on the next build; excess bytes + /// are safer than leaving a newly-visible map blank. + bool get shouldIncludeMapGeo { + final suppressedAt = _mapGeoSuppressedAt; + if (suppressedAt == null) return true; + return DateTime.now().difference(suppressedAt) >= _mapGeoClaimFreshFor; + } + /// Wire up the inbound command path. Safe to call more than once. void attachCommandHandler( WatchCommandHandler handler, { @@ -113,9 +128,35 @@ class WatchBridgeService { final rawIssuedAtMs = args['issuedAtMs']; final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; + final ageMs = issuedAtMs == null + ? null + : DateTime.now().millisecondsSinceEpoch - issuedAtMs; + final requestedMapGeo = args['mapGeoNeeded']; + final mapGeoNeeded = requestedMapGeo is bool ? requestedMapGeo : null; + final freshMapGeoSuppression = ageMs != null && + ageMs >= -_clockTolerance.inMilliseconds && + ageMs <= _maximumCommandAge.inMilliseconds; + final latestMapGeoClaim = _lastMapGeoClaimIssuedAtMs; + final suppressionIsNewest = issuedAtMs != null && + (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim); + final effectiveMapGeoNeeded = mapGeoNeeded == false && + (!freshMapGeoSuppression || !suppressionIsNewest) + ? null + : mapGeoNeeded; + if (kind == WatchCommandKind.requestSnapshot && + effectiveMapGeoNeeded != null) { + // A stale or out-of-order false could arrive after the wrist returned to + // the map. Ignore it silently; true is always safe because it only + // restores detail. Never move the ordering watermark backwards when an + // older true is accepted for that conservative reason. + _mapGeoSuppressedAt = effectiveMapGeoNeeded ? null : DateTime.now(); + if (issuedAtMs != null && + (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim)) { + _lastMapGeoClaimIssuedAtMs = issuedAtMs; + } + } if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { - final ageMs = DateTime.now().millisecondsSinceEpoch - issuedAtMs; - if (ageMs > _maximumCommandAge.inMilliseconds) { + if (ageMs! > _maximumCommandAge.inMilliseconds) { const reason = 'Took too long to reach iPhone'; // This window is about correctness, not queue housekeeping: executing // a transmit after the vehicle has moved attributes it to the wrong @@ -133,6 +174,7 @@ class WatchBridgeService { final admission = handler(WatchCommand( kind: kind, mode: args['mode'] as String?, + mapGeoNeeded: effectiveMapGeoNeeded, )); final refusal = admission is Future ? await admission : admission; @@ -178,6 +220,8 @@ class WatchBridgeService { // availability remains true, or an installed replacement watch could wait // forever for state whose fingerprint Dart still considers delivered. if (!available || refreshNativeState) { + _mapGeoSuppressedAt = null; + _lastMapGeoClaimIssuedAtMs = null; _lastPayload = null; _lastUrgencyKey = null; _lastSentAt = null; @@ -331,5 +375,7 @@ class WatchBridgeService { _commandRefusalHandler = null; _availabilityHandler = null; _snapshotDeliveryHandler = null; + _mapGeoSuppressedAt = null; + _lastMapGeoClaimIssuedAtMs = null; } } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index d2d55aca..f349d755 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -25,10 +25,10 @@ class WatchWire { /// gone: the overlay is fed `directRepeaters` only. /// /// Additive optional fields do not bump this version. A new watch defaults - /// an absent start-mode list to Passive and absent Ping applicability to - /// false, while an older phone ignores the optional mode on a command and - /// retains its existing safe resolver. A bump would therefore strand - /// compatible pairs without preventing a bad decode. + /// an absent start-mode list to Passive, absent Ping applicability to false, + /// and absent map-geo state to included. An older phone ignores optional + /// command fields and keeps sending full geography. A bump would therefore + /// strand compatible pairs without preventing a bad decode. static const int version = 2; static const int maxPings = 60; @@ -208,12 +208,15 @@ class WatchGeo { final List heard; final List linkedRepeaterIds; - Map toMap() => { + Map toMap({bool includeMapDetail = true}) => { 'you': you?.toMap(), - 'pings': pings.map((p) => p.toMap()).toList(), - 'repeaters': repeaters.map((r) => r.toMap()).toList(), + 'pings': + includeMapDetail ? pings.map((p) => p.toMap()).toList() : const [], + 'repeaters': includeMapDetail + ? repeaters.map((r) => r.toMap()).toList() + : const [], 'heard': heard.map((h) => h.toMap()).toList(), - 'linkedRepeaterIds': linkedRepeaterIds, + 'linkedRepeaterIds': includeMapDetail ? linkedRepeaterIds : const [], }; } @@ -294,6 +297,7 @@ class WatchSnapshot { required this.geo, required this.controls, required this.updatedAt, + this.mapGeoIncluded = true, this.availableStartModes = const [WatchStartMode.passive], this.pingColor, this.cue, @@ -304,6 +308,11 @@ class WatchSnapshot { final LiveActivitySnapshot core; final WatchGeo geo; final WatchControls controls; + + /// False means map-only arrays were deliberately cleared, not that the + /// current area simply has no markers. The wrist uses this to recover from + /// an out-of-order suppressed context when the map is already visible. + final bool mapGeoIncluded; final List availableStartModes; final WatchColor? pingColor; final WatchHapticCue? cue; @@ -333,9 +342,13 @@ class WatchSnapshot { 'traceCount': core.traceCount, 'queueSize': core.queueSize, 'pingColor': pingColor?.toMap(), + 'mapGeoIncluded': mapGeoIncluded, 'availableStartModes': availableStartModes.map((mode) => mode.name).toList(), - 'geo': geo.toMap(), + // Keep the geo object and its keys for older v2 watches, but clear the + // map-only arrays when the wrist has leased suppression. The provider + // also avoids constructing them; this is the last-line wire invariant. + 'geo': geo.toMap(includeMapDetail: mapGeoIncluded), 'controls': controls.toMap(), 'cue': cue?.toMap(), 'updatedAtMs': updatedAt.millisecondsSinceEpoch.toDouble(), @@ -355,6 +368,7 @@ class WatchSnapshot { isConnected: core.isConnected, controls: controls, cue: cue, + mapGeoIncluded: mapGeoIncluded, ); static String buildUrgencyKey({ @@ -367,6 +381,7 @@ class WatchSnapshot { required bool isConnected, required WatchControls controls, required WatchHapticCue? cue, + bool mapGeoIncluded = true, }) => [ sessionId, @@ -380,6 +395,7 @@ class WatchSnapshot { controls.canManualPing, controls.isSessionActive, controls.manualPingApplicable, + mapGeoIncluded, cue?.id ?? '', ].join('|'); } @@ -402,10 +418,15 @@ enum WatchCommandKind { /// Decoded wrist intent. [mode] stays raw until phone-side admission so an /// unknown value can be refused rather than mistaken for an omitted mode. class WatchCommand { - const WatchCommand({required this.kind, this.mode}); + const WatchCommand({required this.kind, this.mode, this.mapGeoNeeded}); final WatchCommandKind kind; final String? mode; + + /// Optional state piggybacked on requestSnapshot. Old phones ignore it and + /// keep the fail-safe full payload; new phones suppress only after a fresh + /// false claim. + final bool? mapGeoNeeded; } typedef WatchRequestedStartModeResolution = ({ diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index c103260e..75831f47 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -18,6 +18,8 @@ WatchSnapshot _snapshot({ String phaseTitle = 'Listening', bool isConnected = true, int? phaseDurationMs, + WatchGeo? geo, + bool mapGeoIncluded = true, List availableStartModes = const [WatchStartMode.passive], }) => WatchSnapshot( @@ -40,17 +42,19 @@ WatchSnapshot _snapshot({ repeatersAreCurrent: true, updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), ), - geo: const WatchGeo( - pings: [], - repeaters: [], - heard: [], - linkedRepeaterIds: [], - ), + geo: geo ?? + const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), controls: const WatchControls( canStartStop: true, canManualPing: false, isSessionActive: true, ), + mapGeoIncluded: mapGeoIncluded, availableStartModes: availableStartModes, pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, @@ -82,6 +86,7 @@ void main() { 'traceCount', 'queueSize', 'pingColor', + 'mapGeoIncluded', 'availableStartModes', 'geo', 'controls', @@ -112,6 +117,103 @@ void main() { expect((map['pingColor']! as Map).keys.toSet(), {'r', 'g', 'b'}); }); + test('full snapshots retain every geography field', () { + final at = DateTime.fromMillisecondsSinceEpoch(1759999980000); + final geo = WatchGeo( + you: WatchPosition(lat: 47.6, lon: -122.3, fixedAt: at), + pings: [ + WatchPing( + id: 'ping-1', + lat: 47.61, + lon: -122.31, + kind: 'tx', + color: const WatchColor(0, 1, 0), + at: at, + ), + ], + repeaters: const [ + WatchRepeater( + id: 'database-1', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.62, + lon: -122.32, + color: WatchColor(1, 0, 1), + heardThisCycle: true, + ), + ], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: at, + ), + ], + linkedRepeaterIds: const ['database-1'], + ); + final map = _snapshot(geo: geo).toMap(); + final encodedGeo = map['geo']! as Map; + + expect(map['mapGeoIncluded'], isTrue); + expect(encodedGeo['you'], isNotNull); + expect(encodedGeo['pings'], hasLength(1)); + expect(encodedGeo['repeaters'], hasLength(1)); + expect(encodedGeo['heard'], hasLength(1)); + expect(encodedGeo['linkedRepeaterIds'], ['database-1']); + }); + + test('suppressed snapshots clear only map detail', () { + final at = DateTime.fromMillisecondsSinceEpoch(1759999980000); + final geo = WatchGeo( + you: WatchPosition(lat: 47.6, lon: -122.3, fixedAt: at), + pings: [ + WatchPing( + id: 'ping-1', + lat: 47.61, + lon: -122.31, + kind: 'tx', + color: const WatchColor(0, 1, 0), + at: at, + ), + ], + repeaters: const [ + WatchRepeater( + id: 'database-1', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.62, + lon: -122.32, + color: WatchColor(1, 0, 1), + heardThisCycle: true, + ), + ], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: at, + ), + ], + linkedRepeaterIds: const ['database-1'], + ); + final map = _snapshot(geo: geo, mapGeoIncluded: false).toMap(); + final encodedGeo = map['geo']! as Map; + + expect(map['mapGeoIncluded'], isFalse); + expect(encodedGeo['you'], isNotNull, + reason: 'the fix is cheap and remains useful state'); + expect(encodedGeo['heard'], hasLength(1), + reason: 'the readout consumes Top Heard'); + expect(encodedGeo['pings'], isEmpty); + expect(encodedGeo['repeaters'], isEmpty); + expect(encodedGeo['linkedRepeaterIds'], isEmpty); + }); + + test('old payload semantics default map geography to included', () { + expect(_snapshot().mapGeoIncluded, isTrue); + expect(_snapshot().toMap()['mapGeoIncluded'], isTrue); + }); + test('timestamps are epoch milliseconds as doubles', () { final map = _snapshot().toMap(); expect(map['updatedAtMs'], isA()); @@ -452,6 +554,8 @@ void main() { String id, String kind, { String? mode, + bool? mapGeoNeeded, + double? issuedAtMs, }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger @@ -462,6 +566,8 @@ void main() { 'id': id, 'kind': kind, if (mode != null) 'mode': mode, + if (mapGeoNeeded != null) 'mapGeoNeeded': mapGeoNeeded, + if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, }), ), null, @@ -625,6 +731,63 @@ void main() { expect(reply?['accepted'], isTrue); }); + test('fresh map demand suppresses and restores geography', () async { + bridge.attachCommandHandler((_) => null); + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + + await sendCommand( + 'geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: nowMs, + ); + expect(bridge.shouldIncludeMapGeo, isFalse); + + await sendCommand( + 'geo-on', + 'requestSnapshot', + mapGeoNeeded: true, + ); + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('stale suppression cannot blank a newly visible map', () async { + bridge.attachCommandHandler((_) => null); + final staleMs = DateTime.now() + .subtract(const Duration(seconds: 45)) + .millisecondsSinceEpoch + .toDouble(); + + await sendCommand( + 'stale-geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: staleMs, + ); + + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('an older queued suppression cannot overtake map demand', () async { + bridge.attachCommandHandler((_) => null); + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + + await sendCommand( + 'new-geo-on', + 'requestSnapshot', + mapGeoNeeded: true, + issuedAtMs: nowMs, + ); + await sendCommand( + 'old-geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: nowMs - 1000, + ); + + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + test('a redelivered command does not transmit twice', () async { bridge.attachCommandHandler((command) async { handled.add(command.kind); From c2b97355341ab905f7c2ca14ed0743ec0bed24a2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:08:33 -0700 Subject: [PATCH 212/360] Let MapKit know what covers the map instead of shifting the camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Crown zooms about the map's region centre. We had been lifting that centre by hand so the fix rendered above the status panel, so a zoom slid the puck across the screen and the correction pass snapped it back when the gesture ended. Both halves worked as designed; the design was the problem. Telling MapKit what is covering it — a safe-area inset for the panel and for the toolbar strip — makes its own centre the centre of the band the wearer can actually see. The fix then lands there because it is centred on, not translated toward, and a zoom anchors on it. Adam drove the Crown on a build of this and reported it stays centred throughout, which is the half neither the simulator nor a static capture can show. That removes the machinery the old approach needed: the target point, the coordinate translation, the post-render correction loop and its deadband, and the remembered rendered centre. `programmaticCenter` stays, now solely as the sentinel for the span handshake, which is unaffected — it is only ever tested for non-nil. Both edges are inset on purpose. Insetting only the bottom centres the fix in the band from the top of the display to the panel, which still includes the toolbar, and the puck reads high — plainly so on 46 mm where the chrome is a smaller fraction of the screen. Measured: the fix sits at 91.2 pt on 40 mm against a predicted 92, and 116.8 pt on 46 mm against 117. The status panel is pixel-identical across every variant, a fresh install still settles at 250 m on both sizes, and a panel height change — forced with long hex IDs — leaves the persisted span untouched, so a growing panel cannot drift the zoom. `MapReader` is now vestigial: nothing calls `proxy.convert`, so the proxy is threaded through five functions unused and its comment is no longer true. Removing it is a hierarchy change around a map whose launch and framing behaviour was just verified, so it belongs in its own change with its own A/B rather than riding along here. --- ios/MeshMapperWatch/MapPage.swift | 92 ++++++++++--------------------- 1 file changed, 30 insertions(+), 62 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index cca80e99..5650dd51 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -106,11 +106,17 @@ struct MapPage: View { } } - /// Where the fix belongs on screen: midway between the top of the display and - /// the top of the panel. - private var targetPoint: CGPoint? { - guard panelFrame.height > 0 else { return nil } - return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) + /// How much of the display the status panel is covering, handed to MapKit so + /// it frames the camera in the band that remains visible. + /// + /// Measured rather than assumed: the panel's height changes with its content + /// — one column or two, with or without heard rows — and a stale constant + /// would drift the fix off centre exactly when the panel grew. Before the + /// first measurement this is zero, which simply centres on the full display + /// for a frame. + private var panelCameraInset: CGFloat { + guard panelFrame.height > 0 else { return 0 } + return max(0, WKInterfaceDevice.current().screenBounds.height - panelFrame.minY) } /// The placement scales with the estimated corner radius, putting every @@ -880,9 +886,19 @@ struct MapPage: View { fixMarker } .mapStyle(.standard) + // Tell MapKit what is covering the map, rather than hand-shifting the + // camera to compensate. Its own centre then *is* the centre of the band + // the wearer can actually see, so the fix lands there without a correction + // pass and a Crown zoom anchors on it instead of sliding it across. + // + // Both edges matter. Insetting only the bottom centres the fix in + // `0...panelTop`, which still includes the toolbar strip, and the puck then + // reads high — obviously so on 46 mm, where the chrome is a smaller + // fraction of the display. + .safeAreaPadding(.top, currentTopSafeAreaInset) + .safeAreaPadding(.bottom, panelCameraInset) .onMapCameraChange(frequency: .onEnd) { context in noteRenderedRegion(context.region) - correctPlacement(proxy) } // The shell's navigation host supplies the system toolbar placement but // must not buy it by shortening the basemap. Only MapKit extends under that @@ -975,9 +991,11 @@ struct MapPage: View { private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { guard showsMap, force || isFollowing, let fix else { return } - let center = centerPlacing(fix, proxy: proxy) - programmaticCenter = center - let region = MKCoordinateRegion(center: center, span: currentSpan) + // Centre on the fix itself. The camera inset already accounts for the + // panel, so no compensating shift is needed and nothing has to be + // corrected after the map reports back. + programmaticCenter = fix + let region = MKCoordinateRegion(center: fix, span: currentSpan) if force { // A tap is a rare, explicit request to move the map, so animation shows @@ -988,56 +1006,12 @@ struct MapPage: View { camera = .region(region) } } else { - // First placement, GPS steps and placement corrections all cut so the - // puck stays visually fixed while the world moves beneath it. + // First placement and GPS steps cut, so the puck stays visually fixed + // while the world moves beneath it. camera = .region(region) } } - /// Region centre that puts [fix] midway between the top of the display and - /// the top of the panel. - /// - /// MapKit centres the region in the map, so with a panel over the lower third - /// the fix would sit low and partly behind it. Rather than predict how far to - /// shift, this asks the map which coordinate is at the target point today and - /// translates the camera by the difference — exact whatever the projection, - /// the zoom, or the latitude. - private func centerPlacing( - _ fix: CLLocationCoordinate2D, - proxy: MapProxy - ) -> CLLocationCoordinate2D { - // Before the first render there is nothing to translate against; centring - // on the fix is the right opening move, and `correctPlacement` lifts it as - // soon as the map reports back. - guard let targetPoint, - let renderedCenter, - let atTarget = proxy.convert(targetPoint, from: .global) - else { return fix } - - // Clamped so a bad conversion — an off-map point, a mid-animation read — - // can never fling the camera somewhere the wearer has to chase. - let lift = (fix.latitude - atTarget.latitude) - .clamped(to: -currentSpan.latitudeDelta...currentSpan.latitudeDelta) - return CLLocationCoordinate2D( - latitude: renderedCenter.latitude + lift, - longitude: fix.longitude - ) - } - - /// Nudge the camera once the map reports where things really landed. - /// - /// The first placement runs before any render, and a zoom changes the scale - /// underneath us, so placement is a feedback loop rather than a calculation. - /// The deadband is what stops it: each pass lands within a couple of points, - /// the next sees no error worth fixing, and it settles. - private func correctPlacement(_ proxy: MapProxy) { - guard showsMap, isFollowing, let fix, let targetPoint, - let point = proxy.convert(fix, to: .global) - else { return } - guard abs(point.y - targetPoint.y) > 6 else { return } - recenterIfFollowing(proxy) - } - /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one /// independent zoom value. After the one-time defaults migration it starts /// at 0.00225 degrees, about 250 m north-south, and later launches reuse the @@ -1049,15 +1023,9 @@ struct MapPage: View { ) } - /// Centre of the region MapKit last rendered — the fixed point every - /// placement is measured against. - @State private var renderedCenter: CLLocationCoordinate2D? - /// Track the region MapKit actually rendered, so a Digital Crown zoom is not - /// thrown away on the next follow update and so placement has a known - /// starting point. + /// thrown away on the next follow update. private func noteRenderedRegion(_ region: MKCoordinateRegion) { - renderedCenter = region.center // `.automatic` can report its annotation fit even after we assign our first // region, so `programmaticCenter != nil` proves only that a request was // made, not that MapKit rendered it. Persist nothing until the rendered From 13346e9c0c49e8d9ae9c4a0f0a3dc6ad0ffd6978 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:15:54 -0700 Subject: [PATCH 213/360] Flag the vestigial MapReader for a decision before the PR Camera placement moved to safe-area insets, so nothing calls proxy.convert any more and the reader's comment claimed a purpose it no longer has. Record what it is and why removing it wants its own change, rather than leaving a false rationale for the next reader to trust. --- ios/MeshMapperWatch/MapPage.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 5650dd51..43f4add3 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -269,9 +269,15 @@ struct MapPage: View { private var pageContent: some View { ZStack { if showsMap { - // The proxy is the only reliable way to relate a coordinate to a point - // on screen. Keep the reader inside this branch: constructing even map + // Keep the reader inside this branch: constructing even map // infrastructure behind the readout would defeat its battery purpose. + // + // DECIDE BEFORE OPENING A PR: this reader is now vestigial. Camera + // placement moved to safe-area insets, so nothing calls + // `proxy.convert` and the proxy is threaded through five functions + // unused. Removing it is a hierarchy change around a map whose launch + // and framing behaviour was verified by measurement, so it wants its + // own change and its own A/B — not a quiet tidy-up inside another one. MapReader { proxy in mapContent(proxy) } From 336d047485c45d0e2a792171e0b88f7fcd732c35 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:15:54 -0700 Subject: [PATCH 214/360] Stop renaming every map marker when one new ping arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker ids embedded the entry's index in its source list. Those histories insert at the front and trim from the back, so one new discovery renumbered every surviving marker and SwiftUI saw sixty replacements rather than a single insertion — tearing down and rebuilding the whole annotation set to show one new dot. TX and RX had the same fault once their five-hundred-entry lists began trimming. Identity now comes from the event's own timestamp, which is intrinsic to it and survives both insertion and trimming. Two events of one kind inside the same millisecond are the only case needing a discriminator, and it counts within the colliding group rather than across the list, so it does not reintroduce positional churn. The ids are also marginally shorter than the ones they replace. The regression test was checked against the old implementation and fails on it, so it covers the property rather than merely describing it. Not measured: how much energy the churn actually cost. That needs Watch Instruments on hardware. The churn itself was certain from the code, and the fix is cheap enough not to need the number first. --- lib/services/watch/watch_geo_builder.dart | 35 ++++++++++++------ .../watch/watch_geo_builder_test.dart | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 4c801512..d9118cf6 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -131,10 +131,24 @@ class WatchGeoBuilder { }) { final pings = []; - for (var i = 0; i < txPings.length; i++) { - final tx = txPings[i]; + // Identity must not depend on a marker's position in its source list. + // These histories insert at the front and trim from the back, so a + // positional id renames every surviving marker whenever one arrives, and + // SwiftUI then tears down and rebuilds all sixty annotations to show one + // new dot. Timestamps are intrinsic to the event and survive both. + final seen = {}; + String stableId(String kind, DateTime at) { + final base = '$kind-${at.millisecondsSinceEpoch}'; + final n = seen.update(base, (v) => v + 1, ifAbsent: () => 0); + // Two events of one kind inside the same millisecond are the only case + // needing a discriminator, and it stays put because it counts within the + // colliding group rather than across the whole list. + return n == 0 ? base : '$base~$n'; + } + + for (final tx in txPings) { pings.add(WatchPing( - id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('tx', tx.timestamp), lat: tx.latitude, lon: tx.longitude, kind: 'tx', @@ -143,10 +157,9 @@ class WatchGeoBuilder { )); } - for (var i = 0; i < rxPings.length; i++) { - final rx = rxPings[i]; + for (final rx in rxPings) { pings.add(WatchPing( - id: 'rx-${rx.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('rx', rx.timestamp), lat: rx.latitude, lon: rx.longitude, kind: 'rx', @@ -155,10 +168,9 @@ class WatchGeoBuilder { )); } - for (var i = 0; i < discLogEntries.length; i++) { - final entry = discLogEntries[i]; + for (final entry in discLogEntries) { pings.add(WatchPing( - id: 'disc-${entry.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('disc', entry.timestamp), lat: entry.latitude, lon: entry.longitude, kind: 'disc', @@ -167,10 +179,9 @@ class WatchGeoBuilder { )); } - for (var i = 0; i < traceLogEntries.length; i++) { - final entry = traceLogEntries[i]; + for (final entry in traceLogEntries) { pings.add(WatchPing( - id: 'trace-${entry.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('trace', entry.timestamp), lat: entry.latitude, lon: entry.longitude, kind: 'trace', diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index b2c8a3c5..dcc45439 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -76,6 +76,43 @@ void main() { setUp(() => PingColors.setColorVisionType(ColorVisionType.none)); group('buildPings', () { + test('marker identity survives a new event arriving at the front', () { + // These histories insert newest-first. A positional id renumbered every + // surviving marker whenever one arrived, so SwiftUI rebuilt all sixty + // annotations to show one new dot. + final base = DateTime(2026, 8, 12, 10); + final older = List.generate(5, (i) => _disc(base.add(Duration(minutes: i)), discovered: true)); + + List idsFor(List entries) => WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: entries, + traceLogEntries: const [], + ).map((p) => p.id).toList(); + + final before = idsFor(older.reversed.toList()); + final arrival = _disc(base.add(const Duration(minutes: 9)), discovered: true); + final after = idsFor([arrival, ...older.reversed]); + + expect(after.length, before.length + 1); + expect( + after.toSet().containsAll(before), + isTrue, + reason: 'every pre-existing marker must keep its id', + ); + }); + + test('two events of one kind in the same millisecond stay distinct', () { + final at = DateTime(2026, 8, 12, 10); + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: [_disc(at, discovered: true), _disc(at, discovered: false)], + traceLogEntries: const [], + ); + expect(pings.map((p) => p.id).toSet().length, 2); + }); + test('merges TX and RX newest-first and caps the list', () { final base = DateTime(2026, 8, 12, 10); final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); From df0dcbdbdbaee12f217bbefb05b2f6a40ce62b93 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:28:04 -0700 Subject: [PATCH 215/360] Add a wrist switch for measuring the countdown drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar's fill is an animated layout width, so SwiftUI re-runs layout on the main thread every frame for the whole phase instead of handing a transform to the render server. That is the expensive class of animation, and a session is almost entirely consecutive countdown phases — but how much energy it actually costs is not knowable from the source, and the cheaper alternatives change how the bar looks. Adam chose this bar, so the measurement has to come before the redesign. A DEBUG-only Settings toggle freezes the drain and leaves everything else running: same snapshots, same markers, same live timer text, same layout. An Instruments trace of the two states therefore isolates this one animation rather than a whole different build. It lives on the watch rather than behind a launch argument so the two states can be compared on a walk without a Mac, and it is read statically so flipping it does not invalidate the view mid-drain and perturb the trace it exists to produce. Compiled out of Release. --- ios/MeshMapperWatch/MapPage.swift | 11 +++++++++++ ios/MeshMapperWatch/SettingsPage.swift | 8 ++++++++ ios/MeshMapperWatch/WatchSettings.swift | 17 +++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 43f4add3..39f98bd3 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1158,9 +1158,20 @@ private struct WatchPhaseBar: View { guard remaining > 0 else { return } await Task.yield() + #if DEBUG + // A/B harness for the drain's energy cost. The fill is an animated *layout* + // width, so SwiftUI re-runs layout every frame for the whole phase rather + // than handing a transform to the render server. Freezing it leaves every + // other cost in place — same snapshots, same markers, same timer text — so + // an Instruments trace of the two states isolates this animation alone. + if !WatchSettings.debugFreezesTimerBar { + withAnimation(.linear(duration: remaining)) { remainingFraction = 0 } + } + #else withAnimation(.linear(duration: remaining)) { remainingFraction = 0 } + #endif do { try await Task.sleep(for: .seconds(remaining)) diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 75972f2a..571b8a1d 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -66,6 +66,14 @@ struct SettingsPage: View { isOn: $settings.showPingWhenAvailable ) } + + #if DEBUG + // Not a preference — an A/B switch for an Instruments trace, so it never + // reaches Release. Takes effect from the next phase rather than mid-drain. + Section("Instruments") { + Toggle("Freeze timer bar", isOn: $settings.freezesTimerBar) + } + #endif } .font(.caption) } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 28cb3d45..17f78ee5 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -164,6 +164,23 @@ final class WatchSettings { didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } } + #if DEBUG + /// Freeze the countdown bar's drain, for measuring what that animation costs. + /// + /// Read statically rather than through an instance so the animation path does + /// not take an observation dependency on it: flipping this must change the + /// next phase, not invalidate the view mid-drain and perturb the very trace + /// it exists to produce. + static var debugFreezesTimerBar: Bool { + UserDefaults.standard.bool(forKey: "MeshMapperFreezeTimerBar") + } + + var freezesTimerBar: Bool { + get { Self.debugFreezesTimerBar } + set { defaults.set(newValue, forKey: "MeshMapperFreezeTimerBar") } + } + #endif + /// One promise shared by Settings and the explicit-mode Start control. If /// these surfaces resolve independently, Settings can claim Hybrid while a /// tap silently requests Passive — precisely the kind of mode ambiguity the From a1dbb2f4d3abd23c293e7de55ab26a25931c048d Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:53:55 -0700 Subject: [PATCH 216/360] Stop a swipe off the map stranding a sheet over the next page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swiping up from the map left the controls page blurred and deaf to every swipe and Crown turn. It reads as a crash and is not one: the process lives, holding a modal it should never have raised. Two faults compound. The status panel is a button that opens the heard sheet, and it lies across the bottom of the map — exactly where an upward page swipe begins — so the pager and the button both claimed the same touch. Then the page changed underneath the sheet, leaving a modal belonging to the map presented over the controls page, blurring it and swallowing input. That became reachable when the map gave up `.pan`. Before, an upward drag moved the basemap; now it pages, so wearers swipe from wherever their thumb rests, and the panel is the largest target on the screen. The panel now opens the sheet only when the heard list is actually placed in a sheet. With the list on its own page — the default — it was presenting a duplicate of a page that already existed, so the tap had nothing to offer and every cost of firing by accident. And a sheet is dismissed when its page stops being selected, because a sheet belongs to the page that raised it; that guard holds however the sheet was raised. Reproduced first: presenting the sheet and then changing pages leaves it stranded with the controls page's buttons bleeding through behind it. Both are fixed, and the sheet still presents correctly over the map when that is the chosen placement. The page-switch launch argument used to reproduce it stays, documented with the others. The simulator cannot swipe, and this class of defect is invisible without it. --- ios/MeshMapperWatch/ContentView.swift | 14 ++++++++++++++ ios/MeshMapperWatch/MapPage.swift | 13 +++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 4 ++++ 3 files changed, 31 insertions(+) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index f3454e9d..f8b67a9a 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -56,6 +56,20 @@ struct ContentView: View { SettingsPage().tag(4) } .tabViewStyle(.verticalPage) + #if DEBUG + /// Drive a page change, because the simulator offers no way to swipe. + /// + /// Page transitions are a real defect surface here — a sheet raised from + /// the map once survived onto the next page, where it blurred that page + /// and swallowed all input, which reads exactly like a crash. This makes + /// that class reproducible without a wrist. + .task { + let target = UserDefaults.standard.integer(forKey: "MeshMapperAutoPageTo") + guard target > 0 else { return } + try? await Task.sleep(for: .seconds(5)) + selection = target + } + #endif } } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 39f98bd3..a2a5e334 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -252,6 +252,13 @@ struct MapPage: View { .onChange(of: needsMapGeo) { _, needed in client.setMapGeoNeeded(needed) } + .onChange(of: isSelected) { _, selected in + // A sheet belongs to the page that raised it. Leaving one presented + // while the pager moves on strands a modal over a different page, + // where it blurs that page and swallows every swipe and Crown turn — + // the app looks crashed while it is merely holding a stuck sheet. + if !selected { showingNodes = false } + } .onChange(of: trailingToolbarControl) { _, _ in // Stable facts own the slot, but a session transition still changes // its meaning. Never carry an armed confirmation into a new action. @@ -719,6 +726,12 @@ struct MapPage: View { /// a name is appended only when the phone could resolve it unambiguously. private var statusPanel: some View { Button { + // Only a sheet placement has anything to open. With the list on its own + // page this was presenting a duplicate of a page that already exists — + // and because the panel sits across the bottom of the map, where an + // upward page swipe begins, it fired on swipes the wearer meant for the + // pager and stranded a modal over the next page. + guard settings.nodeListPlacement == .sheet else { return } showingNodes = true } label: { VStack(alignment: .leading, spacing: 3) { diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index a998edb4..1ac7991f 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -20,6 +20,10 @@ import Foundation /// - `-MeshMapperForceDimmed YES` renders the reduced-luminance readout. /// - `-MeshMapperForceRefusal ` presents the failure banner; capture /// within six seconds because it deliberately uses the production expiry. +/// - `-MeshMapperAutoPageTo ` switches pages five seconds after launch. +/// The simulator cannot swipe, and page transitions are a real defect +/// surface: a sheet raised from the map once survived onto the next page, +/// blurring it and swallowing every swipe, which reads exactly like a crash. /// /// Listening and active remain the defaults so existing capture commands keep /// their behaviour. From 8dd93e27c4e77ba30858a54126cad539c99b04e5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 21:06:36 -0700 Subject: [PATCH 217/360] Measure the panel's height, not where it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swiping off the map wedged the watch: the destination page rendered blurred and stopped accepting swipes and Crown input. No crash report, process alive — the main thread simply never came free. A log capture during the gesture showed why. In forty-five seconds: 444,859 lines, 21,611 MapKit reconfigurations, and 194,490 evaluations of one debug flag read from the view body. Roughly 480 map rebuilds and 4,300 body passes per second. The cycle ran through the camera inset added in c2b9735: panel measured in .global -> panelFrame -> panelCameraInset -> Map safeAreaPadding -> layout invalidation -> new global frame An interactive swipe translates the page, so the panel's global position changed every frame, cleared the half-point guard, and re-framed the map again. Assigning `selection` in code swaps pages without ever producing those intermediate positions, which is exactly why every test passed while the real gesture was broken. Three rounds of green simulator runs described a build that locked up in ordinary use. The inset only ever needed to know how much of the display the panel covers, and that follows from its height plus the gap beneath it — both invariant under translation. So the cycle cannot close: moving the page no longer changes anything the map is told. The inset is also clamped to three quarters of the display. A status panel has no business claiming more, and a future measurement fault should degrade the framing rather than starve MapKit of anywhere to put the camera. Placement is unchanged: the fix renders at 91.25 pt against 91.2 pt before. Adam confirmed by hand that the gesture no longer wedges, and the same capture now yields 10,146 lines with 704 map reconfigurations. --- ios/MeshMapperWatch/MapPage.swift | 57 ++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index a2a5e334..868d6854 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -88,9 +88,9 @@ struct MapPage: View { } } - /// The panel's frame in global coordinates, so the camera can keep the fix - /// out from behind it. - @State private var panelFrame: CGRect = .zero + /// The panel's measured height, which is what the camera inset needs and, + /// unlike its position, does not change while a page transition is animating. + @State private var panelHeight: CGFloat = 0 @State private var latchedTopSafeAreaInset: CGFloat = 0 @State private var currentTopSafeAreaInset: CGFloat = 0 @State private var bottomSafeAreaInset: CGFloat = 0 @@ -98,25 +98,42 @@ struct MapPage: View { /// Only one subtree sets this, but every *other* subtree still contributes /// the default. Taking `nextValue()` unconditionally would let a later /// sibling's `.zero` overwrite the real measurement, so empties are ignored. - private struct PanelFrameKey: PreferenceKey { - static let defaultValue: CGRect = .zero - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + /// + /// This carries the panel's **height**, never its position. Position was a + /// runaway loop: an interactive page swipe translates the page, so a global + /// frame changes every frame, which changed the camera inset, which + /// re-framed the map, which invalidated layout, which re-measured. The watch + /// wedged with a blurred half-finished transition — 21,611 MapKit + /// reconfigurations and 4,300 body evaluations per second, main thread never + /// free. Height does not change when the page moves, so the cycle cannot + /// close. A programmatic selection change never exposes those intermediate + /// positions, which is why only a real swipe reproduced it. + private struct PanelHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { let next = nextValue() - if next != .zero { value = next } + if next > 0 { value = next } } } /// How much of the display the status panel is covering, handed to MapKit so /// it frames the camera in the band that remains visible. /// - /// Measured rather than assumed: the panel's height changes with its content - /// — one column or two, with or without heard rows — and a stale constant - /// would drift the fix off centre exactly when the panel grew. Before the - /// first measurement this is zero, which simply centres on the full display - /// for a frame. + /// Derived from the panel's height plus the gap it leaves beneath itself, + /// which reconstructs exactly what a bottom-anchored panel occupies without + /// asking where it currently is. Measured rather than assumed because the + /// height changes with content — one column or two, with or without heard + /// rows — and a stale constant would drift the fix off centre precisely when + /// the panel grew. private var panelCameraInset: CGFloat { - guard panelFrame.height > 0 else { return 0 } - return max(0, WKInterfaceDevice.current().screenBounds.height - panelFrame.minY) + guard panelHeight > 0 else { return 0 } + let gapBeneath = curvedPanelHorizontalInset == nil + ? bottomSafeAreaInset + : panelBottomGap + // Clamped: an inset approaching the display height would leave MapKit no + // band to frame, and nothing about a status panel justifies that. + let limit = WKInterfaceDevice.current().screenBounds.height * 0.75 + return min(max(0, panelHeight + gapBeneath), limit) } /// The placement scales with the estimated corner radius, putting every @@ -523,9 +540,9 @@ struct MapPage: View { map(proxy) mapOverlay(proxy) } - .onPreferenceChange(PanelFrameKey.self) { frame in - guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } - panelFrame = frame + .onPreferenceChange(PanelHeightKey.self) { height in + guard abs(height - panelHeight) > 0.5 else { return } + panelHeight = height recenterIfFollowing(proxy) } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in @@ -610,14 +627,16 @@ struct MapPage: View { // Aligning a guide below the banner with the panel's top puts the // transient immediately above the measured card. Unlike adding a // VStack row, an overlay contributes no size, so neither the - // panel's signed-off placement nor PanelFrameKey can move. + // panel's signed-off placement nor its measured height can move. .alignmentGuide(.top) { dimensions in dimensions[.bottom] + 3 } } .background( GeometryReader { geo in - Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) + // Size, not frame: a global position moves with the page during + // an interactive swipe and closes a layout feedback loop. + Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height) } ) } From 8430f96cfabb75ac661b42cfe752615d418d1f5e Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 09:28:56 -0700 Subject: [PATCH 218/360] Stop the map camera resting at a continental span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `camera` started `.automatic` and `recenterIfFollowing` was the only thing that ever assigned a region, so its three early returns each left the camera untouched. `.automatic` fits every annotation, which on hardware meant 34.4 degrees of latitude — about 3,800 km — and nothing recovered from it. Twenty consecutive continental callbacks across five wrist raises; only a manual Crown zoom escaped. Both triggers are now confirmed on a Series 9 and both are handled: Follow off reproduced in the simulator, which had never shown this because nobody had turned Follow off there fix == nil caught on device, `.automatic` reporting a 58.4-degree fit `anchorCameraIfNeeded` asserts a region once per native-map lifetime from a fresh stored centre, else the live fix, else a stale stored centre, so `isFollowing` governs tracking rather than whether any region is asserted at all. The centre persists in `WatchSettings` as one array — two keys can tear if the app is killed between writes — with a timestamp, because this map cannot pan and opening on a centre the wearer has travelled away from strands them somewhere they can only zoom. The span handshake is unchanged and still load-bearing: the `.automatic` fit arrives first on every rebuild and is rejected by the 25% gate, so a broken camera never corrupts the saved zoom. Verified in the logs as `confirmed N drift 3789.0%` followed by our own region at `drift 0.0%`. Also here, all measured rather than reasoned: - Past roughly 22 degrees of span MapKit shifts the camera centre north and leaves it there — 8.2 km at 24.5 degrees, 14.0 km at 25.0 — and the shift survived every later zoom including one back in to street level. It is now repaired after each Crown zoom, preserving the rendered span so correcting the centre does not undo the zoom. - The zoom floor drops to 0.0002. The Crown reaches 0.000230 degrees (~26 m) and the old 0.0005 floor clamped it, so zooming in snapped back out. The ceiling stays 0.5: zooming out past it still works, only persistence caps. - `MapReader` is gone. Camera placement moved to `.safeAreaPadding`, nothing called `proxy.convert`, and the proxy was threaded through six functions unread. - `WakeLog` and a persisted Instruments toggle, because launch arguments live in `NSArgumentDomain` and evaporate when watchOS relaunches the app, which is exactly what a wrist-down test provokes. - No main-actor hitch detector. One was written and removed: it fired on every wake at almost exactly the wrist-down duration, because it was timing the app being suspended rather than the main actor being blocked. --- ios/MeshMapperWatch/MapPage.swift | 417 +++++++++++++++++++++-- ios/MeshMapperWatch/SampleSnapshot.swift | 9 + ios/MeshMapperWatch/SettingsPage.swift | 4 + ios/MeshMapperWatch/WatchSettings.swift | 147 +++++++- 4 files changed, 543 insertions(+), 34 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 868d6854..d96cbb6a 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -46,11 +46,50 @@ struct MapPage: View { /// Non-nil once we have driven the camera. Assignment is not proof that /// MapKit rendered the request, but it is the first half of the span /// handshake that keeps `.automatic` from becoming the remembered zoom. + /// + /// It doubles as the in-memory half of the centre seed: it is by definition + /// the last place we drove the camera to, so a rebuilt map can be restored + /// from it without waiting on a fix. @State private var programmaticCenter: CLLocationCoordinate2D? + /// Whether *this* native map has been given a region of ours. + /// + /// Scoped to the map's lifetime like `hasConfirmedRequestedSpan`, and for the + /// same reason: `MapPage` survives a wrist drop while the `Map` inside it does + /// not, so a flag scoped to the page would claim a freshly built map was + /// already anchored. Cleared in `mapContent`'s `onAppear`. + /// + /// This is what makes the anchor idempotent. Follow re-asserts on every geo + /// update and that is fine; the anchor must fire exactly once per map, or a + /// wearer with Follow off would have their Crown zoom overwritten by a + /// re-assert on every panel resize. + @State private var hasAssertedRegion = false + /// Span counterpart to the centre handshake: MapKit can report its old /// `.automatic` fit after we assign a region, so assignment alone is not /// evidence that a rendered zoom came from us. + /// + /// Scoped to the *native map's* lifetime, not the page's. Dimming tears the + /// map subtree down while `MapPage` — and therefore this `@State` — survives, + /// so a confirmation earned before a wrist drop would otherwise still be + /// standing when a freshly built `Map` emits its first region. Nothing proves + /// that region is ours, and with the handshake already satisfied + /// `noteRenderedRegion` would bank it as a Crown zoom and overwrite the + /// wearer's saved span. `mapContent` clears it on appear so every native map + /// re-earns the confirmation. + /// + /// **Load-bearing, and measured so.** The simulator gave no support for this + /// at all — three teardown cycles, 0.0% drift every time — and it was written + /// as speculative hardening. Hardware disagreed immediately. Once the camera + /// falls into `.automatic` it reports a continental fit on *every* subsequent + /// rebuild: twenty consecutive callbacks at 34.364390 degrees, ~3,800 km, + /// against a requested 0.000500. Each one arrives at a freshly built map with + /// this flag cleared, so the 25% gate rejects it and the wearer's saved zoom + /// survives a completely broken camera. Without the reset, the first of those + /// twenty would have been banked as a Crown zoom and persisted. + /// + /// This protects the *setting*, not the view. The camera getting stuck is a + /// separate defect — see `recenterIfFollowing`. @State private var hasConfirmedRequestedSpan = false /// Initial confirmation only needs to distinguish our request from the much @@ -263,6 +302,19 @@ struct MapPage: View { if let forced = UserDefaults.standard.string(forKey: "MeshMapperForceRefusal") { client.debugForceRefusal(forced) } + // Time the readout->map switch, which rebuilds the same MapKit tree a + // wrist raise does — the one thing about a raise that is reproducible + // without a wrist, since Always-On cannot be entered here. Pair it with + // `-layout.mainPageContent readout` or the flip is a no-op against a + // map that is already showing, which reads as a suspiciously fast + // result rather than as no measurement at all. + if UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap") { + Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + NSLog("[switch] requesting map at %f", Date().timeIntervalSince1970) + settings.mainPageContent = .map + } + } #endif client.setMapGeoNeeded(needsMapGeo) } @@ -286,25 +338,45 @@ struct MapPage: View { } .onChange(of: isLuminanceReduced) { _, reduced in if reduced { disarmToolbarControl() } + #if DEBUG + // The reference timestamp for a wrist-raise measurement. Nothing else + // marks the moment the wrist came up, so without this the `map` and + // `span` lines have no zero to be measured against. + WakeLog.note(reduced ? "wrist-down" : "wrist-up") + #endif } .onDisappear { disarmToolbarControl() } + // A main-actor "hitch" detector lived here — a 250 ms tick that logged + // whenever it lost more than a second — meant to decide whether the 6.1 s + // wake stall was the app being descheduled or SwiftUI deferring the + // rebuild. It was removed because it cannot tell those apart from the one + // thing that always happens: watchOS suspends this app while the wrist is + // down, so the tick simply stops. Measured, it fired on every single wake + // at almost exactly the wrist-down duration — 7.42 s against 8.20 s, + // 12.89 against 13.63, 23.01 against 23.99. It reports suspension and + // calls it a stall. + // + // Its silence was equally worthless, and had already been cited as + // evidence that the stall "did not recur". Anything replacing it must + // establish the app was actually scheduled — a suspension-aware signal + // such as `scenePhase`, not a timer that cannot observe its own absence. } private var pageContent: some View { ZStack { if showsMap { - // Keep the reader inside this branch: constructing even map + // Keep the map inside this branch: constructing even map // infrastructure behind the readout would defeat its battery purpose. // - // DECIDE BEFORE OPENING A PR: this reader is now vestigial. Camera - // placement moved to safe-area insets, so nothing calls - // `proxy.convert` and the proxy is threaded through five functions - // unused. Removing it is a hierarchy change around a map whose launch - // and framing behaviour was verified by measurement, so it wants its - // own change and its own A/B — not a quiet tidy-up inside another one. - MapReader { proxy in - mapContent(proxy) - } + // There is deliberately no `MapReader` here. One wrapped this content + // while `centerPlacing` and `correctPlacement` translated the camera + // through `proxy.convert`; camera placement is now `.safeAreaPadding`, + // which insets MapKit's own framing, so no coordinate conversion + // happens anywhere in this file. The proxy was threaded through six + // functions unread. Do not reintroduce a reader to place the camera — + // see the handoff: the map's SwiftUI frame is not the map, and any + // offset computed from view geometry under-shoots. + mapContent } else { readoutContent } @@ -535,22 +607,57 @@ struct MapPage: View { armedToolbarControl = nil } - private func mapContent(_ proxy: MapProxy) -> some View { + private var mapContent: some View { ZStack { - map(proxy) - mapOverlay(proxy) + map + mapOverlay } .onPreferenceChange(PanelHeightKey.self) { height in guard abs(height - panelHeight) > 0.5 else { return } panelHeight = height - recenterIfFollowing(proxy) + recenterIfFollowing() } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in - recenterIfFollowing(proxy) + recenterIfFollowing() + // A first fix is the moment a map that had nothing to anchor to becomes + // placeable. Idempotent, so this is a no-op on every later update. + anchorCameraIfNeeded() } .onAppear { - recenterIfFollowing(proxy) + // A new native map has to re-earn the span handshake, and holds no region + // of ours until one is asserted below. + hasConfirmedRequestedSpan = false + hasAssertedRegion = false + #if DEBUG + // Marks the MapKit subtree being rebuilt. This is the SwiftUI half of a + // wrist raise only — the basemap paints later, with no callback of any + // kind, so the gap from here to visible pixels needs a camera or an eye. + WakeLog.note("map-subtree-appeared") + // Read as a pair with `camera-after`. The span log cannot separate "we + // never asserted a region" from "we asserted one and MapKit rendered its + // own anyway", and those want different fixes: + // + // before automatic=true, after automatic=false, span still continental + // -> we asserted and were ignored; the assignment is not the lever + // before automatic=true, after automatic=true + // -> `anchorCenter` was nil, so nothing was available to anchor to + // before automatic=false, after automatic=false, span continental + // -> the camera holds a region but MapKit is not honouring it + WakeLog.note("camera-before \(cameraStateDescription)") + #endif + recenterIfFollowing() + anchorCameraIfNeeded() + #if DEBUG + WakeLog.note("camera-after \(cameraStateDescription)") + #endif } + #if DEBUG + // Separates "built twice" from "built, torn down, rebuilt". Every device + // wake logs two appearances 50-70 ms apart and the simulator logs one, so + // the shape of the pair decides where to look: an interleaved disappear + // means a genuine teardown, two bare appearances mean two live instances. + .onDisappear { WakeLog.note("map-subtree-disappeared") } + #endif } private var readoutContent: some View { @@ -613,11 +720,11 @@ struct MapPage: View { /// Map chrome remains an overlay so its measured frame can place the fix in /// the visible band above it. The readout has its own hierarchy and therefore /// cannot accidentally inherit this bottom-pinned card again. - private func mapOverlay(_ proxy: MapProxy) -> some View { + private var mapOverlay: some View { VStack(spacing: 0) { HStack { Spacer(minLength: 0) - recenterButton(proxy) + recenterButton } Spacer(minLength: 0) statusPanel @@ -896,10 +1003,10 @@ struct MapPage: View { private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder - private func recenterButton(_ proxy: MapProxy) -> some View { + private var recenterButton: some View { if !isFollowing, fix != nil { Button { - recenterIfFollowing(proxy, force: true) + recenterIfFollowing(force: true) } label: { Image(systemName: "location.fill") .font(.system(size: 10)) @@ -912,7 +1019,7 @@ struct MapPage: View { // MARK: - Map - private func map(_ proxy: MapProxy) -> some View { + private var map: some View { // Pan consumes the vertical gesture the page shell needs, while the Crown // remains the deliberate zoom control. Once drag input is absent, centre // drift cannot honestly identify a pan — MapKit and Crown zoom can both @@ -927,7 +1034,11 @@ struct MapPage: View { // Tell MapKit what is covering the map, rather than hand-shifting the // camera to compensate. Its own centre then *is* the centre of the band // the wearer can actually see, so the fix lands there without a correction - // pass and a Crown zoom anchors on it instead of sliding it across. + // pass. A Crown zoom holds it there across the useful range — measured + // drift is 0.000000 degrees from 0.0002 up to about 21 degrees of span — + // but *not* past roughly 22 degrees, where MapKit shifts the centre north + // and leaves it there. `recentreAfterUserZoom` repairs that; the framing + // here is not what breaks. // // Both edges matter. Insetting only the bottom centres the fix in // `0...panelTop`, which still includes the toolbar strip, and the puck then @@ -936,7 +1047,9 @@ struct MapPage: View { .safeAreaPadding(.top, currentTopSafeAreaInset) .safeAreaPadding(.bottom, panelCameraInset) .onMapCameraChange(frequency: .onEnd) { context in - noteRenderedRegion(context.region) + // `.onEnd` matters: the correction below must never run mid-gesture, or + // it would fight the Crown while the wearer is still turning it. + noteRenderedRegion(context.region, cameraCentre: context.camera.centerCoordinate) } // The shell's navigation host supplies the system toolbar placement but // must not buy it by shortening the basemap. Only MapKit extends under that @@ -1027,19 +1140,100 @@ struct MapPage: View { // MARK: - Camera - private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { + private func recenterIfFollowing(force: Bool = false) { + // Every early return here leaves `camera` untouched, and an untouched + // camera can still be `.automatic` — which fits every annotation and has + // been measured rendering 34.4 degrees, about 3,800 km. Log which clause + // bailed: "no fix" and "follow off" are indistinguishable in the span log + // and want different fixes. + #if DEBUG + if !(showsMap && (force || isFollowing) && fix != nil) { + WakeLog.note( + "recenter-skipped showsMap \(showsMap) following \(isFollowing) " + + "fix \(fix == nil ? "nil" : "yes") force \(force)" + ) + } + #endif guard showsMap, force || isFollowing, let fix else { return } // Centre on the fix itself. The camera inset already accounts for the // panel, so no compensating shift is needed and nothing has to be // corrected after the map reports back. - programmaticCenter = fix - let region = MKCoordinateRegion(center: fix, span: currentSpan) - - if force { - // A tap is a rare, explicit request to move the map, so animation shows - // the wearer what their action changed. Automatic follow is different: - // the fix coordinate has already changed in this frame, and animating - // the camera after it makes the puck wander before the map catches up. + // + // A tap is a rare, explicit request to move the map, so animation shows the + // wearer what their action changed. Automatic follow is different: the fix + // coordinate has already changed in this frame, and animating the camera + // after it makes the puck wander before the map catches up. + applyRegion(center: fix, animated: force) + } + + /// Guarantee this native map has a region of ours, whatever `recenterIfFollowing` + /// decided. + /// + /// **`.automatic` is a terminal state, and this is what ends it.** Every early + /// return in `recenterIfFollowing` leaves `camera` untouched, and an untouched + /// camera can still be `.automatic` — which fits every annotation and was + /// measured on a Series 9 rendering 34.364390 degrees of latitude, about + /// 3,800 km. Nothing else in this file assigns a region, so once the camera + /// landed there only a manual Crown zoom escaped it: twenty consecutive + /// callbacks at that span, across five separate wrist raises. + /// + /// The wake log is specific about when it happens. Wakes preceded only by + /// follow updates restored perfectly — first callback `drift 0.0%`, every + /// time. The one wake that came back continental was the one preceded by a + /// burst of Crown zooming, which suggests the interaction leaves `camera` in a + /// state that does not survive the subtree teardown. This does not depend on + /// that being the mechanism: it asserts a region regardless of how the camera + /// got where it is. + /// + /// Follow governs *tracking*, not whether a region is ever asserted. Turning + /// Follow off must not be able to strand the map at a continental span. + private func anchorCameraIfNeeded() { + guard showsMap, !hasAssertedRegion, let center = anchorCenter else { return } + applyRegion(center: center, animated: false) + } + + /// Where a map with no follow-driven centre should open. + /// + /// Ordered by how well each source reflects what the wearer last saw. With + /// Follow off a *recent* centre wins over the live fix on purpose — restoring + /// the view they left is the whole point, and jumping to the fix is precisely + /// the tracking they turned off. + /// + /// The live fix outranks a stale one because this map cannot pan + /// (`interactionModes: [.zoom]`), so opening on a centre the wearer has since + /// travelled away from strands them somewhere they can only zoom. A stale + /// centre is still preferred over nothing: it is a real place the wearer once + /// was, which beats `.automatic` fitting every annotation at 3,800 km. + private var anchorCenter: CLLocationCoordinate2D? { + if isFollowing, let fix { return fix } + if let programmaticCenter { return programmaticCenter } + return settings.freshMapCenter ?? fix ?? settings.lastMapCenter + } + + /// The one place `camera` is assigned, so every path records the centre and + /// marks the map anchored. + /// + /// `span` defaults to the persisted zoom, which is right for every caller + /// except the post-zoom correction: that one has to carry the span MapKit + /// just rendered, or correcting the centre would also undo the wearer's zoom. + private func applyRegion( + center: CLLocationCoordinate2D, + span: MKCoordinateSpan? = nil, + animated: Bool + ) { + programmaticCenter = center + hasAssertedRegion = true + settings.noteMapCenter(center) + let region = MKCoordinateRegion(center: center, span: span ?? currentSpan) + + // DO NOT re-add a "skip when the region is unchanged" guard here. One was + // tried and reverted: it bought no measurable wake latency (0.43/0.46/0.47 s + // against a 0.15 s sampling floor) and it removed a repair. This assignment + // is not merely redundant — it is the only thing that re-asserts our region + // over whatever the camera drifted to, including `.automatic`, whose + // annotation fit spans a continent. Adam raised his wrist to a map showing + // the whole United States while that guard was installed. + if animated { withAnimation(.easeInOut(duration: 0.25)) { camera = .region(region) } @@ -1050,6 +1244,17 @@ struct MapPage: View { } } + #if DEBUG + /// `positionedByUser` is included because a Crown zoom is the one interaction + /// that precedes the stuck camera in the wake log, and it is the only public + /// signal that MapKit rather than we last moved the camera. + private var cameraStateDescription: String { + "automatic \(camera == .automatic) byUser \(camera.positionedByUser) " + + "anchored \(hasAssertedRegion) fix \(fix == nil ? "nil" : "yes") " + + "following \(isFollowing)" + } + #endif + /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one /// independent zoom value. After the one-time defaults migration it starts /// at 0.00225 degrees, about 250 m north-south, and later launches reuse the @@ -1063,7 +1268,50 @@ struct MapPage: View { /// Track the region MapKit actually rendered, so a Digital Crown zoom is not /// thrown away on the next follow update. - private func noteRenderedRegion(_ region: MKCoordinateRegion) { + private func noteRenderedRegion( + _ region: MKCoordinateRegion, + cameraCentre: CLLocationCoordinate2D + ) { + // Latched before anything below can assign the camera and clear it. + let wasPositionedByUser = camera.positionedByUser + + // Deferred because the span write-back below has four early returns and the + // centre needs repairing on every one of them — the northward shift arrives + // on a zoom-out large enough that the write-back may well bail. + defer { + recentreAfterUserZoom( + wasPositionedByUser: wasPositionedByUser, + renderedSpan: region.span + ) + } + + // A wearer who works the Crown on a map we never anchored has stated a + // camera preference, and it outranks our seed. Without this, the sequence + // "launch with no fix -> wearer zooms -> first fix arrives" ends with + // `anchorCameraIfNeeded` replacing their zoom with the persisted span, + // because `hasAssertedRegion` was still false. Marking it here retires the + // anchor without asserting anything, which is exactly the intent: the map + // is positioned, just not by us. + if wasPositionedByUser { + hasAssertedRegion = true + } + + #if DEBUG + if wasPositionedByUser, let fix { + // Quantifies the zoom-anchor drift. The wearer reported that zooming in + // loses the centre faster than zooming out; this is the number behind it, + // and it should read near zero once the correction below has run. + WakeLog.note( + String( + format: "user-zoom drift lat %.6f lon %.6f span %.6f", + cameraCentre.latitude - fix.latitude, + cameraCentre.longitude - fix.longitude, + region.span.latitudeDelta + ) + ) + } + #endif + // `.automatic` can report its annotation fit even after we assign our first // region, so `programmaticCenter != nil` proves only that a request was // made, not that MapKit rendered it. Persist nothing until the rendered @@ -1075,6 +1323,16 @@ struct MapPage: View { let requested = settings.mapLatitudeDelta guard rendered.isFinite, requested.isFinite, requested > 0 else { return } + #if DEBUG + WakeLog.note( + String( + format: "span rendered %.6f requested %.6f confirmed %@ drift %.1f%%", + rendered, requested, hasConfirmedRequestedSpan ? "Y" : "N", + abs(rendered - requested) / requested * 100 + ) + ) + #endif + guard hasConfirmedRequestedSpan else { let relativeDifference = abs(rendered - requested) / requested if relativeDifference <= Self.spanConfirmationTolerance { @@ -1090,6 +1348,48 @@ struct MapPage: View { settings.mapLatitudeDelta = rendered } + /// Put the fix back at the centre after the wearer has zoomed. + /// + /// **What actually drifts, measured on a Series 9.** A Crown zoom holds the + /// camera centre exactly — drift is 0.000000 degrees through spans of 0.0002 + /// up to about 21 degrees. Past roughly 22 degrees MapKit moves the centre + /// *north* and does not put it back: measured jumps of 0.074080 degrees + /// (8.2 km) at a 24.5-degree span and 0.126004 degrees (14.0 km) at a + /// 25.0-degree span. The shift is **sticky** — it survives every later zoom, + /// including a zoom all the way back in to 0.000229, so the wearer ends up + /// looking at a point 8-14 km north of themselves at street level. Only a + /// wrist drop, which rebuilds the map, cleared it. + /// + /// That is exactly what was reported: "zoom out seems to stay centered + /// initially, but zoom didn't stay centered (missed to the north, + /// significantly). Re-centered after drop/raise." + /// + /// **This function was once deleted, and the deletion was a mistake worth + /// recording.** An earlier reading of the same probe showed drift pinned at + /// zero and concluded there was nothing to repair. That run had this + /// correction installed: the probe samples at the start of the *next* camera + /// change, so it was reading a centre this function had just repaired. The + /// zero was the repair working. Before removing it again, remove it *first* + /// and re-measure — a metric taken through a repair cannot evaluate it. + /// + /// **The span is preserved, not re-asserted.** Using `currentSpan` here would + /// pull the zoom back to the persisted (and clamped) value on every turn of + /// the Crown, which reads as the map refusing to zoom. Only the centre moves. + /// + /// **Termination.** Gated on `positionedByUser`, which MapKit sets for an + /// interactive change and our own assignment clears, so this can trigger + /// exactly one more camera change and that one does not re-enter. Confirmed + /// on device: `byUser true` appears only after a real Crown zoom. + /// + /// Following only — with Follow off the camera is the wearer's. + private func recentreAfterUserZoom(wasPositionedByUser: Bool, renderedSpan: MKCoordinateSpan) { + guard wasPositionedByUser, isFollowing, let fix else { return } + // Unconditional, like every other assertion in this file. When the centre + // has not drifted this re-asserts the same region, which is the cheap case; + // see `applyRegion` for what a "skip when unchanged" guard cost last time. + applyRegion(center: fix, span: renderedSpan, animated: false) + } + } /// A phase-scoped progress animation rather than a one-second render clock. @@ -1395,3 +1695,54 @@ private struct RepeaterPin: View { } } } + +#if DEBUG +/// Wrist-raise timings, written to a file rather than only to the system log. +/// +/// Two failures pushed this out of `NSLog` alone. `log collect --device-udid` +/// **cannot reach an Apple Watch** — the watch connects over `localNetwork` and +/// that path wants a USB-attached device, so it fails with `Device not +/// configured (6)`. Console.app can stream it, but only live, so a run is lost +/// if streaming was not already started, and the operator has to copy text back +/// by hand. A file survives the app being suspended, terminated and relaunched, +/// and comes back whole: +/// +/// xcrun devicectl device copy from --device \ +/// --domain-type appDataContainer \ +/// --domain-identifier dev.agessaman.meshmapper.watchkitapp \ +/// --source Documents/wake-timings.log --destination . +/// +/// Lines still go to `NSLog` as well, so Console.app keeps working for anyone +/// who prefers watching it live. +enum WakeLog { + private static let name = "wake-timings.log" + + private static var url: URL? { + FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask) + .first? + .appendingPathComponent(name) + } + + /// Appends one timestamped line. Opened and closed per write on purpose: a + /// held handle would not be flushed if watchOS killed the app mid-test, which + /// is the one moment this log exists to describe. + static func note(_ message: String) { + guard WatchSettings.debugLogsWakeTiming else { return } + let stamp = Date().timeIntervalSince1970 + NSLog("[wake-log] %@ at %f", message, stamp) + + guard let url else { return } + let line = String(format: "%.4f %@\n", stamp, message) + guard let data = line.data(using: .utf8) else { return } + + if let handle = try? FileHandle(forWritingTo: url) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } else { + try? data.write(to: url) + } + } +} +#endif diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 1ac7991f..c2346878 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -24,6 +24,15 @@ import Foundation /// The simulator cannot swipe, and page transitions are a real defect /// surface: a sheet raised from the map once survived onto the next page, /// blurring it and swallowing every swipe, which reads exactly like a crash. +/// - `-MeshMapperTimeSwitchToMap YES` flips the main page to the map five +/// seconds after launch and logs `[switch] requesting map at `, so the +/// MapKit rebuild a wrist raise pays can be timed against screenshot mtimes. +/// Always pass `-layout.mainPageContent readout` with it: starting on the map +/// makes the flip a no-op that still logs, which looks like a fast result. +/// It also logs `[span] rendered … requested … confirmed …` on every camera +/// callback, which is how to check on hardware whether a rebuilt map ever +/// reports a region that is not the one we asked for. In the simulator it +/// never does — three teardown cycles, 0.0% drift every time. /// /// Listening and active remain the defaults so existing capture commands keep /// their behaviour. diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 571b8a1d..855ecb09 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -72,6 +72,10 @@ struct SettingsPage: View { // reaches Release. Takes effect from the next phase rather than mid-drain. Section("Instruments") { Toggle("Freeze timer bar", isOn: $settings.freezesTimerBar) + // Persisted, unlike the launch argument this replaced — a wrist-down + // test invites watchOS to relaunch the app, which silently emptied + // `NSArgumentDomain` and produced an empty log with no clue why. + Toggle("Log wake timing", isOn: $settings.logsWakeTiming) } #endif } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 17f78ee5..f4eca88d 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation import SwiftUI @@ -14,6 +15,7 @@ final class WatchSettings { static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion" + static let mapLastCenter = "map.lastCenter" static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" static let defaultStartMode = "controls.defaultStartMode" @@ -22,7 +24,23 @@ final class WatchSettings { /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. static let defaultMapLatitudeDelta = 0.00225 - private static let mapLatitudeDeltaLimits = 0.0005...0.5 + /// The Crown's own range is wider than this at both ends, and the two bounds + /// are set for different reasons. + /// + /// **The floor tracks the hardware.** Measured on a Series 9, MapKit's + /// tightest Crown zoom is about 0.000230 degrees (~26 m); the floor used to + /// be 0.0005 (~56 m), so zooming in past that was clamped and the next + /// re-assert snapped the map back out. The wearer reported it as the map not + /// holding position. 0.0002 sits just below the observed limit so the Crown + /// is never fought on the way in. + /// + /// **The ceiling is a policy, not a measurement.** The Crown reaches about + /// 25 degrees (~2,800 km), and persisting that would let the app open showing + /// a continent — visually indistinguishable from the `.automatic` bug this + /// file exists to prevent, just chosen rather than inflicted. 0.5 (~56 km) + /// keeps a stray flick from becoming a sticky state. Zooming out past it + /// still works; only the persisted value is capped. + private static let mapLatitudeDeltaLimits = 0.0002...0.5 private static let mapZoomDefaultsVersion = 1 /// Where the recently-responded list lives. @@ -148,6 +166,113 @@ final class WatchSettings { } } + /// Where the camera last rested, so a map with no live fix opens on the last + /// place the wearer actually saw rather than on MapKit's annotation fit. + /// + /// `.automatic` is not a neutral starting state: measured on a Series 9 it + /// renders 34.4 degrees of latitude, about 3,800 km, and once the camera + /// lands there nothing in `MapPage` moves it back. This value is the seed + /// that keeps that from being reachable — see `anchorCameraIfNeeded`. + /// + /// Deliberately *not* `@Observable`-backed state: nothing renders from it, it + /// is read once per native-map lifetime, and making it observable would + /// invalidate the map on every GPS step it records. + /// + /// Absence is distinguished from zero the same way the span is. `0, 0` is a + /// real coordinate in the Gulf of Guinea, so treating a missing key as zero + /// would open a fresh install in the Atlantic rather than falling through to + /// the live fix. + /// + /// Stored as one array rather than a latitude key, a longitude key and a + /// timestamp key. Separate writes can be torn apart by the app being killed + /// between them — watchOS terminates this app freely, which is the whole + /// reason the value is persisted — and half a coordinate is a + /// plausible-looking centre somewhere on the equator or the prime meridian. + /// One key cannot tear. + var lastMapCenter: CLLocationCoordinate2D? { + storedMapCenter?.center + } + + /// The stored centre only while it still plausibly describes where the wearer + /// is, which is what lets a live fix outrank it. + /// + /// Without an age, a centre saved in another city wins over a real fix + /// forever once Follow is off. That matters more here than it would on the + /// phone, because `interactionModes` is `[.zoom]` — the wearer cannot pan out + /// of a wrong place, only zoom within it. + /// + /// Twelve hours is chosen to survive a full session and a break, while not + /// surviving a night's travel. Note that a fresh centre and a live fix agree + /// whenever the wearer has not moved, so this threshold only decides the case + /// where they *have* — which is exactly the case the live fix answers better. + var freshMapCenter: CLLocationCoordinate2D? { + guard let stored = storedMapCenter, + let savedAt = stored.savedAt, + Date().timeIntervalSince1970 - savedAt < Self.mapCenterFreshnessSeconds + else { return nil } + return stored.center + } + + private static let mapCenterFreshnessSeconds: TimeInterval = 12 * 60 * 60 + + /// A missing timestamp is treated as unknown age rather than as fresh, so an + /// older two-element value can never masquerade as current. + private var storedMapCenter: (center: CLLocationCoordinate2D, savedAt: TimeInterval?)? { + guard + let stored = defaults.array(forKey: Key.mapLastCenter) as? [Double], + stored.count >= 2 + else { return nil } + let (lat, lon) = (stored[0], stored[1]) + guard + lat.isFinite, lon.isFinite, + (-90...90).contains(lat), (-180...180).contains(lon) + else { return nil } + let savedAt = stored.count >= 3 && stored[2].isFinite ? stored[2] : nil + return (CLLocationCoordinate2D(latitude: lat, longitude: lon), savedAt) + } + + /// Record a centre the camera was actually driven to. + /// + /// Throttled because follow asserts a region on every geo update, and the + /// phone's own `minMoveMeters` filter (15 m) is *finer* than anything this + /// value needs — left unthrottled at walking pace this would write roughly + /// once every 11 seconds for the life of a session, against active battery + /// work. + /// + /// A coarse threshold is affordable because this is only ever read at app + /// launch: within a session `MapPage` holds the live centre in memory and + /// never consults this. So the stored value only has to be good enough to + /// open the map in the right place, and it is competing against `.automatic` + /// at 3,800 km — not against a perfect restore. + func noteMapCenter(_ center: CLLocationCoordinate2D) { + guard + center.latitude.isFinite, center.longitude.isFinite, + (-90...90).contains(center.latitude), + (-180...180).contains(center.longitude) + else { return } + + // Freshness is part of the skip test, not just position. A wearer who stays + // put would otherwise match the stored coordinates forever, never rewrite + // the entry, and so let its timestamp expire under an accurate centre — + // the map would then decline to use a position that never stopped being + // correct. Requiring freshness here costs one write per expiry window. + if let stored = lastMapCenter, freshMapCenter != nil, + abs(stored.latitude - center.latitude) < Self.mapCenterWriteThreshold, + abs(stored.longitude - center.longitude) < Self.mapCenterWriteThreshold + { + return + } + defaults.set( + [center.latitude, center.longitude, Date().timeIntervalSince1970], + forKey: Key.mapLastCenter + ) + } + + /// About 110 m of latitude. Degrees, not metres: longitude convergence is + /// irrelevant to a write throttle, and treating it as a distance would imply + /// a precision this value does not need. + private static let mapCenterWriteThreshold = 0.001 + var mainPageContent: MainPageContent { didSet { defaults.set(mainPageContent.rawValue, forKey: Key.mainPageContent) } } @@ -179,6 +304,26 @@ final class WatchSettings { get { Self.debugFreezesTimerBar } set { defaults.set(newValue, forKey: "MeshMapperFreezeTimerBar") } } + + /// Record wrist-raise timings to `WakeLog`. + /// + /// **Persisted deliberately, and this is the whole point.** The same job was + /// first done by a `-MeshMapperTimeSwitchToMap` launch argument, which cannot + /// work here: launch arguments live in `NSArgumentDomain` and evaporate the + /// moment watchOS terminates and relaunches the app — which is exactly what a + /// wrist-down test provokes. The gate closed silently and the run produced an + /// empty log with nothing to indicate why. A measurement switch that outlives + /// process death has to be persisted, and being on the wrist means it can be + /// flipped without a reinstall. + static var debugLogsWakeTiming: Bool { + UserDefaults.standard.bool(forKey: "MeshMapperLogWakeTiming") + || UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap") + } + + var logsWakeTiming: Bool { + get { Self.debugLogsWakeTiming } + set { defaults.set(newValue, forKey: "MeshMapperLogWakeTiming") } + } #endif /// One promise shared by Settings and the explicit-mode Start control. If From 591b9ed80c93813cd8f89fef7ac849dd5d8be29d Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 12:50:55 -0700 Subject: [PATCH 219/360] Show why the phone is or is not talking to the watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone stopped sending snapshots for two hours and said nothing. The cause was `WCSession.isWatchAppInstalled` reporting false, so `WatchSessionManager.send` returned false and Dart's `schedule()` bailed before attempting anything. Neither side logs that: the bridge had no logging at all, and native only logs when a real send attempt fails, which never happened. `statusDictionary()` held the exact answer, handed it to Dart, and Dart showed it nowhere. Diagnosis took an hour and three wrong theories — a WCSession pairing fault, a second app install competing for the radio, two sources of truth for `isConnected` — none of which survived comparing file timestamps in the app containers. This is the surface that makes that a one-minute lookup instead. Settings gains an Apple Watch section listing the five native WCSession values, Dart's derived `canSync`, and — the point of the whole thing — which of `activated`, `paired` and `installed` is failing when it is false. Plus time since the last successful send, time since the last availability change, and whether that send was delivered or refused. Reachability is shown but deliberately excluded from the gate, because it is not one of the three conditions and mistaking it for one is exactly the wrong turn taken during the incident. The entry appears only when a watch is paired now or has ever been paired. The history flag is one-way on purpose: an unpaired watch is precisely when this needs to stay reachable, so no later native false may erase it. Also here: `statusDictionary()` now returns `activated` from its no-session branch too, so Dart never reads a missing key; and `_applyAvailability` logs the full status map on change only, never per send — the payload design exists to avoid chatty updates and this must not undo it. Tests cover both directions: the healthy gate, and the incident-shaped state where `installed` is false and the screen must name it. --- ios/Runner/WatchSessionManager.swift | 8 +- lib/providers/app_state_provider.dart | 59 ++++++ lib/screens/settings_screen.dart | 20 ++ lib/screens/watch_diagnostics_screen.dart | 193 ++++++++++++++++++ lib/services/watch/watch_bridge_service.dart | 102 ++++++++- .../watch_diagnostics_screen_test.dart | 45 ++++ .../watch/watch_wire_contract_test.dart | 52 ++++- 7 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 lib/screens/watch_diagnostics_screen.dart create mode 100644 test/screens/watch_diagnostics_screen_test.dart diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index bae3aab7..cca3a7c0 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -128,7 +128,13 @@ final class WatchSessionManager: NSObject { private func statusDictionary() -> [String: Any] { guard let session else { - return ["supported": false, "paired": false, "installed": false, "reachable": false] + return [ + "supported": false, + "paired": false, + "installed": false, + "reachable": false, + "activated": false, + ] } return [ "supported": true, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 65c3389c..d7aad0a9 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -134,6 +134,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); + bool _hasEverPairedWatch = false; /// Last position sent to the watch, held until the fix moves far enough to /// be worth an update. See [_resolveWatchPosition]. @@ -555,6 +556,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String get deviceId => _deviceId; bool get preferencesLoaded => _preferencesLoaded; + WatchBridgeService get watchBridge => _watchBridge; + bool get shouldShowWatchDiagnostics => + _watchBridge.isSupportedPlatform && + (_watchBridge.diagnostics.value.paired || _hasEverPairedWatch); TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; @@ -1713,6 +1718,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + void _handleWatchDiagnosticsChanged() { + if (_watchBridge.diagnostics.value.paired) { + unawaited(_rememberWatchPairing()); + } + } + + Future _rememberWatchPairing() async { + if (_hasEverPairedWatch) return; + + // Pairing history is intentionally one-way. An unpaired watch is the most + // important time to keep this diagnostic reachable, so no later native + // false is allowed to erase the evidence that the feature once existed. + _hasEverPairedWatch = true; + _notifyWatchDiagnosticVisibilityChanged(); + + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + try { + await box.put('watch_has_ever_been_paired', true); + } catch (error) { + debugError('[WATCH] Failed to persist pairing history: $error'); + } + } + + void _notifyWatchDiagnosticVisibilityChanged() { + if (_isDisposed) return; + // The provider's normal notifier also schedules a watch snapshot. This + // flag only controls Settings visibility, so bypass that transport side + // effect and keep pairing persistence strictly observational. + super.notifyListeners(); + } + ({ LiveActivityPhase phase, String title, @@ -2146,6 +2183,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.addListener(_handleLiveActivityTimerChange); } if (_watchBridge.isSupportedPlatform) { + _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged); _watchBridge.attachCommandHandler( _handleWatchCommand, onRefusal: _emitWatchFailure, @@ -2240,6 +2278,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Load user preferences debugLog('[INIT] Loading preferences...'); await _loadPreferences(); + await _loadWatchPairingPreference(); await _loadDeviceAntennaPreferences(); await _loadDevicePowerOverrides(); await _loadDeviceRealNames(); @@ -8435,6 +8474,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + Future _loadWatchPairingPreference() async { + if (!_watchBridge.isSupportedPlatform) return; + + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + try { + // OR with the in-memory observation because WCSession may report a + // pairing while startup persistence is still loading. That race must + // never turn the one-way flag back off. + final wasPaired = box.get('watch_has_ever_been_paired') == true; + if (wasPaired && !_hasEverPairedWatch) { + _hasEverPairedWatch = true; + _notifyWatchDiagnosticVisibilityChanged(); + } + } catch (error) { + debugError('[WATCH] Failed to load pairing history: $error'); + } + } + /// Save user preferences to Hive storage Future _savePreferences() async { final box = await _openBoxSafely(_preferencesBoxName); @@ -8940,6 +8998,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; _timerListenable.removeListener(_handleLiveActivityTimerChange); _liveActivityService.dispose(); + _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d4581280..93938f4b 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 00000000..dbd41294 --- /dev/null +++ b/lib/screens/watch_diagnostics_screen.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../services/watch/watch_bridge_service.dart'; + +/// On-device evidence for why the phone is or is not sending to Apple Watch. +/// +/// The bridge remains the single source of truth so opening this route cannot +/// accidentally create a second connectivity policy that drifts from the send +/// gate it is meant to explain. +class WatchDiagnosticsScreen extends StatefulWidget { + const WatchDiagnosticsScreen({ + super.key, + required this.bridge, + }); + + final WatchBridgeService bridge; + + @override + State createState() => _WatchDiagnosticsScreenState(); +} + +class _WatchDiagnosticsScreenState extends State { + Timer? _clockTimer; + var _secondsSinceRefresh = 0; + var _refreshing = false; + + @override + void initState() { + super.initState(); + unawaited(_refresh()); + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _secondsSinceRefresh++); + + // WCSession status properties are local reads. Poll only while this + // diagnostic is visible so reachability stays live without adding work + // to snapshot scheduling or turning normal app use into a hot path. + if (_secondsSinceRefresh >= 5) { + _secondsSinceRefresh = 0; + unawaited(widget.bridge.refreshAvailability()); + } + }); + } + + @override + void dispose() { + _clockTimer?.cancel(); + super.dispose(); + } + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + try { + await widget.bridge.refreshAvailability(); + } finally { + if (mounted) setState(() => _refreshing = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Watch Connectivity', style: TextStyle(fontSize: 18)), + actions: [ + TextButton.icon( + onPressed: _refreshing ? null : _refresh, + icon: _refreshing + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ], + ), + body: ValueListenableBuilder( + valueListenable: widget.bridge.diagnostics, + builder: (context, status, _) { + final failing = status.failingSyncConditions; + final gateDetail = status.canSync + ? 'activated, paired, and installed are all true' + : failing.isEmpty + ? 'The iOS platform gate is unavailable' + : 'Failing ${failing.length == 1 ? 'condition' : 'conditions'}: ' + '${failing.join(', ')}'; + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _buildSection(context, 'Native WCSession', [ + _statusTile('supported', status.supported), + _statusTile('paired', status.paired), + _statusTile('installed', status.installed), + _statusTile('reachable', status.reachable), + _statusTile('activated', status.activated), + ]), + _buildSection(context, 'Dart Bridge', [ + _statusTile('canSync', status.canSync), + ListTile( + leading: Icon( + status.canSync ? Icons.check_circle : Icons.error_outline, + color: status.canSync ? Colors.green : Colors.orange, + ), + title: const Text('Sync gate'), + subtitle: Text(gateDetail), + ), + ]), + _buildSection(context, 'Delivery History', [ + ListTile( + leading: const Icon(Icons.send_outlined), + title: const Text('Last successful send'), + subtitle: Text(_timeSince(status.lastSuccessfulSendAt)), + ), + ListTile( + leading: const Icon(Icons.sync_alt), + title: const Text('Last availability change'), + subtitle: Text(_timeSince(status.lastAvailabilityChangedAt)), + ), + ListTile( + leading: const Icon(Icons.fact_check_outlined), + title: const Text('Last send outcome'), + subtitle: Text(switch (status.lastSendDelivered) { + true => 'Delivered (delivered == true)', + false => 'Refused (delivered != true)', + null => 'No send attempted this run', + }), + ), + ]), + ], + ); + }, + ), + ); + } + + Widget _statusTile(String name, bool value) { + return ListTile( + leading: Icon( + value ? Icons.check_circle_outline : Icons.cancel_outlined, + color: value ? Colors.green : Colors.orange, + ), + title: Text(name), + trailing: Text(value ? 'true' : 'false'), + ); + } + + String _timeSince(DateTime? timestamp) { + if (timestamp == null) return 'Not recorded this run'; + final elapsed = DateTime.now().difference(timestamp); + if (elapsed.inSeconds < 5) return 'Just now'; + if (elapsed.inMinutes < 1) return '${elapsed.inSeconds}s ago'; + if (elapsed.inHours < 1) return '${elapsed.inMinutes}m ago'; + if (elapsed.inDays < 1) { + return '${elapsed.inHours}h ${elapsed.inMinutes.remainder(60)}m ago'; + } + return '${elapsed.inDays}d ${elapsed.inHours.remainder(24)}h ago'; + } + + Widget _buildSection( + BuildContext context, String title, List children) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ...children, + const SizedBox(height: 4), + ], + ), + ), + ); + } +} diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 90833955..c25c2876 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -19,6 +19,43 @@ typedef WatchCommandRefusalHandler = void Function(String reason); typedef WatchAvailabilityHandler = void Function(bool available); typedef WatchSnapshotDeliveryHandler = void Function(WatchSnapshot snapshot); +/// Read-only evidence from both sides of the phone-to-watch bridge. +/// +/// These timestamps intentionally do not share the transport's throttle and +/// dedupe fields. Those fields are cleared when WatchConnectivity changes +/// state, while a diagnostic must retain the last known-good send across the +/// exact outage that caused the state change. +@immutable +class WatchDiagnosticStatus { + const WatchDiagnosticStatus({ + this.supported = false, + this.paired = false, + this.installed = false, + this.reachable = false, + this.activated = false, + this.canSync = false, + this.lastSuccessfulSendAt, + this.lastAvailabilityChangedAt, + this.lastSendDelivered, + }); + + final bool supported; + final bool paired; + final bool installed; + final bool reachable; + final bool activated; + final bool canSync; + final DateTime? lastSuccessfulSendAt; + final DateTime? lastAvailabilityChangedAt; + final bool? lastSendDelivered; + + List get failingSyncConditions => [ + if (!activated) 'activated', + if (!paired) 'paired', + if (!installed) 'installed', + ]; +} + /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// /// Deliberately mirrors [LiveActivityService]'s shape — fingerprint dedupe, @@ -55,6 +92,12 @@ class WatchBridgeService { bool _disposed = false; bool _didReconcileNativeState = false; bool _canSync = false; + Map? _lastNativeStatus; + DateTime? _lastSuccessfulSendAt; + DateTime? _lastAvailabilityChangedAt; + bool? _lastSendDelivered; + final ValueNotifier _diagnostics = + ValueNotifier(const WatchDiagnosticStatus()); DateTime? _mapGeoSuppressedAt; double? _lastMapGeoClaimIssuedAtMs; Future _operationChain = Future.value(); @@ -65,6 +108,7 @@ class WatchBridgeService { bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; bool get canSync => isSupportedPlatform && _canSync; + ValueListenable get diagnostics => _diagnostics; /// Whether the next payload must carry map-only geography. /// @@ -204,16 +248,39 @@ class WatchBridgeService { } } + /// Re-reads the local WCSession properties for the diagnostic surface. + /// This does not send a snapshot or bypass the existing availability gate. + Future refreshAvailability() => _refreshAvailability(); + void _applyAvailability( Object? raw, { bool refreshNativeState = false, }) { if (raw is! Map) return; - final available = raw['activated'] == true && - raw['paired'] == true && - raw['installed'] == true; - if (available == _canSync && !refreshNativeState) return; - _canSync = available; + final status = { + 'supported': raw['supported'] == true, + 'paired': raw['paired'] == true, + 'installed': raw['installed'] == true, + 'reachable': raw['reachable'] == true, + 'activated': raw['activated'] == true, + }; + final available = + status['activated']! && status['paired']! && status['installed']!; + final availabilityChanged = available != _canSync; + final statusChanged = !mapEquals(_lastNativeStatus, status); + + if (availabilityChanged || refreshNativeState) _canSync = available; + if (statusChanged) { + _lastNativeStatus = Map.unmodifiable(status); + _lastAvailabilityChangedAt = DateTime.now(); + _publishDiagnostics(); + // This is deliberately tied to a changed native status map. Snapshot + // scheduling and explicit refreshes can call this path frequently, but + // repeated state adds no evidence and would hide the useful transition. + debugLog( + '[WATCH] Availability changed: status=$status canSync=$_canSync'); + } + if (!availabilityChanged && !refreshNativeState) return; // Native forgets its application-context cache whenever WatchConnectivity // reports a state change. Forget ours on the same notification even when @@ -230,6 +297,22 @@ class WatchBridgeService { _availabilityHandler?.call(available); } + void _publishDiagnostics() { + if (_disposed) return; + final status = _lastNativeStatus; + _diagnostics.value = WatchDiagnosticStatus( + supported: status?['supported'] ?? false, + paired: status?['paired'] ?? false, + installed: status?['installed'] ?? false, + reachable: status?['reachable'] ?? false, + activated: status?['activated'] ?? false, + canSync: canSync, + lastSuccessfulSendAt: _lastSuccessfulSendAt, + lastAvailabilityChangedAt: _lastAvailabilityChangedAt, + lastSendDelivered: _lastSendDelivered, + ); + } + void _rememberCommandId(String id) { _handledCommandIds.add(id); // Unbounded growth would leak across a long session. @@ -326,6 +409,8 @@ class WatchBridgeService { 'urgent': urgent, }); if (delivered != true) { + _lastSendDelivered = false; + _publishDiagnostics(); // Native can lose availability between status and send. Do not cache // a payload it refused. Re-query rather than guessing which condition // failed, so a transient context error cannot permanently close the @@ -336,7 +421,11 @@ class WatchBridgeService { _didReconcileNativeState = true; _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; - _lastSentAt = DateTime.now(); + final sentAt = DateTime.now(); + _lastSentAt = sentAt; + _lastSuccessfulSendAt = sentAt; + _lastSendDelivered = true; + _publishDiagnostics(); _snapshotDeliveryHandler?.call(snapshot); } on MissingPluginException { // Expected on non-iOS hosts and in tests. @@ -377,5 +466,6 @@ class WatchBridgeService { _snapshotDeliveryHandler = null; _mapGeoSuppressedAt = null; _lastMapGeoClaimIssuedAtMs = null; + _diagnostics.dispose(); } } diff --git a/test/screens/watch_diagnostics_screen_test.dart b/test/screens/watch_diagnostics_screen_test.dart new file mode 100644 index 00000000..a0d3597b --- /dev/null +++ b/test/screens/watch_diagnostics_screen_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/screens/watch_diagnostics_screen.dart'; +import 'package:mesh_mapper/services/watch/watch_bridge_service.dart'; + +/// Renders the diagnostic in the exact shape of the 2026-08-14 incident: +/// paired and reachable, but `installed` false, which silently closed the +/// sync gate for two hours with no log line anywhere. +void main() { + testWidgets('names the condition that closed the gate', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/watch_diag_test'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': false, + 'reachable': true, + }; + } + return null; + }); + final bridge = WatchBridgeService(channel: channel); + + await tester.pumpWidget( + MaterialApp(home: WatchDiagnosticsScreen(bridge: bridge)), + ); + await tester.pump(); + + expect(find.text('Failing condition: installed'), findsOneWidget); + expect(find.text('installed'), findsOneWidget); + expect(find.text('canSync'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 75831f47..e7c156fa 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -530,9 +530,11 @@ void main() { .setMockMethodCallHandler(channel, (call) async { if (call.method == 'status') { return { + 'supported': true, 'activated': nativeAvailable, 'paired': nativeAvailable, 'installed': nativeAvailable, + 'reachable': nativeAvailable, }; } if (call.method == 'sync') { @@ -604,7 +606,13 @@ void main() { channel.name, channel.codec.encodeMethodCall(const MethodCall( 'availabilityChanged', - {'activated': true, 'paired': true, 'installed': true}, + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }, )), null, ); @@ -613,6 +621,36 @@ void main() { expect(changes, [true]); }); + test('diagnostics identify the exact condition closing the sync gate', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': false, + 'reachable': true, + }, + )), + null, + ); + + expect(result, isNotNull); + expect(bridge.canSync, isFalse); + expect(bridge.diagnostics.value.supported, isTrue); + expect(bridge.diagnostics.value.reachable, isTrue); + expect(bridge.diagnostics.value.failingSyncConditions, ['installed']); + expect(bridge.diagnostics.value.lastAvailabilityChangedAt, isNotNull); + }); + test('a native false is not cached as a delivered snapshot', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); @@ -636,6 +674,8 @@ void main() { expect(builds, 2); expect(syncCalls, 2, reason: 'native refused both; neither payload was delivered'); + expect(bridge.diagnostics.value.lastSendDelivered, isFalse); + expect(bridge.diagnostics.value.lastSuccessfulSendAt, isNull); }); test('a watch state change resends even when availability stays true', @@ -662,7 +702,13 @@ void main() { channel.name, channel.codec.encodeMethodCall(const MethodCall( 'availabilityChanged', - {'activated': true, 'paired': true, 'installed': true}, + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }, )), null, ); @@ -717,6 +763,8 @@ void main() { expect(delivered, same(snapshot)); expect(syncCalls, 1); + expect(bridge.diagnostics.value.lastSendDelivered, isTrue); + expect(bridge.diagnostics.value.lastSuccessfulSendAt, isNotNull); }); test('accepted commands reach the handler', () async { From ebc09fb1abd6eb74ff923908baed089aa9f70a77 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 14:03:43 -0700 Subject: [PATCH 220/360] Stop the puck lagging the pings beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pings carry their own transmit-time GPS while the puck was held still until the fix moved fifteen metres, so on a walk the pings led the wearer in the direction of travel. Tightening the watch's zoom floor to about twenty-two metres of visible latitude turned a long-standing offset into two thirds of the display. The fifteen-metre gate exists to stop a parked phone's GPS jitter waking the radio, and it did that by making the payload identical so the bridge deduped it. But a new ping changes the payload by itself: that packet goes out regardless, and sending a stale puck inside it buys nothing. So ask the question in the transport instead of answering it in the payload. WatchBridgeService now encodes the fingerprint a second time with geo.you removed and skips the send only when everything else is byte-identical and the fix has moved less than the threshold. The anchor is the fix the watch actually received, so two eleven-metre steps add up and go, and a refused send cannot silently consume the wearer's next fifteen metres. Position-derived fields keep the old gate. distanceM is a full-precision double and the repeater list is distance-sorted, so feeding those the live fix would move the payload on every jitter in fields the transport cannot recognise as position — a send every two seconds from a phone on a table. _resolveRankingPosition holds them still; fifteen metres is invisible in a kilometre-scale readout and was only ever visible in the puck. movedEnough and distanceMeters move to WatchWire beside minMoveMeters, so the transport can apply the gate without importing a builder that reaches back into AppStateProvider. --- lib/providers/app_state_provider.dart | 83 ++++++-- lib/services/watch/watch_bridge_service.dart | 72 +++++++ lib/services/watch/watch_geo_builder.dart | 43 +---- lib/services/watch/watch_models.dart | 41 ++++ .../watch/watch_geo_builder_test.dart | 6 +- .../watch/watch_wire_contract_test.dart | 180 ++++++++++++++++++ 6 files changed, 360 insertions(+), 65 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d7aad0a9..4a70f74f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -136,9 +136,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final WatchBridgeService _watchBridge = WatchBridgeService(); bool _hasEverPairedWatch = false; - /// Last position sent to the watch, held until the fix moves far enough to - /// be worth an update. See [_resolveWatchPosition]. + /// Last position handed to the watch, kept so a dropped GPS fix leaves the + /// puck where it was rather than removing it. See [_resolveWatchPosition]. WatchPosition? _lastWatchPosition; + + /// Held position behind every distance and ordering decision in the payload. + /// See [_resolveRankingPosition] for why this one still lags on purpose. + ({double lat, double lon})? _rankingPosition; WatchHapticCue? _watchCue; /// Human-readable failure from the most recent server-side session check. @@ -1326,6 +1330,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchGeo _buildWatchGeo({required bool includeMapGeo}) { final position = _resolveWatchPosition(); + final ranking = _resolveRankingPosition(); // The wrist mirrors the map's "Top Heard" overlay: the latest ping's top // three by SNR plus the current RX slot. Same source, so the two surfaces @@ -1347,8 +1352,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterByHex: repeaterByHex, topAt: _topRepeatersOverlayUpdatedAt, rxAt: _liveActivityRxUpdatedAt, - lat: position?.lat, - lon: position?.lon, + lat: ranking?.lat, + lon: ranking?.lon, ); // The readout still needs the fix and Top Heard, but none of the arrays @@ -1380,8 +1385,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: WatchGeoBuilder.buildRepeaters( repeaters: _repeaters, heardThisCycle: heardIds, - lat: position?.lat, - lon: position?.lon, + lat: ranking?.lat, + lon: ranking?.lon, ), heard: heard, linkedRepeaterIds: [ @@ -1393,26 +1398,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - /// Current fix, held still until it moves meaningfully. + /// The current fix, reported as it is. + /// + /// **The movement gate used to live here and deliberately does not any + /// more.** It returned the *previous* position until the fix had moved + /// [WatchWire.minMoveMeters], which left the payload fingerprint unchanged so + /// the bridge's dedupe suppressed the send — that is how a parked phone stops + /// streaming GPS jitter at the watch, and it is still how it works. But + /// suppressing a *send* by degrading the *content* also degrades every send + /// that happens for some other reason. A new ping changes the payload + /// regardless, so the packet goes out carrying a puck up to 15 m stale while + /// the ping beside it carries its own transmit-time GPS. The wearer sees the + /// pings leading them in the direction of travel. /// - /// Returning the previous position leaves the payload fingerprint unchanged, - /// so the bridge's dedupe suppresses the send. A parked phone therefore - /// stops talking to the watch instead of streaming GPS jitter at it. + /// The gate now sits in [WatchBridgeService], which asks "is this change + /// worth a send?" without touching what gets sent — and in + /// [_resolveRankingPosition], which keeps every *derived* field as still as + /// it was before. Only the puck moved. WatchPosition? _resolveWatchPosition() { final position = _currentPosition; if (position == null) return _lastWatchPosition; - final previous = _lastWatchPosition; - if (previous != null && - !WatchGeoBuilder.movedEnough( - lastLat: previous.lat, - lastLon: previous.lon, - lat: position.latitude, - lon: position.longitude, - )) { - return previous; - } - final resolved = WatchPosition( lat: position.latitude, lon: position.longitude, @@ -1426,6 +1432,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return resolved; } + /// Position used to rank repeaters and to measure Top Heard distances, held + /// still until the fix moves [WatchWire.minMoveMeters]. + /// + /// **This is the old gate, kept exactly where it still belongs.** It was + /// removed from the puck because a stale puck is visibly wrong beside a ping + /// carrying its own GPS. Everything derived from position has the opposite + /// requirement: `WatchHeardNode.distanceM` is a full-precision double over a + /// distance measured in kilometres, and the nearest-first repeater order can + /// swap on a metre. Feeding those the live fix would make a parked phone's + /// GPS jitter change the payload every time, which is precisely the send the + /// bridge's gate exists to suppress — and it would slip past that gate, + /// because the gate can only recognise a change confined to the fix itself. + /// + /// Fifteen metres of staleness is invisible in a distance readout and cannot + /// meaningfully reorder repeaters. The puck is the only place it showed. + ({double lat, double lon})? _resolveRankingPosition() { + final position = _currentPosition; + if (position == null) return _rankingPosition; + + final previous = _rankingPosition; + if (previous != null && + !WatchWire.movedEnough( + lastLat: previous.lat, + lastLon: previous.lon, + lat: position.latitude, + lon: position.longitude, + )) { + return previous; + } + + final resolved = (lat: position.latitude, lon: position.longitude); + _rankingPosition = resolved; + return resolved; + } + ({bool allowed, String? reason}) get _manualPingAvailability { // This must remain the sole copy of the app button's gate. One caller says // what the wrist may offer while the other decides whether the radio may diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index c25c2876..b6ef048f 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -86,6 +86,12 @@ class WatchBridgeService { WatchSnapshotDeliveryHandler? _snapshotDeliveryHandler; String? _lastPayload; + + /// The delivered payload with the fix removed, plus the fix that went with + /// it. Together they answer "did anything but our position change?", which is + /// what the movement gate needs and the whole-payload fingerprint cannot say. + String? _lastPayloadWithoutFix; + ({double lat, double lon})? _lastSentFix; String? _lastUrgencyKey; DateTime? _lastSentAt; DateTime? _lastBuiltAt; @@ -290,6 +296,8 @@ class WatchBridgeService { _mapGeoSuppressedAt = null; _lastMapGeoClaimIssuedAtMs = null; _lastPayload = null; + _lastPayloadWithoutFix = null; + _lastSentFix = null; _lastUrgencyKey = null; _lastSentAt = null; _lastBuiltAt = null; @@ -390,6 +398,20 @@ class WatchBridgeService { final encoded = jsonEncode(fingerprint); if (encoded == _lastPayload) return; + // The movement gate. A stationary GPS jitters by a few metres for as long + // as the phone is switched on, and forwarding that would keep the watch + // radio busy for a puck that never visibly moves. + // + // It is deliberately expressed as "nothing but the fix changed, and the fix + // did not move far enough" rather than as a stale position in the payload, + // which is where it used to live. Those are the same suppression and a very + // different packet: a new ping defeats the dedupe by itself, and the older + // arrangement then sent that ping alongside a puck up to 15 m behind it — + // visibly so once the watch's zoom floor reached ~22 m of latitude. + final fix = _fixOf(fingerprint); + final withoutFix = _encodeWithoutFix(fingerprint); + if (withoutFix == _lastPayloadWithoutFix && !_fixMovedEnough(fix)) return; + final urgent = snapshot.urgencyKey != _lastUrgencyKey; final sentAt = _lastSentAt; if (!urgent && sentAt != null) { @@ -420,6 +442,10 @@ class WatchBridgeService { } _didReconcileNativeState = true; _lastPayload = encoded; + // Anchored to what the watch actually received, so a refused or dropped + // send cannot quietly consume the wearer's next 15 m of movement. + _lastPayloadWithoutFix = withoutFix; + _lastSentFix = fix; _lastUrgencyKey = snapshot.urgencyKey; final sentAt = DateTime.now(); _lastSentAt = sentAt; @@ -448,12 +474,58 @@ class WatchBridgeService { } finally { _didReconcileNativeState = true; _lastPayload = null; + _lastPayloadWithoutFix = null; + _lastSentFix = null; _lastUrgencyKey = null; _lastSentAt = null; _lastBuiltAt = null; } } + /// Whether the fix has moved far enough to be worth a send on its own. + /// + /// Measured against the fix the watch last *received*, not the last one the + /// phone computed, so a parked phone's jitter can never accumulate its way + /// past the threshold one sub-threshold step at a time. + bool _fixMovedEnough(({double lat, double lon})? fix) { + final last = _lastSentFix; + if (fix == null || last == null) return true; + return WatchWire.movedEnough( + lastLat: last.lat, + lastLon: last.lon, + lat: fix.lat, + lon: fix.lon, + ); + } + + static Map? _geoOf(Map fingerprint) { + final geo = fingerprint['geo']; + return geo is Map ? Map.from(geo) : null; + } + + static ({double lat, double lon})? _fixOf(Map fingerprint) { + final you = _geoOf(fingerprint)?['you']; + if (you is! Map) return null; + final lat = you['lat']; + final lon = you['lon']; + if (lat is! num || lon is! num) return null; + return (lat: lat.toDouble(), lon: lon.toDouble()); + } + + /// The payload with the wearer's position taken out, so two of them can be + /// compared for "did anything else change?". + /// + /// The whole `you` object goes, not just its coordinates: heading, accuracy + /// and fix time all drift on a phone that has not moved, and treating any of + /// them as a reason to send would defeat the gate they are travelling with. + static String _encodeWithoutFix(Map fingerprint) { + final geo = _geoOf(fingerprint); + if (geo == null) return jsonEncode(fingerprint); + return jsonEncode( + Map.from(fingerprint)..['geo'] = (geo..remove('you')), + ); + } + void dispose() { _disposed = true; _scheduledUpdate?.cancel(); diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index d9118cf6..3bd455c3 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import '../../models/log_entry.dart'; import '../../models/ping_data.dart'; import '../../models/repeater.dart'; @@ -15,29 +13,6 @@ import 'watch_models.dart'; class WatchGeoBuilder { WatchGeoBuilder._(); - /// Great-circle distance in metres. - /// - /// Local rather than `Geolocator.distanceBetween` to keep this file free of - /// plugin imports; the maths is identical. - static double distanceMeters( - double lat1, - double lon1, - double lat2, - double lon2, - ) { - const earthRadius = 6371000.0; - final dLat = _toRadians(lat2 - lat1); - final dLon = _toRadians(lon2 - lon1); - final a = math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(_toRadians(lat1)) * - math.cos(_toRadians(lat2)) * - math.sin(dLon / 2) * - math.sin(dLon / 2); - return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); - } - - static double _toRadians(double degrees) => degrees * math.pi / 180.0; - /// Colour for a ping marker, matching the iOS map's `_coverageStatusColor`. static WatchColor pingColor(String kind, bool success) { switch (kind) { @@ -219,7 +194,7 @@ class WatchGeoBuilder { final ranked = located .map((r) => ( repeater: r, - distance: distanceMeters(lat, lon, r.lat, r.lon), + distance: WatchWire.distanceMeters(lat, lon, r.lat, r.lon), )) .toList() ..sort((a, b) => a.distance.compareTo(b.distance)); @@ -346,7 +321,7 @@ class WatchGeoBuilder { lon != null && repeater != null && repeater.hasLocation) { - distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); + distance = WatchWire.distanceMeters(lat, lon, repeater.lat, repeater.lon); } return WatchHeardNode( @@ -385,18 +360,4 @@ class WatchGeoBuilder { return index; } - /// True when the fix moved far enough to be worth an update. - /// - /// A stationary GPS jitters by a few metres indefinitely; without this gate - /// a parked phone would keep the watch radio busy for no visible change. - static bool movedEnough({ - required double? lastLat, - required double? lastLon, - required double lat, - required double lon, - double thresholdMeters = WatchWire.minMoveMeters, - }) { - if (lastLat == null || lastLon == null) return true; - return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; - } } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index f349d755..9d05b281 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import '../live_activity/live_activity_models.dart'; import 'watch_color.dart'; @@ -41,6 +43,45 @@ class WatchWire { /// changes and new pings always go through; this only suppresses the /// jitter of a stationary GPS. static const double minMoveMeters = 15.0; + + /// True when the fix moved far enough to be worth an update on its own. + /// + /// Lives here beside [minMoveMeters], and not with the rest of the geography + /// helpers, so the transport can apply the gate without importing anything + /// that knows what a ping is. + static bool movedEnough({ + required double? lastLat, + required double? lastLon, + required double lat, + required double lon, + double thresholdMeters = minMoveMeters, + }) { + if (lastLat == null || lastLon == null) return true; + return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; + } + + /// Great-circle distance in metres. + /// + /// Local rather than `Geolocator.distanceBetween` to keep this file free of + /// plugin imports; the maths is identical. + static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const earthRadius = 6371000.0; + final dLat = _toRadians(lat2 - lat1); + final dLon = _toRadians(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _toRadians(double degrees) => degrees * math.pi / 180.0; } /// Start modes the phone may explicitly offer to the wrist. diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index dcc45439..6c657b4c 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -611,7 +611,7 @@ void main() { group('movedEnough', () { test('always sends the first fix', () { expect( - WatchGeoBuilder.movedEnough( + WatchWire.movedEnough( lastLat: null, lastLon: null, lat: 47.6, @@ -623,7 +623,7 @@ void main() { test('suppresses stationary GPS jitter', () { expect( - WatchGeoBuilder.movedEnough( + WatchWire.movedEnough( lastLat: 47.6, lastLon: -122.3, lat: 47.60002, @@ -635,7 +635,7 @@ void main() { test('passes once the fix moves past the threshold', () { expect( - WatchGeoBuilder.movedEnough( + WatchWire.movedEnough( lastLat: 47.6, lastLon: -122.3, lat: 47.6005, diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index e7c156fa..26129051 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -926,4 +926,184 @@ void main() { expect(reply?['reason'], 'Passive Only'); }); }); + + /// The gate that stops a parked phone streaming GPS jitter at the watch, and + /// the reason it lives in the transport rather than in the payload builder. + /// + /// It used to be applied by handing the watch a *stale* position until the + /// fix had moved 15 m, which suppressed the send by making the payload + /// identical. A new ping defeats that dedupe by itself, so the packet went + /// out anyway carrying a puck up to 15 m behind the ping beside it. Adam saw + /// it on a walk: "the pings appear ahead of the current location and center". + group('movement gate', () { + late WatchBridgeService bridge; + late MethodChannel channel; + late List> sent; + + /// Long enough for the 2 s non-urgent throttle to clear and for the + /// bridge's own retry timer to run, so "no send" means suppressed rather + /// than merely deferred. A fresh bridge has no throttle history at all, so + /// its first push needs neither. + const settle = Duration(milliseconds: 2500); + const firstPush = Duration(milliseconds: 100); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/watch_move_test'); + bridge = WatchBridgeService(channel: channel); + sent = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }; + } + if (call.method == 'sync') { + final args = call.arguments as Map; + sent.add(args['payload'] as Map); + return true; + } + return null; + }); + bridge.attachCommandHandler((_) => null); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + WatchGeo geoAt( + double lat, { + required int fixedAtMs, + List pings = const [], + }) => + WatchGeo( + you: WatchPosition( + lat: lat, + lon: -122.3, + fixedAt: DateTime.fromMillisecondsSinceEpoch(fixedAtMs), + ), + pings: pings, + repeaters: const [], + heard: const [], + linkedRepeaterIds: const [], + ); + + Future push(WatchGeo geo, {Duration wait = settle}) async { + final snapshot = _snapshot(geo: geo); + bridge.schedule( + () => snapshot, + urgencyKeyBuilder: () => snapshot.urgencyKey, + immediate: true, + ); + await Future.delayed(wait); + } + + double sentLat(int index) { + final geo = sent[index]['geo'] as Map; + final you = geo['you'] as Map; + return you['lat'] as double; + } + + test('suppresses jitter, but never a payload that is going out anyway', + () async { + // 1e-5 degrees of latitude is about 1.11 m here, so 47.60002 is roughly + // 2 m from the baseline and 47.6003 is roughly 33 m. + await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); + expect(sent, hasLength(1), reason: 'the first fix always goes'); + + // A newer fix time and a couple of metres. This is a stationary phone, + // and it must not reach the radio — including on the retry the throttle + // scheduled, which `settle` has already allowed to fire. + await push(geoAt(47.60002, fixedAtMs: 1760000030000)); + expect(sent, hasLength(1)); + + // The same two metres, now travelling with a ping. The ping alone + // defeats the dedupe, so this send happens either way; the assertion is + // that it carries the *current* fix rather than the last one sent. + await push(geoAt( + 47.60002, + fixedAtMs: 1760000060000, + pings: [ + WatchPing( + id: 'rx-1760000060000', + lat: 47.60002, + lon: -122.3, + kind: 'rx', + color: const WatchColor(0, 0, 255), + at: DateTime.fromMillisecondsSinceEpoch(1760000060000), + ), + ], + )); + expect(sent, hasLength(2)); + expect(sentLat(1), 47.60002, + reason: 'the puck must not lag the ping beside it'); + + // And the gate still opens on its own once the wearer has actually + // moved, with nothing else in the payload changing. + await push(geoAt(47.6003, fixedAtMs: 1760000090000)); + expect(sent, hasLength(3)); + expect(sentLat(2), 47.6003); + }); + + /// The gate can only recognise a change confined to the fix itself, which + /// is why `AppStateProvider._resolveRankingPosition` still holds a lagging + /// position behind every *derived* field. `WatchHeardNode.distanceM` is a + /// full-precision double, so feeding it the live fix would move the payload + /// on every GPS jitter and walk straight past this gate — one send per + /// throttle interval, from a phone sitting on a table. + test('cannot see through a derived field, so derived fields must not move', + () async { + WatchGeo geoWith(double lat, double distanceM) => WatchGeo( + you: WatchPosition( + lat: lat, + lon: -122.3, + fixedAt: DateTime.fromMillisecondsSinceEpoch(1760000000000), + ), + pings: const [], + repeaters: const [], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: DateTime.fromMillisecondsSinceEpoch(1760000000000), + distanceM: distanceM, + ), + ], + linkedRepeaterIds: const [], + ); + + await push(geoWith(47.6, 1423.5), wait: firstPush); + expect(sent, hasLength(1)); + + // Two metres of jitter, with the distance readout recomputed from it. + await push(geoWith(47.60002, 1421.3)); + expect(sent, hasLength(2), + reason: 'a moved distanceM is indistinguishable from real news'); + }); + + test('measures from the last fix the watch received', () async { + await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); + expect(sent, hasLength(1)); + + // Two ~11 m steps in the same direction. Each is short of the threshold + // on its own, but the second lands ~22 m from the fix the watch actually + // holds, so it goes. Anchoring on the previous *computed* fix instead + // would suppress a wearer walking away one short step at a time. + await push(geoAt(47.6001, fixedAtMs: 1760000030000)); + expect(sent, hasLength(1)); + await push(geoAt(47.6002, fixedAtMs: 1760000060000)); + expect(sent, hasLength(2), reason: '~22 m from the delivered fix'); + expect(sentLat(1), 47.6002); + }); + }); } From 71dd8dd385051295f7912157bf3ddc3324b44726 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 14:03:59 -0700 Subject: [PATCH 221/360] Keep the map on screen when the display dims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, on a walk: "the watch often falls back to the always on state very rapidly despite still being in the lifted position. This can make the map flicker on and disappear while a user is looking at it." It is not the wrist coming down. watchOS drops to reduced luminance about 5.9 seconds into a glance — thirty-three lifts measured, fourteen inside a half-second band — and the clock face does the same, so this is the platform's full-brightness window and there is nothing upstream to fix. The display stays legible. showsMap read the dim as nobody looking and tore the whole MapKit subtree down mid-glance. The map now survives the dim for twenty seconds. The geo claim does not: needsMapGeo reads luminance directly instead of going through showsMap, so the held map draws the pings the watch already has and the phone sends no more than before. MapKit staying constructed is a real cost, and twenty seconds a glance is the trade; a wrist-down's worth would not be. Four things had to hold at once, and hardware taught three of them. The hold cannot depend on a timer. watchOS runs the app at a much lower cadence once dimmed, so a boolean owned by a sleeping task would stay set for an entire wrist-down and leave a map as the Always-On surface. The predicate reads the clock. A clock is not enough either, because the last frame drawn stays on the display until something asks for another. A repeating TimelineView requests that render. Its entries are not the deadline, though: entering Always On changes the timeline cadence, which re-queries the schedule and delivers the next entry early. Treating that as the boundary expired the hold 0.82 to 1.05 seconds into every glance, thirteen for thirteen on a Series 9, while the simulator — which never enters Always On — passed every time. The clock decides; the entry only says when to look. The first dimmed frame must already be holding, because the timestamp is written by onChange, which runs after the body evaluation that first sees the dim. So a missing timestamp means hold. That default is fenced by hasBeenBright, which no view-lifecycle callback may clear: watchOS re-hosts this page at the dim, and clearing it there put the wearer back on the readout a second in. Fresh @State already covers what the reset was for. And nothing may change shape at the dim. The toolbar items were wrapped in an if, and removing them alters the toolbar's structure, which re-hosts the page and rebuilds all of MapKit beneath it — a subtree appearing 0.13 seconds after every dim, with a fresh camera anchor and basemap repaint, which the wearer saw as the map jumping as it faded. They now stay put and are hidden by value. Disabled and accessibility-hidden as well as transparent, because Start transmits on a single tap and an invisible control is still reachable by an assistive technology. -MeshMapperAutoDimAfter drives the transition in the simulator, which MeshMapperForceDimmed could not: it only sets the state a page is born into, so it exercised the cold-dimmed path and never the glance. Every fix above was found by measurement rather than reasoning, three of them only after hardware disagreed with a simulator that had signed the work off. Also records the settled cause of the double map construction. It is not the map: a bare probe above the branch doubles, and so does one in the readout on a launch with no MapKit anywhere, while probes on the NavigationStack and the TabView each fire once. The vertical pager builds its selected page twice and discards one, and nothing here can prevent it. --- ios/MeshMapperWatch/MapPage.swift | 355 ++++++++++++++++++++++++++++-- 1 file changed, 341 insertions(+), 14 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index d96cbb6a..80702e68 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -27,19 +27,142 @@ struct MapPage: View { /// captured headlessly, like the sample-data affordances. private var isLuminanceReduced: Bool { #if DEBUG + if debugForcedDim { return true } if UserDefaults.standard.bool(forKey: "MeshMapperForceDimmed") { return true } #endif return environmentLuminanceReduced } + #if DEBUG + /// Drives a real full-luminance -> dimmed *transition* in the simulator, + /// which `MeshMapperForceDimmed` alone cannot do: that flag only sets the + /// state the page is born into, so it exercises the cold-dimmed path and + /// never the glance path. Everything about the dim hold lives in the + /// transition, so without this the only way to test it is Adam's wrist. + /// + /// Launch with `-MeshMapperAutoDimAfter `. + @State private var debugForcedDim = false + #endif + /// Content choice and display cadence are independent. A chosen readout can - /// run at full luminance with a precise timer; reduced luminance selects the - /// same approved surface because MapKit is not worth its Always-On cost. + /// run at full luminance with a precise timer, and reduced luminance settles + /// on that same approved surface because MapKit is not worth holding through + /// a wrist-down. + /// + /// It settles there *after* the hold, not at the dim — see `dimmedMapHold`. private var showsMap: Bool { - settings.mainPageContent == .map && !isLuminanceReduced + settings.mainPageContent == .map + && (!isLuminanceReduced || isWithinDimmedMapHold) + } + + /// Whether the phone should keep sending map geography. + /// + /// **Deliberately not `showsMap && isSelected`.** The hold below keeps + /// drawing the pings the watch already has; it must not also keep the + /// phone's geo claim open. `WatchBridgeService` only schedules suppression + /// 15 s out, so a glance already costs about 21 s of geo — 5.9 s of map plus + /// that delay — and holding the claim for the hold's duration as well would + /// roughly double it, for a map the wearer was looking at either way. + private var needsMapGeo: Bool { + settings.mainPageContent == .map && !isLuminanceReduced && isSelected + } + + /// How long the map keeps drawing after the display dims. + /// + /// **The dim is not the wrist coming down.** watchOS drops to reduced + /// luminance about 5.9 seconds into a glance — 33 lifts measured, 14 of them + /// inside a 0.49 s band, seven between 5.88 and 5.91 s — and the *clock face* + /// dims in about the same time, so this is the platform's full-brightness + /// window rather than anything this app can hold on to. The display stays + /// legible: "the screen does get a bit dimmer but the level is relatively + /// high still." + /// + /// `showsMap` treated that as nobody looking and tore the whole MapKit + /// subtree down mid-glance. The wearer's report: "the watch often falls back + /// to the always on state very rapidly despite still being in the lifted + /// position. This can make the map flicker on and disappear while a user is + /// looking at it." + /// + /// Twenty seconds covers a deliberate glance. It leaves the *radio* alone — + /// `needsMapGeo` above is untouched by the hold — but it is not free: MapKit + /// stays constructed and rendering for the duration, which is the cost the + /// Always-On readout was introduced to avoid. Twenty seconds of it per + /// glance is the trade; a wrist-down's worth would not be. + private static let dimmedMapHold: TimeInterval = 20 + + + /// When the display last dimmed, or nil at full luminance. + @State private var dimmedAt: Date? + + /// Exists to make a render happen at the end of the hold, not to decide when + /// the hold ends — see `isWithinDimmedMapHold` for why that distinction is + /// load-bearing. + @State private var dimmedMapHoldTask: Task? + + /// Whether the dim is recent enough that the wearer is plausibly still + /// reading the map. + /// + /// **A missing timestamp means hold — but only if this page has been seen at + /// full luminance.** `dimmedAt` is written by `onChange`, which runs *after* + /// the body evaluation that first sees reduced luminance. Defaulting to "not + /// holding" would therefore give that first frame `showsMap == false` and + /// only restore the map on the update the callback provokes — turning the + /// flicker this fixes into a teardown and a full rebuild, complete with a + /// fresh camera anchor, at the exact moment the wearer is looking at it. + /// + /// `hasBeenBright` is what keeps that default from swallowing the Always-On + /// design. A page that *appeared* already dimmed was never mid-glance, and + /// without this it opened straight onto MapKit — measured, not theorised: a + /// force-dimmed cold launch built the map subtree until this guard existed. + /// + /// **Past that, read the clock rather than a flag the expiry task cleared.** + /// watchOS suspends this app within about a second of the dim — measured by + /// the removed hitch detector, which lost 7.42 s of an 8.20 s wrist-down, + /// 12.89 of 13.63, 23.01 of 23.99 — so `dimmedMapHoldTask` cannot be relied + /// on to fire on time, and a boolean it owns would sit `true` for a whole + /// wrist-down. Evaluating the timestamp means every render that actually + /// happens gets the right answer, whenever it happens. + /// + /// That still needs a render to happen at all, which is what + /// `dimmedMapHoldTicker` is for. + /// Whether a dim arriving right now could hold anything. + /// + /// The same conditions `isWithinDimmedMapHold` applies, minus the ones that + /// only make sense once a hold exists. Kept separate so arming can be + /// skipped entirely rather than armed and then rejected on every render. + private var canHoldMapThroughDim: Bool { + settings.mainPageContent == .map && isSelected && hasBeenBright + } + + private var isWithinDimmedMapHold: Bool { + guard isSelected, isLuminanceReduced, hasBeenBright, !isDimmedMapHoldExpired + else { + return false + } + guard let dimmedAt else { return true } + return Date().timeIntervalSince(dimmedAt) < Self.dimmedMapHold } - private var needsMapGeo: Bool { showsMap && isSelected } + /// Latched by the expiry task or the ticker, so a hold that has ended cannot + /// be revived by `dimmedAt` being cleared out from under it. + @State private var isDimmedMapHoldExpired = false + + /// Whether this page has been *watched* — selected and at full luminance — + /// at some point since its state was created. + /// + /// Only then can a dim be part of a glance rather than the state the page was + /// born into. It is deliberately not cleared by the view lifecycle: doing so + /// broke the feature on hardware, because watchOS re-hosts the page at the + /// dim and the reset landed mid-glance. Fresh `@State` already covers the + /// case it was meant to, since a page SwiftUI genuinely rebuilds starts + /// `false`. + /// + /// What it guards is narrower than an earlier comment here claimed. An + /// off-screen page is already excluded — `isWithinDimmedMapHold` checks + /// `isSelected` directly. This covers the two cases that check cannot see: a + /// page that appeared already dimmed, and one selected for the first time + /// while dimmed. Neither is a glance in progress. + @State private var hasBeenBright = false @State private var camera: MapCameraPosition = .automatic @@ -237,14 +360,30 @@ struct MapPage: View { var body: some View { pageContent + // **Both items stay in the toolbar at all times.** They used to be wrapped + // in `if !isLuminanceReduced`, and removing them at the dim changes the + // toolbar's structure, which re-hosts this page and rebuilds the entire + // MapKit subtree underneath it. Measured on a Series 9: a + // `map-subtree-appeared` 0.13 s after every single `hold-armed`, with the + // old subtree discarded 0.09 s later — a full teardown, fresh camera + // anchor and basemap repaint, right under the wearer's eye. Adam saw it + // as the map jumping as the display faded. With the items always present + // the dim rebuilds nothing at all. + // + // Hidden by value rather than by presence, and hidden *properly*: + // `.opacity(0)` alone leaves a live button, and `.allowsHitTesting` + // covers ordinary touch but neither disables the action nor takes it out + // of the accessibility tree. Start transmits on a single tap with no + // confirmation, so a VoiceOver focus carried across the dim could fire + // it on an invisible control. `hiddenWhileDimmed` disables and + // accessibility-hides as well; all three are value changes, so the + // toolbar's structure stays fixed. .toolbar { - if !isLuminanceReduced { - ToolbarItem(placement: .topBarLeading) { - mainPageToggle - } - ToolbarItem(placement: .topBarTrailing) { - trailingToolbarButton - } + ToolbarItem(placement: .topBarLeading) { + mainPageToggle.hiddenWhileDimmed(isLuminanceReduced) + } + ToolbarItem(placement: .topBarTrailing) { + trailingToolbarButton.hiddenWhileDimmed(isLuminanceReduced) } } .background( @@ -308,6 +447,14 @@ struct MapPage: View { // `-layout.mainPageContent readout` or the flip is a no-op against a // map that is already showing, which reads as a suspiciously fast // result rather than as no measurement at all. + let autoDim = UserDefaults.standard.integer(forKey: "MeshMapperAutoDimAfter") + if autoDim > 0 { + Task { @MainActor in + try? await Task.sleep(for: .seconds(autoDim)) + WakeLog.note("debug-forcing-dim") + debugForcedDim = true + } + } if UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap") { Task { @MainActor in try? await Task.sleep(for: .seconds(5)) @@ -317,6 +464,10 @@ struct MapPage: View { } #endif client.setMapGeoNeeded(needsMapGeo) + // A page that opens selected and bright is a glance in progress; one + // that opens dimmed or behind another page is not, and must not hold a + // map it never showed. + armGlanceIfWatched() } .onChange(of: needsMapGeo) { _, needed in client.setMapGeoNeeded(needed) @@ -327,6 +478,7 @@ struct MapPage: View { // where it blurs that page and swallows every swipe and Crown turn — // the app looks crashed while it is merely holding a stuck sheet. if !selected { showingNodes = false } + noteSelection(selected) } .onChange(of: trailingToolbarControl) { _, _ in // Stable facts own the slot, but a session transition still changes @@ -337,6 +489,7 @@ struct MapPage: View { if !enabled { disarmToolbarControl() } } .onChange(of: isLuminanceReduced) { _, reduced in + noteLuminance(reduced: reduced) if reduced { disarmToolbarControl() } #if DEBUG // The reference timestamp for a wrist-raise measurement. Nothing else @@ -345,6 +498,14 @@ struct MapPage: View { WakeLog.note(reduced ? "wrist-down" : "wrist-up") #endif } + // Deliberately does NOT touch the hold. Apple's guidance is that Always On + // keeps views in the hierarchy, but hardware also collapses the + // navigation bar at the dim where the simulator does not — and the + // re-host that causes fired this handler mid-glance, which destroyed the + // hold about a second in and put the wearer straight back on the readout. + // `@State` already gives the right answer for free: if SwiftUI really + // destroys this page, `hasBeenBright` goes with it and a page rebuilt + // while dimmed starts unarmed. If it survives, so should the glance. .onDisappear { disarmToolbarControl() } // A main-actor "hitch" detector lived here — a 250 ms tick that logged // whenever it lost more than a second — meant to decide whether the 6.1 s @@ -381,6 +542,64 @@ struct MapPage: View { readoutContent } } + // Attached as a background rather than as a sibling in the ZStack. As a + // sibling it enters the stack exactly when luminance drops, which + // re-identifies the branch beside it and rebuilt the whole MapKit subtree + // at the dim — measured on hardware as `map-subtree-appeared` 0.13 s after + // every `hold-armed`, and visible on the wrist as the map jumping right + // as it dims. A background cannot change the identity of the content it + // decorates. + .background(dimmedMapHoldTicker) + } + + /// Asks watchOS to render this page again when the hold ends. + /// + /// **Without this the hold could not end during a real wrist-down at all.** + /// The clock in `isWithinDimmedMapHold` only decides the answer for renders + /// that happen, and a suspended app renders nothing: the last frame drawn + /// stays on the Always-On display until something asks for another. Hold the + /// map and then suspend, and the map — not the approved readout — would be + /// the Always-On surface for the whole time the wrist was down. + /// + /// A timeline entry is the supported way to ask for that render — the same + /// mechanism the readout's coarse countdown uses while dimmed, though not + /// literally the same wake, since that countdown is not in the tree while the + /// map is held. watchOS may still coalesce them into one cadence. The + /// schedule repeats rather than naming the single boundary: a two-entry + /// `.explicit` schedule can be exhausted by an early re-query and then never + /// ask for another render at all, which would strand the map on the display. + /// Repeating entries cannot run out, and the ticker leaves the tree entirely + /// once the hold is expired, so nothing keeps asking. + /// + /// Always-On throttles these updates to roughly one a minute, so the readout + /// may return somewhat after twenty seconds. Nobody is looking by then — the + /// point is that it returns without needing the wrist. + @ViewBuilder + private var dimmedMapHoldTicker: some View { + if isLuminanceReduced, !isDimmedMapHoldExpired, let dimmedAt { + TimelineView(.periodic(from: dimmedAt, by: Self.dimmedMapHold)) { context in + Color.clear + .frame(width: 0, height: 0) + .onChange(of: context.date) { _, _ in + // **A schedule entry arriving is not proof its deadline arrived.** + // Entering Always On changes the timeline's cadence, which + // re-queries the schedule, and SwiftUI can hand over the next entry + // there and then. An earlier version treated the date changing as + // the boundary and called this directly: on a Series 9 it expired + // the hold 0.82, 0.85, 0.94 and 1.05 s after the dim, four glances + // out of four, while the simulator — which never enters Always On, + // so never changes cadence — passed every time. + // + // The clock is the authority, exactly as it is in + // `isWithinDimmedMapHold`. This callback only decides whether the + // render that just happened is the one that ends the hold. + guard Date().timeIntervalSince(dimmedAt) >= Self.dimmedMapHold else { + return + } + expireDimmedMapHold() + } + } + } } private var commandFailure: String? { @@ -607,6 +826,82 @@ struct MapPage: View { armedToolbarControl = nil } + private func noteLuminance(reduced: Bool) { + guard reduced else { + armGlanceIfWatched() + resetDimmedMapHold() + return + } + // Nothing to hold means nothing to arm. `isWithinDimmedMapHold` rejects + // all of these anyway, but arming would still start a task and put a + // timeline in the tree, asking watchOS for an Always-On render that can + // only ever decide there was no hold. + guard canHoldMapThroughDim else { return } + dimmedMapHoldTask?.cancel() + dimmedAt = Date() + isDimmedMapHoldExpired = false + #if DEBUG + WakeLog.note("hold-armed bright \(hasBeenBright) selected \(isSelected)") + #endif + // Fires only while the app happens to still be scheduled, which is roughly + // the first second after the dim. `dimmedMapHoldTicker` is what covers the + // rest; this is here so a glance that keeps the app alive ends the hold at + // exactly the right moment rather than at the next system update. + dimmedMapHoldTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(Self.dimmedMapHold)) + guard !Task.isCancelled else { return } + expireDimmedMapHold() + } + } + + private func expireDimmedMapHold() { + dimmedMapHoldTask?.cancel() + dimmedMapHoldTask = nil + guard !isDimmedMapHoldExpired else { return } + isDimmedMapHoldExpired = true + #if DEBUG + WakeLog.note("hold-expired") + #endif + } + + private func resetDimmedMapHold(_ reason: String = "luminance") { + #if DEBUG + if dimmedAt != nil || isDimmedMapHoldExpired { + WakeLog.note("hold-reset \(reason)") + } + #endif + dimmedMapHoldTask?.cancel() + dimmedMapHoldTask = nil + dimmedAt = nil + isDimmedMapHoldExpired = false + } + + /// Ends both the hold and the glance that earned it. Only deselection does + /// this: it is an explicit statement that the wearer is looking elsewhere, + /// unlike a view-lifecycle callback, which on watchOS can fire for reasons + /// that have nothing to do with whether anyone is looking. + private func endDimmedMapHoldLifetime(_ reason: String) { + resetDimmedMapHold(reason) + #if DEBUG + if hasBeenBright { WakeLog.note("hold-disarmed \(reason)") } + #endif + hasBeenBright = false + } + + private func armGlanceIfWatched() { + if isSelected, !isLuminanceReduced { hasBeenBright = true } + } + + private func noteSelection(_ selected: Bool) { + // A hold belongs to a glance at *this* page. Swiping away ends the glance, + // whatever the display is doing. + guard selected else { + endDimmedMapHoldLifetime("deselected") + return + } + armGlanceIfWatched() + } + private var mapContent: some View { ZStack { map @@ -653,9 +948,20 @@ struct MapPage: View { } #if DEBUG // Separates "built twice" from "built, torn down, rebuilt". Every device - // wake logs two appearances 50-70 ms apart and the simulator logs one, so - // the shape of the pair decides where to look: an interleaved disappear - // means a genuine teardown, two bare appearances mean two live instances. + // wake logs two appearances 50-70 ms apart, and the shape of the pair + // decides where to look: an interleaved disappear means a genuine teardown, + // two bare appearances mean two live instances. + // + // **The pair is settled, and it is not ours.** It is construct → construct + // → discard the first, netting one live subtree. A probe placed as a bare + // sibling above `pageContent`'s `if showsMap` doubled identically, and so + // did one inside `readoutContent` on a launch with no MapKit anywhere — + // so this is the whole page being built twice, not anything about the map. + // Probes on the `NavigationStack` and the `TabView` each fired once, and + // it survived both removing the hoisted stack entirely and making the + // conditional Heard page unconditional. What remains is the vertical pager + // building its selected page twice at launch. Nothing here can prevent it; + // the cost is one discarded construction against a 0.104 s rebuild. .onDisappear { WakeLog.note("map-subtree-disappeared") } #endif } @@ -1529,6 +1835,27 @@ extension Comparable { } } +extension View { + /// Take a control out of service while the display is dimmed, without taking + /// it out of the view tree. + /// + /// Presence is what must not change: wrapping toolbar items in an `if` alters + /// the toolbar's structure, which re-hosts the page and rebuilds everything + /// under it — measured on hardware as a full MapKit teardown 0.13 s into + /// every dim. Every modifier here is a value change instead. + /// + /// All three are needed. Opacity hides it, hit testing stops a touch, and + /// `disabled` plus `accessibilityHidden` stop an assistive technology + /// activating a control nobody can see. The trailing toolbar control can be + /// Start, which transmits on one tap with no confirmation step. + fileprivate func hiddenWhileDimmed(_ dimmed: Bool) -> some View { + opacity(dimmed ? 0 : 1) + .allowsHitTesting(!dimmed) + .disabled(dimmed) + .accessibilityHidden(dimmed) + } +} + /// The approved full-screen phase treatment with cadence chosen independently /// from its layout. Always-On updates too slowly to promise seconds, while a /// wearer-selected readout at full luminance can let the native timer provide From 1d7bbe01cbd9fa2e6ad9ea2d62dbfdf21d0d79c2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 15:37:40 -0700 Subject: [PATCH 222/360] Stop the movement-gate tests racing the throttle They slept a fixed 2.5 seconds against the bridge's 2 second non-urgent throttle and the retry it schedules, which left about half a second of margin. That was enough until a loaded machine ate it, and a suite that fails one run in ten teaches people to re-run rather than to look. Only the assertions that nothing was sent actually need to wait, so they keep a fixed window comfortably past the throttle: "no send" has to mean suppressed rather than merely deferred. The ones expecting a send now poll for it instead, which is both faster and indifferent to how busy the machine is. --- .../watch/watch_wire_contract_test.dart | 92 +++++++++++-------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 26129051..60cf578f 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -940,12 +940,12 @@ void main() { late MethodChannel channel; late List> sent; - /// Long enough for the 2 s non-urgent throttle to clear and for the - /// bridge's own retry timer to run, so "no send" means suppressed rather - /// than merely deferred. A fresh bridge has no throttle history at all, so - /// its first push needs neither. - const settle = Duration(milliseconds: 2500); - const firstPush = Duration(milliseconds: 100); + /// Waiting out the 2 s non-urgent throttle is only necessary when the + /// assertion is that nothing was sent. A fixed wait tuned near that + /// boundary flakes on a loaded machine — this suite has already seen it — + /// so the positive cases poll instead and only "no send" pays a fixed cost. + const suppressionWindow = Duration(seconds: 3); + const sendTimeout = Duration(seconds: 8); setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -998,14 +998,33 @@ void main() { linkedRepeaterIds: const [], ); - Future push(WatchGeo geo, {Duration wait = settle}) async { + void schedule(WatchGeo geo) { final snapshot = _snapshot(geo: geo); bridge.schedule( () => snapshot, urgencyKeyBuilder: () => snapshot.urgencyKey, immediate: true, ); - await Future.delayed(wait); + } + + /// Polls for the send rather than sleeping a fixed interval. The bridge + /// reschedules instead of sending while the throttle is closed, so the + /// delay is real but its exact length is not the thing under test. + Future pushExpectingSend(WatchGeo geo, int total) async { + schedule(geo); + final deadline = DateTime.now().add(sendTimeout); + while (sent.length < total && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(sent, hasLength(total)); + } + + /// There is nothing to poll for, so this waits past the throttle *and* the + /// retry it schedules: "no send" has to mean suppressed, not deferred. + Future pushExpectingSuppression(WatchGeo geo, int total) async { + schedule(geo); + await Future.delayed(suppressionWindow); + expect(sent, hasLength(total)); } double sentLat(int index) { @@ -1018,40 +1037,39 @@ void main() { () async { // 1e-5 degrees of latitude is about 1.11 m here, so 47.60002 is roughly // 2 m from the baseline and 47.6003 is roughly 33 m. - await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); - expect(sent, hasLength(1), reason: 'the first fix always goes'); + await pushExpectingSend(geoAt(47.6, fixedAtMs: 1760000000000), 1); // A newer fix time and a couple of metres. This is a stationary phone, // and it must not reach the radio — including on the retry the throttle // scheduled, which `settle` has already allowed to fire. - await push(geoAt(47.60002, fixedAtMs: 1760000030000)); - expect(sent, hasLength(1)); + await pushExpectingSuppression(geoAt(47.60002, fixedAtMs: 1760000030000), 1); // The same two metres, now travelling with a ping. The ping alone // defeats the dedupe, so this send happens either way; the assertion is // that it carries the *current* fix rather than the last one sent. - await push(geoAt( - 47.60002, - fixedAtMs: 1760000060000, - pings: [ - WatchPing( - id: 'rx-1760000060000', - lat: 47.60002, - lon: -122.3, - kind: 'rx', - color: const WatchColor(0, 0, 255), - at: DateTime.fromMillisecondsSinceEpoch(1760000060000), - ), - ], - )); - expect(sent, hasLength(2)); + await pushExpectingSend( + geoAt( + 47.60002, + fixedAtMs: 1760000060000, + pings: [ + WatchPing( + id: 'rx-1760000060000', + lat: 47.60002, + lon: -122.3, + kind: 'rx', + color: const WatchColor(0, 0, 255), + at: DateTime.fromMillisecondsSinceEpoch(1760000060000), + ), + ], + ), + 2, + ); expect(sentLat(1), 47.60002, reason: 'the puck must not lag the ping beside it'); // And the gate still opens on its own once the wearer has actually // moved, with nothing else in the payload changing. - await push(geoAt(47.6003, fixedAtMs: 1760000090000)); - expect(sent, hasLength(3)); + await pushExpectingSend(geoAt(47.6003, fixedAtMs: 1760000090000), 3); expect(sentLat(2), 47.6003); }); @@ -1082,27 +1100,21 @@ void main() { linkedRepeaterIds: const [], ); - await push(geoWith(47.6, 1423.5), wait: firstPush); - expect(sent, hasLength(1)); + await pushExpectingSend(geoWith(47.6, 1423.5), 1); // Two metres of jitter, with the distance readout recomputed from it. - await push(geoWith(47.60002, 1421.3)); - expect(sent, hasLength(2), - reason: 'a moved distanceM is indistinguishable from real news'); + await pushExpectingSend(geoWith(47.60002, 1421.3), 2); }); test('measures from the last fix the watch received', () async { - await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); - expect(sent, hasLength(1)); + await pushExpectingSend(geoAt(47.6, fixedAtMs: 1760000000000), 1); // Two ~11 m steps in the same direction. Each is short of the threshold // on its own, but the second lands ~22 m from the fix the watch actually // holds, so it goes. Anchoring on the previous *computed* fix instead // would suppress a wearer walking away one short step at a time. - await push(geoAt(47.6001, fixedAtMs: 1760000030000)); - expect(sent, hasLength(1)); - await push(geoAt(47.6002, fixedAtMs: 1760000060000)); - expect(sent, hasLength(2), reason: '~22 m from the delivered fix'); + await pushExpectingSuppression(geoAt(47.6001, fixedAtMs: 1760000030000), 1); + await pushExpectingSend(geoAt(47.6002, fixedAtMs: 1760000060000), 2); expect(sentLat(1), 47.6002); }); }); From 0b383157ac8725b7fdf78b94d3a70f3c20f15a23 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:31:55 -0700 Subject: [PATCH 223/360] Let the watch app install on watchOS 11 The three watch configurations carried a 26.0 deployment target, which gates installation on every earlier OS. Nothing in the app needs it: the compile floor is 10.0, and below that only `@Observable` and one `NodeListView` initialiser fail. watchOS 11 and 26 share a hardware floor of Series 6, so this costs no device support at all and reaches everyone who has not updated. Built, installed, and launched on watchOS 10.5, 11.5, and 26.5. Map, controls, and settings all render, including on the smallest watchOS 10 screen, because layout is measured from safe-area insets rather than tuned to one version's chrome. Also drop the Foundation.framework reference, which pointed into a WatchOS11.0.sdk path that no longer exists in Xcode 26. Swift links Foundation implicitly, so the entry was inert generator residue. --- ios/Runner.xcodeproj/project.pbxproj | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 170fd02a..f34448d8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -35,7 +35,6 @@ A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178C77B846A00943CD881203 /* WatchSessionClient.swift */; }; B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; }; - CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */; }; E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */; }; E83718073D76FB741949ED05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CC8647C002484845F02D0CE /* Assets.xcassets */; }; F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; }; @@ -128,7 +127,6 @@ 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchApp.swift; sourceTree = ""; }; 8BA04B67488852DCDA49C863 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -155,7 +153,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -219,7 +216,6 @@ 321ECD9614BDCAFCF545D162 /* watchOS */ = { isa = PBXGroup; children = ( - 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */, ); name = watchOS; sourceTree = ""; @@ -848,7 +844,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 26.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Profile; }; @@ -1041,7 +1037,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 26.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; @@ -1156,7 +1152,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 26.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; From 0e3916d7a2d6b7a6cd67e2bae1eaf8d584d82d29 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:04 -0700 Subject: [PATCH 224/360] Stop a fast watch clock widening the transmit window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued command carries the time the wrist tapped it, and the phone refuses one older than 30 seconds so a transmit cannot be attributed to a place the vehicle has already left. Only the upper bound was checked, so a timestamp in the future made the age negative and sailed through: a watch clock running ten minutes fast bought its commands ten extra minutes of life. The map-geography check directly above already had this right, and now both use the same tolerance. The window had no coverage at all, in either direction. Add it for every transmitting command: too old refuses and never reaches admission, a small forward skew still runs, and a far-future stamp refuses. Also pin the two exemptions, since both are deliberate — an untimestamped command from an older watch build is still accepted, and requestSnapshot is exempt because it transmits nothing. --- lib/services/watch/watch_bridge_service.dart | 7 +- .../watch/watch_wire_contract_test.dart | 103 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index b6ef048f..2ff2c7b6 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -206,7 +206,12 @@ class WatchBridgeService { } } if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { - if (ageMs! > _maximumCommandAge.inMilliseconds) { + // Both bounds matter. Too old is the obvious case; too far in the future + // is the same bug wearing a disguise, because a watch clock running fast + // makes `ageMs` negative and would otherwise extend the window by however + // far the clocks disagree. The tolerance matches the map-geo check above. + if (ageMs! > _maximumCommandAge.inMilliseconds || + ageMs < -_clockTolerance.inMilliseconds) { const reason = 'Took too long to reach iPhone'; // This window is about correctness, not queue housekeeping: executing // a transmit after the vehicle has moved attributes it to the wrong diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 60cf578f..d54bda62 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -866,6 +866,109 @@ void main() { expect(handled, hasLength(2)); }); + // transferUserInfo keeps a tapped command alive until the phone is + // reachable again, so the admission window is the only thing stopping a + // queued transmit from firing long after the wearer asked for it. Both + // bounds are load-bearing: "too old" is the queue sitting on it, and "too + // far in the future" is a skewed watch clock buying the same command extra + // life. requestSnapshot is deliberately exempt — it transmits nothing. + group('queued command age', () { + const transmitting = ['startSession', 'stopSession', 'manualPing']; + + double nowMs() => DateTime.now().millisecondsSinceEpoch.toDouble(); + + for (final kind in transmitting) { + test('$kind older than 30 seconds is refused', () async { + final refusals = []; + bridge.attachCommandHandler( + (command) async { + handled.add(command.kind); + return null; + }, + onRefusal: refusals.add, + ); + + final reply = await sendCommand( + 'aged-$kind', + kind, + issuedAtMs: nowMs() - const Duration(seconds: 31).inMilliseconds, + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Took too long to reach iPhone'); + expect(handled, isEmpty, + reason: 'an expired command must never reach admission'); + expect(refusals, ['Took too long to reach iPhone'], + reason: 'the wearer is told why the tap did nothing'); + }); + + test('$kind dated far into the future is refused', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'future-$kind', + kind, + issuedAtMs: nowMs() + const Duration(minutes: 10).inMilliseconds, + ); + + expect(reply?['accepted'], isFalse, + reason: 'a fast watch clock must not extend the 30s window'); + expect(handled, isEmpty); + }); + + test('$kind within the clock tolerance is accepted', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'skewed-$kind', + kind, + issuedAtMs: nowMs() + const Duration(seconds: 2).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'ordinary skew must not refuse a live tap'); + expect(handled, hasLength(1)); + }); + } + + test('an untimestamped command is still accepted', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand('legacy-1', 'manualPing'); + + expect(reply?['accepted'], isTrue, + reason: 'older watch builds send no issuedAtMs'); + expect(handled, hasLength(1)); + }); + + test('an aged requestSnapshot is exempt from the transmit window', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'aged-snapshot', + 'requestSnapshot', + issuedAtMs: nowMs() - const Duration(minutes: 5).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'asking for state transmits nothing and cannot go stale'); + expect(handled, [WatchCommandKind.requestSnapshot]); + }); + }); + test('an unknown command is refused without reaching the handler', () async { bridge.attachCommandHandler((command) async { From 24370e4208dd8a2a73d2ff2dac798755d4b5abc7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:13 -0700 Subject: [PATCH 225/360] Stop retained state reading as fresh on watch launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staleness was measured from when a snapshot reached the wrist. On launch the app ingests whatever application context WatchConnectivity retained, which can be hours old, and stamping arrival there presented long-dead state as current for a full 90 seconds — dimming and the age badge both said the phone was in touch when it was not. Age from the phone's own updatedAt instead, which the payload already carried and nothing read. Clamped to arrival so a phone clock running fast cannot date a snapshot into the future and extend its life, with a few seconds of slack so ordinary skew does not age a live payload early. A context already past the boundary is now stale immediately. The cue freshness check wanted the same tolerance for the same reason, so the two constants become one. --- ios/MeshMapperWatch/WatchSessionClient.swift | 41 +++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 44b0a2f5..bb511ec5 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -68,7 +68,10 @@ final class WatchSessionClient: NSObject { /// about "the phone has probably gone away", not "no update recently". static let staleAfter: TimeInterval = 90 private static let cueFreshFor: TimeInterval = 30 - private static let cueClockTolerance: TimeInterval = 5 + /// Phone and watch clocks normally agree to well under a second. This is the + /// slack allowed anywhere a phone-stamped time is compared against watch + /// "now", and mirrors the tolerance the phone applies to watch commands. + private static let clockTolerance: TimeInterval = 5 private static let mapGeoSuppressionDelay: TimeInterval = 15 private static let mapGeoRenewalInterval: TimeInterval = 5 * 60 private static let mapGeoRecoveryThrottle: TimeInterval = 3 @@ -253,16 +256,36 @@ final class WatchSessionClient: NSObject { } } - private func markSnapshotReceived(at arrival: Date = Date()) { + /// - Parameter producedAt: when the phone built the payload. Absent for the + /// debug sample, which is generated on the wrist. + private func markSnapshotReceived(at arrival: Date = Date(), producedAt: Date? = nil) { staleBoundaryTask?.cancel() - receivedAt = arrival + staleBoundaryTask = nil + + // Age from when the phone built the payload rather than when it reached the + // wrist. Launch ingests whatever application context WatchConnectivity + // retained, which can be hours old, and stamping arrival there would + // present long-dead state as fresh for a full 90 seconds. + // + // Clamped to `arrival` so a phone clock running fast cannot date a snapshot + // into the future and extend its life, with a few seconds of slack so + // ordinary skew does not age a genuinely live payload early. + let origin = + producedAt.map { min(arrival, $0.addingTimeInterval(Self.clockTolerance)) } ?? arrival + receivedAt = origin + + let remaining = Self.staleAfter - arrival.timeIntervalSince(origin) + guard remaining > 0 else { + isStale = true + return + } isStale = false // One task per delivery makes the 90-second boundary observable without a // polling timer. A newer snapshot cancels this task and owns the next one. staleBoundaryTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(Self.staleAfter)) - guard !Task.isCancelled, self?.receivedAt == arrival else { return } + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled, self?.receivedAt == origin else { return } self?.isStale = true self?.staleBoundaryTask = nil } @@ -271,9 +294,9 @@ final class WatchSessionClient: NSObject { private static func isFresh(_ cue: WatchHapticCue, at arrival: Date) -> Bool { guard let issuedAt = cue.issuedAt else { return false } let age = arrival.timeIntervalSince(issuedAt) - // Phone and watch clocks normally agree, but a few seconds of skew must - // not suppress a real failure that has just crossed the radio. - return age >= -cueClockTolerance && age <= cueFreshFor + // A few seconds of skew must not suppress a real failure that has just + // crossed the radio. + return age >= -clockTolerance && age <= cueFreshFor } // MARK: - Ingest @@ -300,7 +323,7 @@ final class WatchSessionClient: NSObject { let arrival = Date() self.versionMismatch = false self.snapshot = decoded - self.markSnapshotReceived(at: arrival) + self.markSnapshotReceived(at: arrival, producedAt: decoded.updatedAt) // A queued command has no ack. Any subsequent snapshot proves the phone // has resumed communicating; a separate timeout covers the case where // state dedupe means no snapshot follows. From 41949ac3fcf79b109ae05ce159c6ed4afb546c9e Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:21 -0700 Subject: [PATCH 226/360] Clear out two pieces of development residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app header still described Phase 2 as shipping a transport and a raw debug dump with the map, node list, and controls due later. All of them are here, so a reader had no way to tell which files were finished. The satellite preference was write-only: declared, persisted, loaded on launch, and read by nothing. Settings offers no toggle for it, and the map page documents why there is none — Apple's .imagery renders indistinguishably from .standard at this size. --- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 4 ++-- ios/MeshMapperWatch/WatchSettings.swift | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 56bf5395..5e6030de 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -6,8 +6,8 @@ import SwiftUI /// holds the BLE link to the MeshCore device and the GPS fix, and pushes /// snapshots over WatchConnectivity. Nothing here drives a session on its own. /// -/// Phase 2 ships the transport and a raw debug dump; the map, node list, and -/// real controls arrive in later phases. +/// The app is a vertical pager over a map, a node list, controls, and a debug +/// dump, with wrist-local layout preferences in settings. @main struct MeshMapperWatchApp: App { @State private var client = WatchSessionClient() diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index f4eca88d..8ab5e9d6 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -10,7 +10,6 @@ import SwiftUI @Observable final class WatchSettings { private enum Key { - static let satellite = "map.satellite" static let showLinks = "map.showLinks" static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" @@ -101,7 +100,6 @@ final class WatchSettings { init(defaults: UserDefaults = .standard) { self.defaults = defaults - satellite = defaults.bool(forKey: Key.satellite) showLinks = defaults.bool(forKey: Key.showLinks) // Following the fix is the useful default while driving; absent any // stored value `bool(forKey:)` returns false, so invert an explicit flag. @@ -134,12 +132,6 @@ final class WatchSettings { ) as? Bool ?? false } - /// Apple imagery rather than the standard basemap. Mirrors the iOS app's - /// satellite option, though the imagery is Apple's, not ArcGIS. - var satellite: Bool { - didSet { defaults.set(satellite, forKey: Key.satellite) } - } - /// Draw a line from the fix to each repeater that answered the last ping. var showLinks: Bool { didSet { defaults.set(showLinks, forKey: Key.showLinks) } From 87f6f1e3357865202a10a21a1ff4b467a969a1b2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:21 -0700 Subject: [PATCH 227/360] Run the tests in CI The suite is no longer empty: 196 tests cover the watch wire contract, geo suppression, redelivery dedupe, movement gating, and command admission, and they are what protects this work from a quiet regression. The comment claiming there were no tests had outlived its truth. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08f6a819..5d3f3bd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,5 +23,4 @@ jobs: - run: dart run build_runner build --delete-conflicting-outputs - run: dart format --output=none --set-exit-if-changed . - run: flutter analyze - #- run: flutter test - # no tests yet, fails without ./test directory + - run: flutter test From 5a2be4da370cd7eb8433d7721dc94e36ad0231e8 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:51:06 -0700 Subject: [PATCH 228/360] Answer a requested refresh even when nothing changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ageing retained state from the phone's updatedAt fixed state that lied about being fresh, and exposed the reciprocal hole: the refresh the watch sends to escape that state could be deduplicated into silence. The phone forgets its delivered-payload fingerprint only when WatchConnectivity reports a state change. A watch app relaunch is not one — `sessionReachabilityDidChange` is not implemented, and pairing and installation are unchanged, so nothing calls `publishStatus()`. The fingerprint survives, the rebuilt payload matches it once updatedAtMs is stripped, and `_flush` returns without sending. The watch then sits on a context it has correctly marked stale, with no way to prove otherwise, until something unrelated moves. It needs the map to have been visible when the watch died: a suppressed map means requestSnapshot restores geography, which changes the payload and defeats dedupe by itself. Leaving the watch on the map page is both the common case and the one where the dimming is most visible. So requestSnapshot now forces delivery. Only that command does — most immediate updates are immediate precisely because something changed, and should stay deduplicatable. The radio throttle still applies, and the obligation outlives its own deferral rather than bypassing it, so a watch asking repeatedly cannot turn this into an unmetered path to the radio. It is cleared on delivery, not on the decision to send, so a payload native refuses does not strand the wearer exactly as before. --- lib/providers/app_state_provider.dart | 8 +- lib/services/watch/watch_bridge_service.dart | 41 ++++++- .../watch/watch_wire_contract_test.dart | 108 +++++++++++++++++- 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4a70f74f..a4501d7d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1224,12 +1224,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - void _scheduleWatchSync({bool immediate = false}) { + void _scheduleWatchSync({bool immediate = false, bool forceDelivery = false}) { if (_isDisposed || !_watchBridge.canSync) return; _watchBridge.schedule( _buildWatchSnapshot, urgencyKeyBuilder: _buildWatchUrgencyKey, immediate: immediate, + forceDelivery: forceDelivery, ); } @@ -1643,7 +1644,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { switch (kind) { case WatchCommandKind.requestSnapshot: - _scheduleWatchSync(immediate: true); + // The watch only asks after a relaunch or a return to the foreground, + // when what it holds is a retained context of unknown age. Dedupe would + // answer an unchanged session with silence and leave it stale. + _scheduleWatchSync(immediate: true, forceDelivery: true); return null; case WatchCommandKind.startSession: diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 2ff2c7b6..2bf9a6df 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -97,6 +97,20 @@ class WatchBridgeService { DateTime? _lastBuiltAt; bool _disposed = false; bool _didReconcileNativeState = false; + + /// A refresh the watch asked for, which dedupe must not answer with silence. + /// + /// The phone only forgets its delivered-payload fingerprint when + /// WatchConnectivity reports a state change, and relaunching the watch app is + /// not one: pairing and installation are unchanged, so the phone still + /// believes the watch holds this exact payload. It does — but only as a + /// retained context whose age is now shown honestly, which is precisely the + /// state the wearer is asking to be rid of. Answering "nothing changed" with + /// nothing at all leaves that state stale until something unrelated moves. + /// + /// Survives a deferred flush so the radio throttle can still delay the + /// refresh, and is cleared only once a payload is actually delivered. + bool _forceDelivery = false; bool _canSync = false; Map? _lastNativeStatus; DateTime? _lastSuccessfulSendAt; @@ -334,15 +348,21 @@ class WatchBridgeService { } } + /// - Parameter forceDelivery: send even when the payload is byte-identical to + /// the last delivered one. Reserved for a refresh the watch explicitly + /// asked for; ordinary immediate updates stay deduplicatable, because most + /// of them are urgent precisely because something did change. void schedule( WatchSnapshotBuilder snapshotBuilder, { required WatchUrgencyKeyBuilder urgencyKeyBuilder, bool immediate = false, + bool forceDelivery = false, }) { if (_disposed || !canSync) return; _pendingSnapshotBuilder = snapshotBuilder; _pendingUrgencyKeyBuilder = urgencyKeyBuilder; + if (forceDelivery) _forceDelivery = true; _scheduledUpdate?.cancel(); if (immediate) { @@ -401,7 +421,11 @@ class WatchBridgeService { final fingerprint = Map.from(payload) ..remove('updatedAtMs'); final encoded = jsonEncode(fingerprint); - if (encoded == _lastPayload) return; + // A forced refresh is answered with the payload as it stands, identical or + // not. Only the fresher updatedAt distinguishes it, and that is the whole + // point: it is what proves the phone is still there. + final force = _forceDelivery; + if (!force && encoded == _lastPayload) return; // The movement gate. A stationary GPS jitters by a few metres for as long // as the phone is switched on, and forwarding that would keep the watch @@ -415,8 +439,16 @@ class WatchBridgeService { // visibly so once the watch's zoom floor reached ~22 m of latitude. final fix = _fixOf(fingerprint); final withoutFix = _encodeWithoutFix(fingerprint); - if (withoutFix == _lastPayloadWithoutFix && !_fixMovedEnough(fix)) return; + if (!force && + withoutFix == _lastPayloadWithoutFix && + !_fixMovedEnough(fix)) { + return; + } + // The throttle still applies. It delays a forced refresh by at most the + // non-urgent interval and `_forceDelivery` outlives the deferral, so the + // refresh still arrives — while a watch asking repeatedly cannot turn this + // into an unmetered path to the radio. final urgent = snapshot.urgencyKey != _lastUrgencyKey; final sentAt = _lastSentAt; if (!urgent && sentAt != null) { @@ -446,6 +478,11 @@ class WatchBridgeService { return; } _didReconcileNativeState = true; + // Cleared here rather than at the dedupe check: a send the native side + // refused leaves the fingerprint in place, so an obligation dropped + // earlier would dedupe against that same payload and strand the wearer + // exactly as before. + _forceDelivery = false; _lastPayload = encoded; // Anchored to what the watch actually received, so a refused or dropped // send cannot quietly consume the wearer's next 15 m of movement. diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index d54bda62..ea223469 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -21,6 +21,7 @@ WatchSnapshot _snapshot({ WatchGeo? geo, bool mapGeoIncluded = true, List availableStartModes = const [WatchStartMode.passive], + DateTime? updatedAt, }) => WatchSnapshot( core: LiveActivitySnapshot( @@ -59,7 +60,7 @@ WatchSnapshot _snapshot({ pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, cue: cue, - updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(1759999999000), ); void main() { @@ -720,6 +721,111 @@ void main() { reason: 'native cleared its context cache on the state change'); }); + // The watch asks for a snapshot after a relaunch, holding a retained + // context it now ages honestly. The phone forgets its delivered fingerprint + // only on a WatchConnectivity state change, and a watch app restart is not + // one — pairing and installation never changed. Without a force path an + // unchanged session answers that request with silence, and the wearer keeps + // looking at state marked stale while the phone is alive and listening. + group('a requested refresh defeats dedupe', () { + // Long enough to clear the non-urgent radio interval. A forced refresh is + // deliberately still subject to it, so anything shorter would only prove + // the throttle deferred the flush, not what the flush decided. + const settle = Duration(milliseconds: 2300); + + Future deliver({ + DateTime? updatedAt, + bool forceDelivery = false, + Duration wait = settle, + }) async { + bridge.schedule( + () => _snapshot(updatedAt: updatedAt), + urgencyKeyBuilder: () => _snapshot(updatedAt: updatedAt).urgencyKey, + immediate: true, + forceDelivery: forceDelivery, + ); + await Future.delayed(wait); + } + + test('an identical payload is still deduplicated without one', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + ); + + expect(syncCalls, 1, + reason: 'a newer updatedAt alone must not spend the radio'); + }); + + test('a forced refresh sends the unchanged payload again', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + // Semantically identical to what the watch already holds. Only + // updatedAt moved, which is exactly what proves the phone is alive. + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + + expect(syncCalls, 2, + reason: 'a refresh the watch asked for must reach it'); + }); + + test('the throttle delays a forced refresh but cannot cancel it', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + // The first delivery is unthrottled — nothing has been built yet — so + // the forced one has to follow it closely enough to land inside the + // interval it opens. + await deliver(wait: const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + wait: const Duration(milliseconds: 10), + ); + + expect(syncCalls, 1, + reason: 'the radio interval still governs when it goes out'); + + await Future.delayed(settle); + + expect(syncCalls, 2, + reason: 'the obligation must survive its own deferral'); + }); + + test('the obligation is spent, not standing', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + expect(syncCalls, 2); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000700000), + ); + + expect(syncCalls, 2, + reason: 'one request buys one delivery, not a permanent bypass'); + }); + }); + test('the nonurgent throttle runs before the snapshot builder', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); From 2bfe0e2940e4e9498452a12120e00626df8fd6e3 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 20:16:30 -0700 Subject: [PATCH 229/360] Stop map-geo lease renewals forcing a snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestSnapshot carries two intents down one wire. One is a genuine plea for state, after a relaunch or a resume onto a retained context of unknown age. The other only changes what future snapshots contain: the map-geo lease, which renews as the same command every five minutes for as long as the map stays hidden. Forcing delivery for the command kind therefore forced an otherwise identical snapshot on every renewal — spending the radio exactly where the lease exists to save it, and against the comment saying renewal "keeps a long Always-On session cheap". Carry the intent explicitly instead. It is not inferred from mapGeoNeeded, which the bridge may legitimately resolve to nil when a suppression claim arrives stale or out of order, and which is also true for an ordinary return to the map — a transition that already defeats dedupe by restoring geography and needs no help. Absent on the wire means false, so a phone paired with an older watch build behaves as it did before. Also pin the behaviour the force path leans on hardest: a send native refuses leaves the fingerprint untouched, so the obligation must survive it or the next flush dedupes against that same payload and strands the wearer exactly as before the fix. --- ios/MeshMapperWatch/WatchSessionClient.swift | 61 +++++++++++++- ios/Shared/MeshMapperWatchPayload.swift | 14 ++++ lib/providers/app_state_provider.dart | 14 +++- lib/services/watch/watch_bridge_service.dart | 1 + lib/services/watch/watch_models.dart | 14 +++- .../watch/watch_wire_contract_test.dart | 79 +++++++++++++++++++ 6 files changed, 175 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index bb511ec5..bb4bd961 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -103,6 +103,42 @@ final class WatchSessionClient: NSObject { session.activate() } + /// Reconcile after the scene becomes active again. + /// + /// `onAppear` is not a resume callback — it fires once — and watchOS suspends + /// this app for essentially the whole wrist-down interval, measured here at + /// 7.42 s of suspension against 8.20 s of wrist-down. So without this a + /// wearer could raise their wrist onto state the UI itself calls stale and + /// have nothing ask the phone to prove otherwise. + /// + /// Deliberately not `refresh()`. That always requests, which would put a + /// WatchConnectivity round trip behind every glance — the opposite of what + /// this transport is built for. Ingesting the retained context is free, so + /// do it always; spend the radio only when what we hold is stale or missing, + /// which is exactly when a request can change what the wearer sees. + func resume() { + #if DEBUG + if SampleSnapshot.isEnabled { return } + #endif + + guard let session else { return } + session.delegate = self + + guard session.activationState == .activated else { + pendingRefresh = true + session.activate() + return + } + + ingest(context: session.receivedApplicationContext) + + if snapshot == nil || isStale { + requestFullSnapshot() + } + + if !mapGeoNeeded { scheduleMapGeoSuppression() } + } + private var pendingRefresh = false // MARK: - Commands @@ -119,6 +155,7 @@ final class WatchSessionClient: NSObject { _ kind: WatchCommand.Kind, mode: String? = nil, mapGeoNeeded: Bool? = nil, + forceRefresh: Bool = false, silent: Bool = false ) { guard let session, session.activationState == .activated else { @@ -132,6 +169,7 @@ final class WatchSessionClient: NSObject { kind: kind, mode: mode, mapGeoNeeded: mapGeoNeeded, + forceRefresh: forceRefresh ? true : nil, id: UUID().uuidString, issuedAtMs: Date().timeIntervalSince1970 * 1000 ) @@ -194,17 +232,34 @@ final class WatchSessionClient: NSObject { } } - private func sendMapGeoPreference(_ needed: Bool, force: Bool = false) { + /// - Parameter force: resend even when the phone was already told this value, + /// which is what renews the lease. + /// - Parameter refresh: additionally demand a snapshot back. Lease traffic + /// leaves this false so an unchanged session stays silent. + private func sendMapGeoPreference( + _ needed: Bool, + force: Bool = false, + refresh: Bool = false + ) { guard force || lastSentMapGeoNeeded != needed else { return } guard let session, session.activationState == .activated else { return } lastSentMapGeoNeeded = needed - send(.requestSnapshot, mapGeoNeeded: needed, silent: true) + send( + .requestSnapshot, + mapGeoNeeded: needed, + forceRefresh: refresh, + silent: true + ) } private func requestFullSnapshot() { // Activation always starts from the safe assumption even if a retained // application context says the previous process had suppressed its map. - sendMapGeoPreference(true, force: true) + // + // This is the one caller that genuinely needs state back: whatever the + // watch holds came from a retained context of unknown age, so an unchanged + // session must still answer rather than dedupe into silence. + sendMapGeoPreference(true, force: true, refresh: true) } private func beginPending(_ kind: WatchCommand.Kind) { diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index a61f440c..98a3bde8 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -375,6 +375,20 @@ struct WatchCommand: Codable, Hashable { /// Optional map-demand state carried only by requestSnapshot. An old phone /// ignores it and keeps sending full geography, so wire v2 remains safe. let mapGeoNeeded: Bool? + /// Whether this is a genuine plea for current state, rather than a change of + /// map demand that happens to travel as the same command. + /// + /// `requestSnapshot` carries both intents. Only the first should defeat the + /// phone's unchanged-state dedupe: the map-geo lease renews every five + /// minutes for as long as the map stays hidden, and forcing an identical + /// snapshot each time would spend the radio precisely where the lease exists + /// to save it. The distinction is stated rather than inferred from + /// `mapGeoNeeded`, which the phone may legitimately resolve to nil when a + /// suppression claim arrives stale or out of order. + /// + /// Optional for the usual reason: absent means false, so a phone paired with + /// an older watch build behaves exactly as it did before. + let forceRefresh: Bool? /// Client-generated, so the phone can dedupe redelivered commands. let id: String /// Queued delivery can outlive the place where a transmit was requested. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a4501d7d..185fbfdd 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1644,10 +1644,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { switch (kind) { case WatchCommandKind.requestSnapshot: - // The watch only asks after a relaunch or a return to the foreground, - // when what it holds is a retained context of unknown age. Dedupe would - // answer an unchanged session with silence and leave it stale. - _scheduleWatchSync(immediate: true, forceDelivery: true); + // This command carries two intents. A genuine plea for state — after a + // relaunch or a resume onto a retained context of unknown age — must + // not be answered with dedupe's silence. A change of map demand is not + // that, and the lease behind it renews every five minutes for as long + // as the map stays hidden, so forcing those would spend the radio + // exactly where the lease exists to save it. + _scheduleWatchSync( + immediate: true, + forceDelivery: command.forceRefresh, + ); return null; case WatchCommandKind.startSession: diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 2bf9a6df..4d0ed6d6 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -244,6 +244,7 @@ class WatchBridgeService { kind: kind, mode: args['mode'] as String?, mapGeoNeeded: effectiveMapGeoNeeded, + forceRefresh: args['forceRefresh'] == true, )); final refusal = admission is Future ? await admission : admission; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 9d05b281..fc63c51c 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -459,7 +459,12 @@ enum WatchCommandKind { /// Decoded wrist intent. [mode] stays raw until phone-side admission so an /// unknown value can be refused rather than mistaken for an omitted mode. class WatchCommand { - const WatchCommand({required this.kind, this.mode, this.mapGeoNeeded}); + const WatchCommand({ + required this.kind, + this.mode, + this.mapGeoNeeded, + this.forceRefresh = false, + }); final WatchCommandKind kind; final String? mode; @@ -468,6 +473,13 @@ class WatchCommand { /// keep the fail-safe full payload; new phones suppress only after a fresh /// false claim. final bool? mapGeoNeeded; + + /// Whether the wrist is asking for state, rather than changing what future + /// snapshots contain. Only the former may defeat unchanged-state dedupe; + /// map-geo lease renewals travel as the same command every five minutes and + /// must stay deduplicatable. Absent on the wire means false, so an older + /// watch build behaves as it always did. + final bool forceRefresh; } typedef WatchRequestedStartModeResolution = ({ diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index ea223469..93a87fcc 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -559,6 +559,7 @@ void main() { String? mode, bool? mapGeoNeeded, double? issuedAtMs, + bool? forceRefresh, }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger @@ -571,6 +572,7 @@ void main() { if (mode != null) 'mode': mode, if (mapGeoNeeded != null) 'mapGeoNeeded': mapGeoNeeded, if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, + if (forceRefresh != null) 'forceRefresh': forceRefresh, }), ), null, @@ -806,6 +808,34 @@ void main() { reason: 'the obligation must survive its own deferral'); }); + test('a native refusal does not consume the obligation', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + // Native refuses. The fingerprint is deliberately not updated, so an + // obligation dropped here would dedupe against that same payload on + // every later flush and strand the watch exactly as before the fix. + syncSucceeds = false; + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + expect(syncCalls, 2, reason: 'the refused attempt still reached native'); + + syncSucceeds = true; + // Note the absent forceDelivery: the obligation has to be the thing + // carrying this through, not a fresh request. + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000700000), + ); + + expect(syncCalls, 3, + reason: 'a refusal must leave the refresh still owed'); + }); + test('the obligation is spent, not standing', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); @@ -1075,6 +1105,55 @@ void main() { }); }); + // requestSnapshot carries two intents down one wire. Only a genuine plea + // for state may defeat dedupe; the map-geo lease renews as the same command + // every five minutes while the map stays hidden, and forcing those would + // spend the radio exactly where the lease exists to save it. The intent is + // stated rather than inferred from mapGeoNeeded, which the bridge may + // resolve to null when a suppression claim is stale or out of order. + group('refresh intent is explicit', () { + late List received; + + setUp(() { + received = []; + bridge.attachCommandHandler((command) { + received.add(command); + return null; + }); + }); + + test('a stated refresh asks for delivery', () async { + await sendCommand('r-1', 'requestSnapshot', + mapGeoNeeded: true, forceRefresh: true); + + expect(received.single.forceRefresh, isTrue); + }); + + test('a lease renewal does not', () async { + await sendCommand('r-2', 'requestSnapshot', + mapGeoNeeded: false, forceRefresh: false); + + expect(received.single.forceRefresh, isFalse, + reason: 'renewals repeat every five minutes and must stay cheap'); + }); + + test('returning to the map does not force on its own', () async { + // Restoring geography changes the payload by itself, so dedupe is + // already defeated where it matters. Forcing here would only add a + // guaranteed send to a page transition the wearer makes constantly. + await sendCommand('r-3', 'requestSnapshot', mapGeoNeeded: true); + + expect(received.single.forceRefresh, isFalse); + }); + + test('an older watch build without the field never forces', () async { + await sendCommand('r-4', 'requestSnapshot'); + + expect(received.single.forceRefresh, isFalse, + reason: 'absent must mean false, not "assume the expensive thing"'); + }); + }); + test('an unknown command is refused without reaching the handler', () async { bridge.attachCommandHandler((command) async { From d9cf5092932d1a8f2bb0ab84f243ce58c6d754f5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 20:16:40 -0700 Subject: [PATCH 230/360] Reconcile on resume, not only on first appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onAppear` fires once and cannot speak for a resume, and watchOS suspends this app for essentially the whole wrist-down interval — measured here at 7.42 s of suspension against 8.20 s of wrist-down, 12.89 against 13.63. Nothing else asked the phone for anything. So the dominant interaction on this device, raising a wrist, could land on state the UI itself declares stale while the phone was available and willing the entire time. Observe scenePhase and reconcile when the scene becomes active. This is deliberately not `refresh()`, which always requests and would put a WatchConnectivity round trip behind every glance — the opposite of what this transport is for. Ingesting the retained application context is free, so it happens on every resume; the radio is spent only when what we hold is stale or missing, which is exactly when a request can change what the wearer sees. A glance onto fresh state costs nothing, and the worst case becomes roughly one request per stale interval while someone is actually looking at the watch, rather than one per raise. The decision is logged to the existing wake-timing harness, because it is otherwise invisible: a run of wrist raises should show `resume-local` while the state is fresh and exactly one `resume-request` on the first glance past the stale boundary. That is the claim, and on hardware it is the only way to see whether it holds. --- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 10 ++++++++++ ios/MeshMapperWatch/WatchSessionClient.swift | 13 ++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 5e6030de..48754fab 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -12,6 +12,7 @@ import SwiftUI struct MeshMapperWatchApp: App { @State private var client = WatchSessionClient() @State private var settings = WatchSettings() + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -19,6 +20,15 @@ struct MeshMapperWatchApp: App { .environment(client) .environment(settings) .onAppear { client.refresh() } + // `onAppear` fires once, and watchOS suspends this app for the whole + // wrist-down interval, so it cannot speak for a resume. Reconciling + // here is what stops a glance landing on state the UI calls stale + // while the phone is available; `resume` decides on its own whether + // that costs a request. + .onChange(of: scenePhase) { previous, phase in + guard phase == .active, previous != .active else { return } + client.resume() + } } } } diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index bb4bd961..09377195 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -132,9 +132,16 @@ final class WatchSessionClient: NSObject { ingest(context: session.receivedApplicationContext) - if snapshot == nil || isStale { - requestFullSnapshot() - } + // Read after ingesting: a context retained while the wrist was down may + // have just answered the question, and then the radio is not needed. + let needsPhone = snapshot == nil || isStale + #if DEBUG + // The whole claim of this method in one line per glance. A wrist-raise run + // should show `resume-local` for fresh glances and exactly one + // `resume-request` for the first glance past the stale boundary. + WakeLog.note(needsPhone ? "resume-request" : "resume-local") + #endif + if needsPhone { requestFullSnapshot() } if !mapGeoNeeded { scheduleMapGeoSuppression() } } From 6bce3aef28a1d9f41322b376a45547bbc17cd8e7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 15 Aug 2026 21:55:18 -0700 Subject: [PATCH 231/360] Stop both countdown bars animating against the system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch bar animated frame(width:) from its current fraction to zero for the length of a whole phase, so SwiftUI re-ran layout for the panel on every frame of a 60-second drain instead of handing one transform to the render server. It is now a leading-anchored scale, and it stops entirely under reduced luminance — Apple's Always-On guidance, and newly load-bearing since the dim hold keeps this panel on screen for up to 20 s past a dim, which is exactly when it was animating over a screen that updates once a minute. The scale applies to a Rectangle mask rather than to the capsule. Scaling the capsule squashed its end caps, so the bar started rounded and finished square. A comment here previously excused that as "the shape it was already collapsing to anyway"; Adam saw it within a minute of getting the build on his wrist. The Live Activity bar was asking ActivityKit for something it does not do: a @State fraction driven by a withAnimation lasting the entire phase. Widgets cap a custom animation at two seconds and run none at all under reduced luminance, so on an always-on lock screen the fill stayed where the last update left it — "mostly full at 9 seconds remaining". Every @State, Task and withAnimation is gone. The title and native countdown share a row and a ProgressView(timerInterval:) draws beneath, so both are derived from absolute dates and every render the system chooses to make lands correctly. The bar now fills rather than drains, which is the trade for handing the work over: its progress comes from a date range and cannot be inverted. Its range is the whole phase, never now...deadline, which would reset it on every redraw. Also here, and confirmed on hardware after one failed attempt: the drain no longer outlives the phase. Assigning a value it already holds does not end the animation driving it — withAnimation sets the model value immediately and animates only the presentation, so mid-drain remainingFraction is already 0, and the snap-to-truth on a stopped session assigned 0 to 0 and changed nothing. The bar kept draining to the old deadline while the title read "Ready". A dim never showed this because a dim mid-phase has a non-zero truth. The nudge that forces a real change needs its own transaction, because SwiftUI batches state changes made in one run-loop turn and the first attempt coalesced into a single net-zero change. Non-urgent Live Activity updates now wait 15 s rather than 2. Phase changes, ping outcomes, connection loss and zone changes stay in urgencyKey and still go immediately; counter and repeater churn waits. This saves on both devices, since a locally generated ActivityKit update is mirrored to the paired watch and counts against its Live Activity budget. The bar probe logs phase title, deadline, duration, fraction, lapsed and whether the fill drains, on every re-run of the phase task. It is what separated "the task never re-ran" from "the animation outlived the state", after this one line had already been wrong three different ways in a day. --- .../MeshMapperLiveActivity.swift | 228 +++++++----------- ios/MeshMapperWatch/MapPage.swift | 197 ++++++++++----- .../live_activity/live_activity_service.dart | 26 +- .../live_activity_service_test.dart | 51 +++- 4 files changed, 308 insertions(+), 194 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index ca4bb43c..1a912201 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -108,7 +108,6 @@ private struct MeshMapperLockScreenContent: View { VStack(alignment: .leading, spacing: 9) { MeshMapperPhaseBar( state: state, - height: 20, titleFont: .subheadline.weight(.semibold), countdownFont: .subheadline.monospacedDigit().weight(.semibold), countdownWidth: 54 @@ -147,7 +146,6 @@ private struct MeshMapperSmallActivityContent: View { VStack(alignment: .leading, spacing: 7) { MeshMapperPhaseBar( state: state, - height: 18, titleFont: .caption.weight(.semibold), countdownFont: .caption2.monospacedDigit().weight(.bold), countdownWidth: 40 @@ -187,133 +185,79 @@ private struct MeshMapperSmallActivityContent: View { } } -/// The phase as a locally depleting bar, matching the watch map panel. +/// The phase as a caption, a native countdown, and system-drawn progress. /// -/// Absolute deadline plus duration lets SwiftUI animate between sparse phone -/// updates. The title and countdown ride over the fill so neither consumes -/// track width, and their shadow keeps them legible on both halves. +/// **Nothing here animates and nothing here holds state.** It used to: a +/// `@State` fraction was driven to zero by a `withAnimation` lasting the whole +/// phase, which a Live Activity cannot honour. Widgets cap a custom animation +/// at two seconds and run none at all under reduced luminance, so on an +/// always-on lock screen the fill simply stayed wherever the last ActivityKit +/// update left it — observed still nearly full with nine seconds on the clock. +/// +/// Both elements are now derived from absolute dates by the system, so every +/// render the system chooses to make, on whatever schedule it likes, lands in +/// the right place — and none of them costs this extension any work. private struct MeshMapperPhaseBar: View { - @Environment(\.isLuminanceReduced) private var isLuminanceReduced - let state: MeshMapperActivityAttributes.ContentState - let height: CGFloat let titleFont: Font let countdownFont: Font let countdownWidth: CGFloat - @State private var remainingFraction: CGFloat - @State private var deadlineLapsed: Bool - - init( - state: MeshMapperActivityAttributes.ContentState, - height: CGFloat, - titleFont: Font, - countdownFont: Font, - countdownWidth: CGFloat - ) { - self.state = state - self.height = height - self.titleFont = titleFont - self.countdownFont = countdownFont - self.countdownWidth = countdownWidth - - let now = Date() - _remainingFraction = State( - initialValue: state.phaseRemainingFraction(at: now) ?? 0 - ) - _deadlineLapsed = State( - initialValue: state.phaseEndsAt.map { $0 <= now } ?? false - ) - } - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - if !isLuminanceReduced { - Capsule() - // Progress says how much time remains. Outcome has quieter, - // dedicated dots elsewhere and must not recolour the whole track. - .fill(MeshMapperPalette.accent) - .frame(width: geometry.size.width * remainingFraction) - } - } - .overlay { - HStack { - Text(state.phaseTitle) - .font(titleFont) - .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 4) - MeshMapperCountdown( - state: state, - isActive: !deadlineLapsed, - font: countdownFont - ) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(state.phaseTitle) + .font(titleFont) + .foregroundStyle(.white.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + // Fixed width because the native timer otherwise reserves room for its + // widest possible value and starves the title. + MeshMapperCountdown(state: state, font: countdownFont) .frame(width: countdownWidth, alignment: .trailing) - } - // The native timer keeps a small amount of reserved space beyond the - // visible glyphs even inside its trailing-aligned fixed frame. Leaving - // the title at seven points but tucking that reservation toward the - // rounded cap puts the visible digits about four points from the end; - // 2.5 points still clears the curve on the shortest 18 pt track. - .padding(.leading, 7) - .padding(.trailing, 2.5) - .shadow(color: .black.opacity(0.7), radius: 1.5) } - } - .frame(height: height) - // An element that implies continuous motion must not be drawn where the - // refresh rate cannot deliver it. Luminance participates only to stop and - // resume rendering; among payload fields, deadline and duration alone - // identify the drain, so a caption change cannot restart it. - .task(id: animationTaskKey) { - await runPhaseAnimation(animateFill: !isLuminanceReduced) + MeshMapperPhaseProgress(state: state) } } +} - private var animationTaskKey: MeshMapperPhaseAnimationTaskKey { - MeshMapperPhaseAnimationTaskKey( - drain: state.phaseAnimationKey, - canRenderContinuously: !isLuminanceReduced - ) - } - - @MainActor - private func runPhaseAnimation(animateFill: Bool) async { - let now = Date() - let fraction = state.phaseRemainingFraction(at: now) ?? 0 - let lapsed = state.phaseEndsAt.map { $0 <= now } ?? false - - // ActivityKit may replace state midway through a phase. Snap to the - // absolute fraction before starting one compositor animation, so no timer - // tick or stale previous endpoint can distort the new bar. - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - remainingFraction = fraction - deadlineLapsed = lapsed - } - - guard animateFill else { return } - guard let endsAt = state.phaseEndsAt else { return } - let remaining = endsAt.timeIntervalSince(now) - guard remaining > 0 else { return } - - await Task.yield() - withAnimation(.linear(duration: remaining)) { - remainingFraction = 0 - } +/// Elapsed phase progress, drawn by the system from the phase's own dates. +/// +/// The range is the **whole phase**, not `now...deadline`: a range beginning at +/// the current render would reset the bar to empty every time the system +/// redrew the activity, which is the failure the hand-animated fill had in a +/// different form. +/// +/// It fills rather than drains, which is the trade for handing the work to +/// `ProgressView`. The countdown beside it remains the authoritative statement +/// of time remaining; the bar is the glanceable one. +private struct MeshMapperPhaseProgress: View { + let state: MeshMapperActivityAttributes.ContentState - do { - try await Task.sleep(for: .seconds(remaining)) - } catch { - return + var body: some View { + if let range = state.phaseProgressRange { + ProgressView(timerInterval: range, countsDown: false) { + EmptyView() + } currentValueLabel: { + EmptyView() + } + .progressViewStyle(.linear) + // Progress says how far through the phase we are. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .tint(MeshMapperPalette.accent) + } else { + // A durable state — disconnected, stopped, waiting for GPS — has no + // deadline to draw. The empty track keeps the block's height fixed, so + // arriving at one cannot reflow everything below it. + Capsule() + .fill(.white.opacity(0.16)) + .frame(height: Self.trackHeight) } - guard !Task.isCancelled else { return } - deadlineLapsed = true } + + /// Matches the linear `ProgressView` track this stands in for. + private static let trackHeight: CGFloat = 4 } private struct MeshMapperStatusLabel: View { @@ -438,7 +382,6 @@ private struct MeshMapperIslandBottom: View { VStack(alignment: .leading, spacing: 6) { MeshMapperPhaseBar( state: state, - height: 18, titleFont: .caption.weight(.semibold), countdownFont: .caption2.monospacedDigit().weight(.bold), countdownWidth: 42 @@ -532,7 +475,7 @@ private struct MeshMapperCompactTrailing: View { MeshMapperOutcomeDot(state: state, diameter: 8) } } - .task(id: state.phaseAnimationKey) { + .task(id: state.phaseDeadlineKey) { let now = Date() deadlineLapsed = state.phaseEndsAt.map { $0 <= now } ?? false guard let endsAt = state.phaseEndsAt else { return } @@ -551,7 +494,7 @@ private struct MeshMapperCompactTrailing: View { private struct MeshMapperCountdown: View { let state: MeshMapperActivityAttributes.ContentState - let isActive: Bool + var isActive: Bool = true let font: Font var body: some View { @@ -569,19 +512,14 @@ private enum MeshMapperPalette { static let accent = Color.accentColor } -private struct MeshMapperPhaseAnimationKey: Hashable { +private struct MeshMapperPhaseDeadlineKey: Hashable { let endsAt: Date? let durationMs: Int? } -private struct MeshMapperPhaseAnimationTaskKey: Hashable { - let drain: MeshMapperPhaseAnimationKey - let canRenderContinuously: Bool -} - extension MeshMapperActivityAttributes.ContentState { - fileprivate var phaseAnimationKey: MeshMapperPhaseAnimationKey { - MeshMapperPhaseAnimationKey( + fileprivate var phaseDeadlineKey: MeshMapperPhaseDeadlineKey { + MeshMapperPhaseDeadlineKey( endsAt: phaseEndsAt, durationMs: phaseDurationMs ) @@ -593,6 +531,28 @@ extension MeshMapperActivityAttributes.ContentState { return now...phaseEndsAt } + /// The phase's own span, for a `ProgressView` that reads the clock itself. + /// + /// Deliberately anchored to the phase's start rather than to `now`, so a + /// system-initiated redraw resumes the bar where the clock says it is + /// instead of restarting it. + fileprivate var phaseProgressRange: ClosedRange? { + guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { + return nil + } + let start = phaseEndsAt.addingTimeInterval(-Double(phaseDurationMs) / 1000) + guard start < phaseEndsAt else { return nil } + return start...phaseEndsAt + } + + /// Evaluated at render time rather than retired by a sleeping task: a widget + /// that is not being redrawn has no one to show the change to, and the phase + /// transition that follows a deadline arrives as an urgent update anyway. + fileprivate var phaseDeadlineHasLapsed: Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt <= Date() + } + fileprivate var connectionLabel: String { isConnected ? "Connected" : "Disconnected" } @@ -660,17 +620,6 @@ extension MeshMapperActivityAttributes.ContentState { default: return .white } } - - /// Fraction remaining in the current countdown, calculated locally so the - /// Live Activity does not need a state update every second. - fileprivate func phaseRemainingFraction(at date: Date) -> CGFloat? { - guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { - return nil - } - let remaining = phaseEndsAt.timeIntervalSince(date) - guard remaining > 0 else { return 0 } - return CGFloat(min(1, remaining / (Double(phaseDurationMs) / 1000))) - } } extension MeshMapperActivityAttributes.HeardRepeater { @@ -825,6 +774,15 @@ private struct MeshMapperActivityPreview: View { /// left three layouts reviewable only on a wrist. The content views take a /// plain `ContentState`, so `ImageRenderer` can draw them headlessly instead. /// +/// **`ImageRenderer` cannot draw the phase progress bar, and its failure looks +/// like a bug in the bar.** A `ProgressView(timerInterval:)` comes out as a +/// full-width track with a no-entry glyph in the middle, identically at every +/// remaining fraction — `Text(timerInterval:)` beside it snapshots fine, which +/// makes the difference easy to misread as ours. Hosting the same views in a +/// live `UIHostingController` in the simulator draws them correctly and +/// advances them with no state update at all: 0:40 of a 60 s phase at 33 %, +/// 0:23 at 62 %, 0:05 at 92 %. Review the bar that way, not through here. +/// /// DEBUG-only, and reached solely from a launch argument. enum MeshMapperActivityRenderHarness { /// Widths are the real ones: a Live Activity on the lock screen spans about diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 80702e68..49fb015f 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1269,8 +1269,8 @@ struct MapPage: View { .foregroundStyle(.orange) .lineLimit(1) } else if let snapshot { - WatchPhaseBar(snapshot: snapshot) - .frame(height: 15) + WatchPhaseBar(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced) + .frame(height: 15) } } @@ -1701,17 +1701,22 @@ struct MapPage: View { /// A phase-scoped progress animation rather than a one-second render clock. /// /// `Text(timerInterval:)` owns its countdown without invalidating this view. -/// The fill is set once from the absolute deadline and animated to zero by the -/// compositor; one sleeping task wakes at the deadline solely to retire the -/// countdown and dim a claim the phone has not refreshed. +/// The fill is set once from the absolute deadline and driven to zero by a +/// single transform animation; one sleeping task wakes at the deadline solely +/// to retire the countdown and dim a claim the phone has not refreshed. private struct WatchPhaseBar: View { let snapshot: WatchSnapshot + /// Passed in rather than read from the environment so the DEBUG dim + /// overrides on `MapPage` reach it — the whole panel is captured through + /// them, and a bar that kept animating would be the one thing that did not. + let isLuminanceReduced: Bool @State private var remainingFraction: CGFloat @State private var deadlineLapsed: Bool - init(snapshot: WatchSnapshot) { + init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) { self.snapshot = snapshot + self.isLuminanceReduced = isLuminanceReduced let now = Date() _remainingFraction = State( initialValue: CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) @@ -1721,10 +1726,29 @@ private struct WatchPhaseBar: View { ) } + /// Whether the fill should be animated at all, as opposed to snapped to the + /// deadline's current truth and left there. + /// + /// **Reduced luminance stops it.** Always-On updates the screen at roughly + /// one frame a minute, so a drain animation there buys no visible motion and + /// spends energy anyway; Apple's Always-On guidance is to pause animations + /// and drop subsecond work while the app is inactive. The map now stays + /// constructed for up to `dimmedMapHold` seconds past the dim, which is + /// exactly when this bar would otherwise keep animating over a screen nobody + /// can see move. + private var drainsFill: Bool { + #if DEBUG + // A/B harness for the drain's energy cost, under Settings -> Instruments. + if WatchSettings.debugFreezesTimerBar { return false } + #endif + return !isLuminanceReduced + } + private var phaseKey: PhaseAnimationKey { PhaseAnimationKey( endsAtMs: snapshot.phaseEndsAtMs, - durationMs: snapshot.phaseDurationMs + durationMs: snapshot.phaseDurationMs, + drainsFill: drainsFill ) } @@ -1739,37 +1763,50 @@ private struct WatchPhaseBar: View { } var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame(width: geo.size.width * remainingFraction) - } - .overlay { - HStack { - // A missing deadline describes a durable state. A passed one is only - // the phone's last claim, so keep it visible but no longer assert it. - Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 4) - if let range = activeCountdownRange { - // Fixed width because the native timer otherwise reserves room for - // its widest possible value and starves the title. - Text(timerInterval: range, countsDown: true) - .font(.system(size: 11, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .frame(width: 38, alignment: .trailing) - } + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + // **A transform, not a width.** Animating `frame(width:)` re-runs + // SwiftUI's layout for the whole panel on every frame of a drain that + // lasts the entire phase; a leading-anchored scale is one affine + // transform handed to the render server and left alone. + // + // **Scale the mask, not the capsule.** Scaling the capsule itself + // squashes its end caps, so the bar started rounded and finished with + // square ends — visible on the wrist at 15 pt tall, where an earlier + // comment here guessed it would not be. Masking keeps the capsule's + // own geometry untouched, so the leading cap stays round and only the + // trailing edge becomes a flat cut, and the animated property is still + // a single affine transform on a solid shape. + .mask(alignment: .leading) { + Rectangle() + .scaleEffect(x: remainingFraction, y: 1, anchor: .leading) + } + } + .overlay { + HStack { + // A missing deadline describes a durable state. A passed one is only + // the phone's last claim, so keep it visible but no longer assert it. + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if let range = activeCountdownRange { + // Fixed width because the native timer otherwise reserves room for + // its widest possible value and starves the title. + Text(timerInterval: range, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 38, alignment: .trailing) } - // The fill slides under both labels, so a shadow keeps them readable - // against the filled and empty parts of the track. - .shadow(color: .black.opacity(0.7), radius: 1.5) - .padding(.horizontal, 6) } + // The fill slides under both labels, so a shadow keeps them readable + // against the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.horizontal, 6) } .task(id: phaseKey) { await runPhaseAnimation() @@ -1783,34 +1820,82 @@ private struct WatchPhaseBar: View { let lapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false // A replacement phase must start at its true current fraction, not animate - // from the previous phase's endpoint before beginning its own drain. - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { + // from the previous phase's endpoint before beginning its own drain. This + // is also what makes the dim honest: the task re-runs when `drainsFill` + // flips, so the frozen bar is snapped to the deadline's truth rather than + // stranded wherever the cancelled animation happened to be. + // + // **A zero-duration animation, not `disablesAnimations`.** Suppressing the + // transaction leaves the running drain in place, and interrupting a + // transform animation reverts the layer to its model value — measured in + // the simulator as a bar that snapped back to *full* at the dim and stayed + // there, while the countdown beside it ran on. Replacing the animation + // rather than opting out of one is what actually ends it. + // + // **And replacing it requires the value to actually change.** While a drain + // runs, the *model* value here is already 0: `withAnimation` set it there at + // the start and is interpolating only the presentation toward it. So this + // assignment is a no-op precisely when the new truth is also 0 — which is + // the stopped-session case — and the in-flight drain survives untouched. + // Adam saw exactly that on the wrist: the title changed to "Ready" while + // the fill kept draining to the old deadline. The dim never showed it + // because a dim mid-phase has a non-zero truth, so the assignment was a + // real change and did replace the animation. + // + // Hence the nudge: one zero-duration transaction to a value that is + // certainly different, which SwiftUI must take and which abandons the + // animation in flight, then the real one to the truth. Both are + // zero-duration, so neither is visible. + // The nudge must land in its own transaction. SwiftUI batches state changes + // made in one run-loop turn, so a nudge and the real assignment written back + // to back coalesce into a single change that nets to nothing — the very + // no-op being escaped. `Task.yield()` separates them, which is the same + // reason the drain below already yields before it starts. + if remainingFraction == fraction { + withAnimation(.linear(duration: 0)) { + remainingFraction = fraction > 0.5 ? fraction - 0.001 : fraction + 0.001 + } + await Task.yield() + } + withAnimation(.linear(duration: 0)) { remainingFraction = fraction deadlineLapsed = lapsed } + #if DEBUG + // Measure instead of reasoning about it: the stopped-session drain has now + // survived one reasoned fix, so record what this task actually sees. If no + // line appears on a stop, `phaseKey` did not change and the task never + // re-ran; if one appears with `endsAt nil fraction 0.000` while the fill is + // still visibly draining, the animation is outliving the state and no + // assignment here will end it. + WakeLog.note( + String( + format: "bar %@ endsAt %@ duration %@ fraction %.3f lapsed %@ drains %@", + snapshot.phaseTitle, + snapshot.phaseEndsAtMs.map { String(format: "%.0f", $0) } ?? "nil", + snapshot.phaseDurationMs.map(String.init) ?? "nil", + fraction, + lapsed ? "yes" : "no", + drainsFill ? "yes" : "no" + ) + ) + #endif + guard let endsAt = snapshot.phaseEndsAt else { return } let remaining = endsAt.timeIntervalSince(now) guard remaining > 0 else { return } - await Task.yield() - #if DEBUG - // A/B harness for the drain's energy cost. The fill is an animated *layout* - // width, so SwiftUI re-runs layout every frame for the whole phase rather - // than handing a transform to the render server. Freezing it leaves every - // other cost in place — same snapshots, same markers, same timer text — so - // an Instruments trace of the two states isolates this animation alone. - if !WatchSettings.debugFreezesTimerBar { - withAnimation(.linear(duration: remaining)) { remainingFraction = 0 } - } - #else - withAnimation(.linear(duration: remaining)) { - remainingFraction = 0 + if drainsFill { + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } } - #endif + // Retiring the deadline is one wake per phase, not per frame, so it stays + // in place while dimmed. Losing it would leave a countdown asserting a + // claim the phone has stopped confirming. do { try await Task.sleep(for: .seconds(remaining)) } catch { @@ -1826,6 +1911,10 @@ private struct WatchPhaseBar: View { // an otherwise continuous bar every time the label flips. let endsAtMs: Double? let durationMs: Int? + /// Luminance participates only to stop and resume the drain. It belongs in + /// the key so the dim cancels the running animation rather than leaving it + /// interpolating against a screen that updates once a minute. + let drainsFill: Bool } } diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart index e1e85f36..f41dc6fb 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -18,14 +18,36 @@ class LiveActivityService { @visibleForTesting MethodChannel? channel, @visibleForTesting Duration unavailableRetryDelay = const Duration(seconds: 30), + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'), - _unavailableRetryDelay = unavailableRetryDelay; + _unavailableRetryDelay = unavailableRetryDelay, + _minimumNonUrgentInterval = minimumNonUrgentInterval; static const Duration _debounceDelay = Duration(milliseconds: 200); - static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + + /// Floor between two updates that carry no change the wearer is waiting on. + /// + /// Counters, queue depth and the heard-repeater rows churn constantly in a + /// busy session, and none of them is worth an ActivityKit round trip the + /// moment it moves. Everything in [LiveActivitySnapshot.urgencyKey] — a phase + /// change, a ping outcome, connection loss, leaving the zone — bypasses this + /// entirely, so what it throttles is the noise and not the news. + /// + /// **This is a saving on both devices.** A locally generated ActivityKit + /// update is synchronised to a paired Apple Watch for the Smart Stack and + /// counts against *its* Live Activity budget, so the phone's update rate is + /// also a wrist battery cost — on top of the snapshots the native watch app + /// receives over WatchConnectivity, which are a separate channel entirely. + /// + /// Fifteen seconds takes the ceiling from 30 updates a minute to 4. The real + /// reduction is smaller, because the payload fingerprint already suppresses + /// updates that change nothing. + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 15); final MethodChannel _channel; final Duration _unavailableRetryDelay; + final Duration _minimumNonUrgentInterval; Timer? _scheduledUpdate; LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart index cca49bba..a27d2e02 100644 --- a/test/services/live_activity/live_activity_service_test.dart +++ b/test/services/live_activity/live_activity_service_test.dart @@ -4,14 +4,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; import 'package:mesh_mapper/services/live_activity/live_activity_service.dart'; -LiveActivitySnapshot _snapshot() => LiveActivitySnapshot( +LiveActivitySnapshot _snapshot({ + LiveActivityPhase phase = LiveActivityPhase.listeningDiscovery, + int rxCount = 2, +}) => + LiveActivitySnapshot( sessionId: 'session-1', mode: 'Passive', - phase: LiveActivityPhase.listeningDiscovery, + phase: phase, phaseTitle: 'Listening…', isConnected: true, txCount: 0, - rxCount: 2, + rxCount: rxCount, discoveryCount: 1, traceCount: 0, queueSize: 0, @@ -51,4 +55,45 @@ void main() { expect(syncCalls, 2, reason: 'enabling the feature must not require a new session ID'); }); + + test('counter churn waits out the non-urgent interval', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_throttle_test'); + final phases = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + phases.add((call.arguments as Map)['phase'] as String); + return true; + }); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(seconds: 30), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule(_snapshot, immediate: true); + await Future.delayed(const Duration(milliseconds: 20)); + + // A changed RX count is real, but nobody is waiting on it. + service.schedule(() => _snapshot(rxCount: 3), immediate: true); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(phases, ['listening_discovery'], + reason: 'a counter change alone must not reach ActivityKit'); + + // A phase change is news, and news is not throttled. + service.schedule( + () => _snapshot(phase: LiveActivityPhase.waitingDiscovery, rxCount: 3), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(phases, ['listening_discovery', 'waiting_discovery']); + }); } From 50a157f1fd38ce81d5972f6bd08ba67876232063 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 15 Aug 2026 21:56:06 -0700 Subject: [PATCH 232/360] Stop the status panel dragging the camera when it resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam reported the map jumping at the end of the wait and listening timers. A DEBUG probe, gated to a 2.5 s window after a phase change so a walk costs a handful of lines rather than hundreds, named the cause in one session: the panel's height. panelCameraInset feeds .safeAreaPadding(.bottom,), so every height the panel passes through was a camera reassignment. Measured across one session stop and restart, 54 -> 59 -> 40 -> 54 inside three seconds, four applyRegion calls, none animated, while the fix itself moved 0.2 m. Ordinary phase boundaries did not move the panel at all — it held at 54.0 through four of them — so this is a session-lifecycle event rather than a per-cycle one. Zoom decides whether it is visible: at the 40 m span Adam happened to be holding, a 19 pt inset change moves the framing centre 1.6 m and cannot be seen, which is why an hour of stationary watching found nothing. At the 250 m default zoom the same swing is roughly 9 m. An inset change is also an apparent zoom, not merely a pan. applyRegion holds the span constant while the safe-area padding changes the height of the band MapKit fits that span into, and fewer points for the same span is a larger scale. Nothing had said so before, and it is what made two rejected attempts read badly: settling with growth adopted immediately gave a zoom in followed by a zoom out 450 ms later, and settling symmetrically with an eased reframe still showed one step each time the panel populated or emptied. So the camera frames against the panel's high-water mark, which was Adam's suggestion: treat the panel as though it were always at its maximum. A shrink then moves the camera not at all, which is the common case and the one he saw. Growth still waits 450 ms for the height to stop moving and then eases, so a transition costs at most one gentle adjustment. Confirmed on the wrist: "no movement on start or stop." The trade is that while the panel is shorter than its maximum the map is framed as if it were not, so the puck sits above the true centre of the visible band by half the height difference, under about 10 pt at the sizes measured. A puck slightly off centre that never moves beats a centred one that jumps. The mark is per-launch @State: it survives the map subtree's teardown on a wrist raise, so it holds across glances, while a fresh launch re-derives it rather than inheriting a measurement from a content shape that has since changed. The 0.75-of-display clamp still bounds it. The settle is deliberately fail-safe rather than timer-dependent, since watchOS suspends this app and the sleep fires whenever it is next resumed — the trap the dim hold was bitten by twice. A late fire only reframes late, and the map subtree's onAppear adopts the measured height outright, so no wrist raise can find the camera framing against a height the panel has abandoned. The reframe itself is untouched. It is the only thing keeping the camera on the visible band, and this file has two scars from deleting an apparently redundant camera assignment; only which height it follows has changed. --- ios/MeshMapperWatch/MapPage.swift | 254 +++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 6 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 49fb015f..b1761b6e 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -250,9 +250,91 @@ struct MapPage: View { } } + #if DEBUG + /// When the phase last changed, opening a short window in which the camera + /// probes below are allowed to speak. + /// + /// **The volume is the point.** Adam sees the map jump at the end of the wait + /// timer and again at the end of the listening timer, and three things happen + /// at exactly those moments: a new fix can arrive and recentre, the status + /// panel's height can change and recentre through `PanelHeightKey`, or the + /// subtree can be rebuilt. A probe on every camera callback would drown that + /// in follow updates — the mistake this file's wake logging made once + /// already, at roughly 400 file writes an hour against the thing being + /// measured. Logging only around a boundary keeps a walk's worth of evidence + /// down to a handful of lines per cycle. + @State private var phaseBoundaryAt: Date? + + /// The phase as of the last boundary this page logged. + @State private var boundaryPhase: String? + + private static let phaseBoundaryWindow: TimeInterval = 2.5 + + /// SwiftUI does not promise an order between two `onChange` handlers on the + /// same view, so a window that only opened when the phase probe happened to + /// run first would miss exactly the callbacks worth catching. A phase that + /// has not been recorded yet counts as inside it. + private var isNearPhaseBoundary: Bool { + if snapshot?.phase != boundaryPhase { return true } + guard let phaseBoundaryAt else { return false } + return Date().timeIntervalSince(phaseBoundaryAt) < Self.phaseBoundaryWindow + } + #endif + /// The panel's measured height, which is what the camera inset needs and, /// unlike its position, does not change while a page transition is animating. @State private var panelHeight: CGFloat = 0 + + /// The panel height the **camera** frames against, which is not always the + /// height just measured. + /// + /// **The panel thrashes when a session starts or stops, and every value it + /// passes through used to be a camera reframe.** Measured on the wrist: + /// 54 -> 59 -> 40 -> 54 inside three seconds across + /// `skipped -> idle -> starting -> listening_discovery`, four `applyRegion` + /// assignments, none animated. Ordinary phase boundaries did *not* move the + /// panel — it held at 54.0 through four of them — so this is a session-lifecycle + /// event, not a per-cycle one. + /// + /// **The inset changes apparent zoom, not just the centre.** `panelCameraInset` + /// feeds `.safeAreaPadding(.bottom,)`, and `applyRegion` holds the span + /// constant, so a taller panel squeezes the same span into fewer points and + /// the map reads as zooming *in*. Adam on the first asymmetric attempt: *"when + /// I click stop it looks like it zooms in and out slightly"* — a grow adopted + /// at once, then a shrink adopted 450 ms later, each one a visible scale + /// change. Hence one settled adoption for both directions: the wearer should + /// see the map adjust once, not twice. + @State private var cameraPanelHeight: CGFloat = 0 + @State private var panelSettleTask: Task? + + /// The tallest the panel has been this launch, and what the camera actually + /// frames against. + /// + /// **Adam's simplification, and it is the right one: treat the panel as though + /// it were always at its maximum.** A settled, animated reframe was still one + /// visible zoom step per session transition, because a panel that populates to + /// two rows and later loses them is a real change in what MapKit is asked to + /// fit. Framing against the high-water mark means a *shrink* moves the camera + /// not at all — the common case, and the one Adam saw — while growth still + /// gets the settle and the ease below. + /// + /// **The cost, stated plainly:** while the panel is shorter than its maximum + /// the map is framed as if it were not, so the puck sits a few points above + /// the true centre of the visible band. Half the difference in heights, so + /// under ~10 pt at the sizes measured. A puck slightly off centre that never + /// moves reads far better than a centred one that jumps, which is the whole + /// trade being made here. + /// + /// Per launch rather than persisted: `@State` on the page survives the map + /// subtree's teardown on a wrist raise, so the mark holds across glances, and + /// a fresh launch re-derives it rather than inheriting a stale measurement + /// from a type ladder or content shape that has since changed. The 0.75-of- + /// display clamp in `panelCameraInset` still bounds it. + @State private var panelHighWaterMark: CGFloat = 0 + + /// Long enough to swallow a session transition's churn, short enough that the + /// map does not sit visibly mis-framed while it waits. + private static let panelSettleDelay: Duration = .milliseconds(450) @State private var latchedTopSafeAreaInset: CGFloat = 0 @State private var currentTopSafeAreaInset: CGFloat = 0 @State private var bottomSafeAreaInset: CGFloat = 0 @@ -287,15 +369,80 @@ struct MapPage: View { /// height changes with content — one column or two, with or without heard /// rows — and a stale constant would drift the fix off centre precisely when /// the panel grew. + /// Reads `cameraPanelHeight`, never the raw measurement, so a boundary's + /// transient heights never reach MapKit. private var panelCameraInset: CGFloat { - guard panelHeight > 0 else { return 0 } + guard cameraPanelHeight > 0 else { return 0 } let gapBeneath = curvedPanelHorizontalInset == nil ? bottomSafeAreaInset : panelBottomGap // Clamped: an inset approaching the display height would leave MapKit no // band to frame, and nothing about a status panel justifies that. let limit = WKInterfaceDevice.current().screenBounds.height * 0.75 - return min(max(0, panelHeight + gapBeneath), limit) + return min(max(0, cameraPanelHeight + gapBeneath), limit) + } + + /// Lets the measured height stop moving before the camera follows it, in both + /// directions, and animates the single reframe that results. + /// + /// **One adoption, not one per measurement.** In the measured + /// 54 -> 59 -> 40 -> 54 swing the camera is assigned once, at 54 — which is + /// where it started, so the whole session transition costs *no* reframe at + /// all. The intermediate 59 and the 19 pt collapse to 40 never reach MapKit. + /// An earlier attempt adopted growth immediately to guarantee the panel could + /// never cover the puck; that produced two visible scale changes 450 ms apart + /// and the protection was unnecessary at real panel sizes, where the puck sits + /// roughly 85 pt above a panel top near 170. + /// + /// **Animated, because a settled change is a rare and deliberate one.** It + /// happens on a session start or stop, not every cycle, so there is nothing + /// for the 0.25 s ease to compete with — the objection to animating was four + /// overlapping reframes, and there is now at most one. + /// + /// **Deliberately fail-safe rather than timer-dependent.** watchOS suspends + /// this app, so the sleep below fires whenever it is next resumed rather than + /// on schedule — the trap the dim hold was bitten by twice. A late fire only + /// reframes late. Against a fire that never comes, the map subtree's + /// `onAppear` reconciles the two heights outright, so no wrist raise can find + /// the camera framing against a height the panel abandoned. + private func adoptPanelHeightForCamera() { + panelHighWaterMark = max(panelHighWaterMark, panelHeight) + panelSettleTask?.cancel() + panelSettleTask = nil + // A shrink cannot change the mark, so it lands here and stops — no task, no + // reframe, nothing for the wearer to see. That is the point. + guard cameraPanelHeight != panelHighWaterMark else { return } + + panelSettleTask = Task { @MainActor in + try? await Task.sleep(for: Self.panelSettleDelay) + guard !Task.isCancelled, panelHighWaterMark != cameraPanelHeight else { return } + #if DEBUG + WakeLog.note( + String( + format: "panel-settled %.1f -> %.1f (measured %.1f)", + cameraPanelHeight, panelHighWaterMark, panelHeight + ) + ) + #endif + cameraPanelHeight = panelHighWaterMark + panelSettleTask = nil + recenterIfFollowing(animated: true) + } + } + + /// Drops any pending settle and takes the measured height as it stands. + /// + /// The safety net for the suspension case above, and the right behaviour on an + /// appearance regardless: a subtree that is being built has no in-flight + /// framing for a wearer to be watching, so there is nothing to animate and + /// nothing to smooth over. + private func adoptPanelHeightNow() { + panelHighWaterMark = max(panelHighWaterMark, panelHeight) + panelSettleTask?.cancel() + panelSettleTask = nil + guard cameraPanelHeight != panelHighWaterMark else { return } + cameraPanelHeight = panelHighWaterMark + recenterIfFollowing() } /// The placement scales with the estimated corner radius, putting every @@ -909,10 +1056,36 @@ struct MapPage: View { } .onPreferenceChange(PanelHeightKey.self) { height in guard abs(height - panelHeight) > 0.5 else { return } + #if DEBUG + // Panel height is a camera input: `panelCameraInset` feeds + // `.safeAreaPadding(.bottom,)`, so the map reframes when the panel grows + // or shrinks. A ping that hears nothing replaces up to four heard rows + // with one "Nothing heard" line, which is exactly the cycle Adam + // described the jump on. + if isNearPhaseBoundary { + WakeLog.note( + String( + format: "boundary panel %.1f -> %.1f camera %.1f inset %.1f", + panelHeight, height, cameraPanelHeight, panelCameraInset + ) + ) + } + #endif panelHeight = height - recenterIfFollowing() + // Not straight to the camera: see `adoptPanelHeightForCamera`. A + // `boundary panel` line with no `boundary camera` line after it is this + // working — the measurement moved and the map did not. + adoptPanelHeightForCamera() } - .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in + .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { previous, current in + #if DEBUG + if isNearPhaseBoundary { + WakeLog.note( + "boundary fix \(previous ?? "nil") -> \(current ?? "nil") " + + String(format: "moved %.1f m", fixDistanceMoved(previous, current)) + ) + } + #endif recenterIfFollowing() // A first fix is the moment a map that had nothing to anchor to becomes // placeable. Idempotent, so this is a no-op on every later update. @@ -940,10 +1113,20 @@ struct MapPage: View { // -> the camera holds a region but MapKit is not honouring it WakeLog.note("camera-before \(cameraStateDescription)") #endif + // Before recentring, not after: a pending settle from a previous + // appearance would otherwise have this frame framed against a height the + // panel has already left, and `adoptPanelHeightNow` recentres itself when + // it actually changes anything. + adoptPanelHeightNow() recenterIfFollowing() anchorCameraIfNeeded() #if DEBUG WakeLog.note("camera-after \(cameraStateDescription)") + // Close the boundary window this appearance opened. `onChange` does not + // fire for an initial value, so without seeding it here the phase would + // never match and every panel measurement and camera assignment for the + // rest of the session would log as though it sat on a boundary. + boundaryPhase = snapshot?.phase #endif } #if DEBUG @@ -963,9 +1146,49 @@ struct MapPage: View { // building its selected page twice at launch. Nothing here can prevent it; // the cost is one discarded construction against a 0.104 s rebuild. .onDisappear { WakeLog.note("map-subtree-disappeared") } + // Opens the window the other boundary probes report inside of. Placement + // in the chain does not decide whether this runs before them — + // `isNearPhaseBoundary` treats an unrecorded transition as inside the + // window, so a boundary's own first callbacks are covered either way. + .onChange(of: snapshot?.phase) { previous, current in + phaseBoundaryAt = Date() + boundaryPhase = current + WakeLog.note( + "boundary \(previous ?? "nil") -> \(current ?? "nil") " + + String( + format: "panel %.1f camera %.1f inset %.1f ", + panelHeight, cameraPanelHeight, panelCameraInset + ) + + cameraStateDescription + ) + } #endif } + #if DEBUG + /// Distance between two `"lat,lon"` observation keys, for the boundary probe + /// only. The keys are this file's own formatting, so parsing them back is + /// cheaper than adding a second observed value beside them. + private func fixDistanceMoved(_ previous: String?, _ current: String?) -> Double { + guard let from = Self.probeCoordinate(previous), + let to = Self.probeCoordinate(current) + else { return 0 } + return CLLocation(latitude: from.latitude, longitude: from.longitude) + .distance( + from: CLLocation(latitude: to.latitude, longitude: to.longitude) + ) + } + + private static func probeCoordinate(_ key: String?) -> CLLocationCoordinate2D? { + let parts = key?.split(separator: ",") ?? [] + guard parts.count == 2, + let lat = Double(parts[0]), + let lon = Double(parts[1]) + else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + #endif + private var readoutContent: some View { ZStack { // Always-On spends most of a long session with the wrist down, while a @@ -1446,7 +1669,11 @@ struct MapPage: View { // MARK: - Camera - private func recenterIfFollowing(force: Bool = false) { + /// `animated` defaults to following `force` — a tap is the case that wants to + /// be shown to the wearer. The panel settle passes it explicitly, because it + /// is not a forced recentre (Follow off must still mean Follow off) and yet it + /// is a deliberate, isolated change that reads better eased than snapped. + private func recenterIfFollowing(force: Bool = false, animated: Bool? = nil) { // Every early return here leaves `camera` untouched, and an untouched // camera can still be `.automatic` — which fits every annotation and has // been measured rendering 34.4 degrees, about 3,800 km. Log which clause @@ -1469,7 +1696,7 @@ struct MapPage: View { // wearer what their action changed. Automatic follow is different: the fix // coordinate has already changed in this frame, and animating the camera // after it makes the puck wander before the map catches up. - applyRegion(center: fix, animated: force) + applyRegion(center: fix, animated: animated ?? force) } /// Guarantee this native map has a region of ours, whatever `recenterIfFollowing` @@ -1527,6 +1754,21 @@ struct MapPage: View { span: MKCoordinateSpan? = nil, animated: Bool ) { + #if DEBUG + // The other half of the boundary probe: the two handlers above say what + // changed, this says whether the camera actually moved because of it. + if isNearPhaseBoundary { + WakeLog.note( + String( + format: "boundary camera %.6f,%.6f span %.6f animated %@", + center.latitude, + center.longitude, + (span ?? currentSpan).latitudeDelta, + animated ? "yes" : "no" + ) + ) + } + #endif programmaticCenter = center hasAssertedRegion = true settings.noteMapCenter(center) From 675fa119ea97ce7efa69ee4ae15f0d25fc34c247 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:38:22 -0700 Subject: [PATCH 233/360] Let a Smart Stack tap reach the watch app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Live Activity already renders a custom .small family, so it appears in the watch's Smart Stack — but tapping it offered to open the iPhone app, because watchOS only looks for a companion when the watch target declares WKSupportsLiveActivityLaunchAttributeTypes. An empty array covers every activity type we start. watchOS 11.1+; older systems ignore an unrecognised key, so it is safe at our 11.0 floor. Note the spelling: the WWDC session names this "Supports Launch for Live Activity Attribute Types", which several write-ups render as WKSupportsLaunchForLiveActivityAttributeTypes. That key does not exist. Also drops the @State and sleeping Task from MeshMapperCompactTrailing. WidgetKit renders these views out of process and the extension "is not continually active", so a flag retired by Task.sleep is a promise this context cannot keep — the rule MeshMapperPhaseBar states two structs above. Nothing changes behaviourally: activeCountdownRange is evaluated on every render and already returns nil past the deadline. --- .../MeshMapperLiveActivity.swift | 75 ++++++------------- ios/MeshMapperWatch/Info.plist | 8 ++ 2 files changed, 31 insertions(+), 52 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 1a912201..14f37314 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -444,50 +444,33 @@ private struct MeshMapperOutcomeDot: View { } } +/// **No state and no task**, for the reason `MeshMapperPhaseBar` states above: +/// WidgetKit renders these views out of process and the extension "is not +/// continually active, even if the widget is onscreen", so a `@State` flag +/// retired by a sleeping `Task.sleep` is a promise this context cannot keep. +/// +/// Nothing is lost by dropping it. `activeCountdownRange` is evaluated on every +/// render and already returns nil once the deadline passes, so the branch below +/// falls through to the SNR or the outcome dot exactly when it should — using +/// the clock the system re-reads for us rather than a flag we hoped to update. private struct MeshMapperCompactTrailing: View { let state: MeshMapperActivityAttributes.ContentState - @State private var deadlineLapsed: Bool - - init(state: MeshMapperActivityAttributes.ContentState) { - self.state = state - let now = Date() - _deadlineLapsed = State( - initialValue: state.phaseEndsAt.map { $0 <= now } ?? false - ) - } - var body: some View { - Group { - if !deadlineLapsed, state.activeCountdownRange != nil { - MeshMapperCountdown( - state: state, - isActive: true, - font: .caption2.monospacedDigit().weight(.bold) - ) - .frame(minWidth: 28) - } else if let best = state.repeaters.first { - Text(best.snr.formattedSnr) - .font(.caption2.monospacedDigit().weight(.bold)) - .foregroundStyle(best.snrColor.map(Color.init) ?? .primary) - .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") - } else { - MeshMapperOutcomeDot(state: state, diameter: 8) - } - } - .task(id: state.phaseDeadlineKey) { - let now = Date() - deadlineLapsed = state.phaseEndsAt.map { $0 <= now } ?? false - guard let endsAt = state.phaseEndsAt else { return } - let remaining = endsAt.timeIntervalSince(now) - guard remaining > 0 else { return } - do { - try await Task.sleep(for: .seconds(remaining)) - } catch { - return - } - guard !Task.isCancelled else { return } - deadlineLapsed = true + if state.activeCountdownRange != nil { + MeshMapperCountdown( + state: state, + isActive: true, + font: .caption2.monospacedDigit().weight(.bold) + ) + .frame(minWidth: 28) + } else if let best = state.repeaters.first { + Text(best.snr.formattedSnr) + .font(.caption2.monospacedDigit().weight(.bold)) + .foregroundStyle(best.snrColor.map(Color.init) ?? .primary) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + MeshMapperOutcomeDot(state: state, diameter: 8) } } } @@ -512,19 +495,7 @@ private enum MeshMapperPalette { static let accent = Color.accentColor } -private struct MeshMapperPhaseDeadlineKey: Hashable { - let endsAt: Date? - let durationMs: Int? -} - extension MeshMapperActivityAttributes.ContentState { - fileprivate var phaseDeadlineKey: MeshMapperPhaseDeadlineKey { - MeshMapperPhaseDeadlineKey( - endsAt: phaseEndsAt, - durationMs: phaseDurationMs - ) - } - fileprivate var activeCountdownRange: ClosedRange? { let now = Date() guard let phaseEndsAt, phaseEndsAt > now else { return nil } diff --git a/ios/MeshMapperWatch/Info.plist b/ios/MeshMapperWatch/Info.plist index e481d35c..1e2b71bd 100644 --- a/ios/MeshMapperWatch/Info.plist +++ b/ios/MeshMapperWatch/Info.plist @@ -24,6 +24,14 @@ WKCompanionAppBundleIdentifier $(MESHMAPPER_BUNDLE_PREFIX) + + WKSupportsLiveActivityLaunchAttributeTypes + WKRunsIndependentlyOfCompanionApp From afe34cf547378fafd1b90898ed10eefde2caa6bb Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:38:32 -0700 Subject: [PATCH 234/360] Let the wearer's text size reach the reason a control is dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every string on this page is Font.system(size:), which is a fixed point size and ignores Dynamic Type entirely — so the page-level dynamicTypeSize(.small ... .large) clamp bounded nothing. blockedReason and the refusal messages rendered at 10 pt in 45% white at every setting, including the largest accessibility sizes, and they are the one thing on the page that explains why a button will not respond. @ScaledMetric keeps the tuned 10 pt base and makes it grow; removing the inert clamp is what lets it reach accessibility sizes at all. Deliberately unbounded, following NodeListView: fewer rows at a large setting is the correct outcome and this page already scrolls. The buttons keep their fixed labels. The 44 pt targets were checked on hardware and the titles are single-line with tail truncation, so growing them trades a real guarantee for a smaller gain — a narrower deviation, and now a stated one rather than an implicit one. --- ios/MeshMapperWatch/ControlsPage.swift | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 585bd929..37196f59 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -15,6 +15,23 @@ struct ControlsPage: View { private static let minimumTapHeight: CGFloat = 44 private static let compactLabelHeight: CGFloat = 24 + /// The explanatory text scales with the wearer's text-size setting; the + /// controls around it do not. + /// + /// **`Font.system(size:)` is a fixed point size and ignores Dynamic Type + /// entirely**, so the page-level `dynamicTypeSize` clamp that used to sit + /// here bounded nothing — every string on this page was 10 or 13 points at + /// every setting, including the largest accessibility sizes. `@ScaledMetric` + /// keeps the tuned 10 pt base and makes it grow, which is the whole point: + /// `blockedReason` is the one thing that explains a dead button, and at + /// 10 pt in 45 % white it was the least legible text in the app. + /// + /// Deliberately unbounded, following `NodeListView`: fewer rows at a large + /// setting is the correct outcome, and this page already scrolls. The + /// buttons keep their fixed labels so the hardware-checked 44 pt targets and + /// single-line titles survive — a narrower deviation, and a stated one. + @ScaledMetric(relativeTo: .caption2) private var reasonFontSize: CGFloat = 10 + private var controls: WatchControls? { client.snapshot?.controls } private var effectiveStartMode: WatchSettings.DefaultStartMode { @@ -70,9 +87,10 @@ struct ControlsPage: View { // this twice before. .padding(.bottom, 14) } - // These are fixed pieces of control chrome rather than reading content; - // bounding type preserves the large tap targets on the smallest watch. - .dynamicTypeSize(.small ... .large) + // No page-level type clamp. It read as protecting the tap targets, but the + // controls it was protecting use fixed point sizes and were never at risk; + // all it actually did was cap `reasonFontSize` at `.large` and stop the one + // piece of reading content on the page from reaching accessibility sizes. .opacity(client.isStale ? 0.5 : 1.0) .onChange(of: controls?.canManualPing) { _, canManualPing in if canManualPing != true { disarmPing() } @@ -153,7 +171,7 @@ struct ControlsPage: View { private func controlReason(_ reason: String) -> some View { Text(reason) - .font(.system(size: 10, weight: .medium)) + .font(.system(size: reasonFontSize, weight: .medium)) .foregroundStyle(.white.opacity(0.45)) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -165,7 +183,7 @@ struct ControlsPage: View { Image(systemName: "exclamationmark.circle.fill") Text(refusal) } - .font(.system(size: 10, weight: .medium)) + .font(.system(size: reasonFontSize, weight: .medium)) .foregroundStyle(WatchPalette.armed) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) From ac7f64f1fcf6cbc988bcba12a89aa0bdd4e56323 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:38:46 -0700 Subject: [PATCH 235/360] Dim the held map, and hold it for twelve seconds instead of twenty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hold's cost model here was wrong. It said "MapKit stays constructed and rendering for the duration", but watchOS suspends this app about a second into a wrist-down — 7.42 s lost of an 8.20 s drop, already measured in this file. Almost none of that rendering happens. What the hold actually costs is a bright basemap held on an OLED always-on display, and that is paid whether or not the app is scheduled. So the levers are lit pixels and seconds of them, not CPU. Both are pulled. A 45% scrim dims the basemap for the length of the hold — Apple's Always-On guidance asks exactly this of "rich images or large areas of color" — and the hold drops to 12 s, which still keeps the map up until nearly 18 s into a glance given the dim lands at 5.9 s. Twenty spent most of its length showing a map to nobody. The scrim is a value change, never a presence change: an overlay that appeared at the dim would re-identify the subtree beneath it and rebuild all of MapKit at the worst possible moment. The readout countdown drops its coarse "<15 sec" ladder and 60 s timeline for the native timer in both luminance states, prefixed "< " while dimmed. Measured on the wrist, the system refreshes that text at a new phase, again 12-18 s later, then about every 12 s — not once a minute as the old comment assumed — so the figure can read high by up to ~18 s. "<" stays true across that gap because remaining time only decreases, and an exact bound beats every rung of the ladder on phases that are only ever 15-60 s long. baselineOffset(1) on the "<" is chosen by eye over the geometric optimum; see the comment for why 2 measures better and looks worse. --- ios/MeshMapperWatch/MapPage.swift | 186 ++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 61 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index b1761b6e..8456aca6 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -62,7 +62,10 @@ struct MapPage: View { /// phone's geo claim open. `WatchBridgeService` only schedules suppression /// 15 s out, so a glance already costs about 21 s of geo — 5.9 s of map plus /// that delay — and holding the claim for the hold's duration as well would - /// roughly double it, for a map the wearer was looking at either way. + /// add the whole of `dimmedMapHold` on top, for a map the wearer was looking + /// at either way. Stated as a ratio this used to read "roughly double"; the + /// hold is now 12 s rather than 20, so it is nearer half again — the argument + /// is unchanged, only the arithmetic. private var needsMapGeo: Bool { settings.mainPageContent == .map && !isLuminanceReduced && isSelected } @@ -83,12 +86,28 @@ struct MapPage: View { /// position. This can make the map flicker on and disappear while a user is /// looking at it." /// - /// Twenty seconds covers a deliberate glance. It leaves the *radio* alone — - /// `needsMapGeo` above is untouched by the hold — but it is not free: MapKit - /// stays constructed and rendering for the duration, which is the cost the - /// Always-On readout was introduced to avoid. Twenty seconds of it per - /// glance is the trade; a wrist-down's worth would not be. - private static let dimmedMapHold: TimeInterval = 20 + /// **Twelve seconds, down from twenty, and the cost model behind that has + /// been corrected.** The earlier note priced the hold as "MapKit stays + /// constructed and rendering for the duration". It does not: watchOS suspends + /// this app about a second into a wrist-down — 7.42 s lost of an 8.20 s drop, + /// 12.89 of 13.63, 23.01 of 23.99 — so almost none of that rendering happens. + /// What the hold actually buys is a bright basemap held on an OLED always-on + /// display, and that cost is paid whether or not the app is scheduled. + /// + /// The dim lands 5.9 s in, so twelve seconds keeps the map up until nearly + /// 18 s into a glance, which still covers a deliberate one with room to + /// spare. Twenty spent most of its length showing a map to nobody: a typical + /// glance is over long before 26 s. The scrim in `map` cuts what each + /// remaining second costs; this cuts how many of them there are. + /// + /// It leaves the *radio* alone — `needsMapGeo` above is untouched by the + /// hold. + /// + /// The floor is the flicker this exists to prevent. Do not take it below the + /// 5.9 s dim by much, or `showsMap` starts tearing the subtree down inside a + /// glance again, which is the defect the wearer reported: "the map flicker on + /// and disappear while a user is looking at it." + private static let dimmedMapHold: TimeInterval = 12 /// When the display last dimmed, or nil at full luminance. @@ -719,8 +738,11 @@ struct MapPage: View { /// once the hold is expired, so nothing keeps asking. /// /// Always-On throttles these updates to roughly one a minute, so the readout - /// may return somewhat after twenty seconds. Nobody is looking by then — the - /// point is that it returns without needing the wrist. + /// may return well after the twelve seconds `dimmedMapHold` asks for — and + /// the shorter the hold, the larger that overshoot is in proportion. Nobody + /// is looking by then; the point is that it returns without needing the + /// wrist. The scrim on `map` is what bounds the cost of the overshoot, since + /// the basemap is dimmed for every second of it. @ViewBuilder private var dimmedMapHoldTicker: some View { if isLuminanceReduced, !isDimmedMapHoldExpired, let dimmedAt { @@ -1585,6 +1607,30 @@ struct MapPage: View { // top chrome; the overlay remains in the safe content region, keeping its // inset recentre button below the system toolbar controls. .ignoresSafeArea(edges: [.top, .bottom]) + // Dim the basemap for the length of the hold rather than tearing it down. + // + // **The hold's real cost is lit pixels, not CPU.** An earlier comment + // priced it as "MapKit stays constructed and rendering", but watchOS + // suspends this app about a second into a wrist-down, so almost none of + // that rendering happens — what does happen is a bright basemap held on an + // OLED always-on display for the length of the hold, per glance, whether or + // not the app is scheduled to draw it. Apple's Always-On + // guidance names this directly: "if you display rich images or large areas + // of color, consider removing the images and using dimmed colors." + // + // A scrim buys that back without giving up the hold, which exists to stop + // the map flickering out from under a wearer who is still looking at it. + // + // **Value, never presence.** An overlay that appeared at the dim would + // re-identify the subtree beneath it and rebuild all of MapKit at exactly + // the wrong moment — the defect `pageContent`'s background note records. + // This one is always in the tree and only its opacity changes. + .overlay { + Color.black + .opacity(isLuminanceReduced ? 0.45 : 0) + .ignoresSafeArea() + .allowsHitTesting(false) + } } @MapContentBuilder @@ -2212,29 +2258,42 @@ private struct ReadoutPhase: View { ) } + /// **The native timer, in both luminance states. No coarse ladder, no + /// timeline.** + /// + /// This replaced a `TimelineView(.periodic(by: 60))` driving a + /// `"<15 sec"` / `"<30 sec"` / `"<1 min"` ladder, whose premise was that "a + /// seconds figure can be nearly a minute wrong while watchOS throttles + /// Always-On". **On hardware that premise is false**: the seconds in a + /// `Text(timerInterval:)` keep updating periodically under Always On, closely + /// enough to beat every rung of the ladder — which matters here because + /// `autoPingInterval` is 15, 30 or 60 seconds, so every phase lives inside + /// the range the ladder was coarsest about. + /// + /// **The simulator disagrees, and it is wrong.** With the Always On toggle on + /// watchOS 26 it renders the same construct as `9:––`, blanking the seconds + /// outright. That reading briefly justified keeping the ladder for sub-minute + /// phases, on the reasoning that `0:––` says nothing. Adam's wrist says + /// otherwise, and the wrist is the authority — this file has been caught by + /// simulator-only behaviour more than once. + /// + /// So the system does the work, more precisely than we did, and the per-dim + /// structural swap and the minute wake both go away. var body: some View { - Group { - if isLuminanceReduced { - // A seconds figure can be nearly a minute wrong while watchOS throttles - // Always-On. Match that cadence explicitly instead of showing false - // precision, and isolate the minute wake-up to this small subtree. - TimelineView(.periodic(from: .now, by: 60)) { context in - phase(at: context.date, usesCoarseCountdown: true) - } - } else { - phase(at: Date(), usesCoarseCountdown: false) + phase(at: Date()) + .task(id: phaseKey) { + await trackDeadline() } - } - .task(id: phaseKey) { - await trackDeadline() - } } @ViewBuilder - private func phase(at date: Date, usesCoarseCountdown: Bool) -> some View { - let lapsed = usesCoarseCountdown - ? snapshot.phaseEndsAt.map { $0 <= date } ?? false - : deadlineLapsed + private func phase(at date: Date) -> some View { + // The clock as well as the flag. `trackDeadline` is gated to full + // luminance — it must not wake a dimmed app — so under Always On the flag + // never flips, and only this comparison can retire a deadline the phone has + // stopped confirming. Same rule as `isWithinDimmedMapHold`: every render + // that actually happens gets the right answer, whenever it happens. + let lapsed = deadlineLapsed || (snapshot.phaseEndsAt.map { $0 <= date } ?? false) VStack(alignment: .leading, spacing: 2) { // Wrapping is intentional. The longest real phase names need two lines @@ -2245,43 +2304,47 @@ private struct ReadoutPhase: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - if usesCoarseCountdown { - if let countdown = coarseCountdown(at: date) { - countdownText(countdown) - } - } else if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date { - Text(timerInterval: date...endsAt, countsDown: true) + if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date { + // Marks the figure as an upper bound, which is exactly what it is under + // Always On. Measured on a Series 9: the system refreshes this text at + // a new phase, then again 12–18 s later, then about every 12 s after + // that — so between refreshes it reads high by up to that much. + // Remaining time only decreases, so "<" stays true across the whole + // gap, and an exact bound beats the ladder this replaced, which + // discarded up to 14 s of precision to say less. + // + // **Concatenation is safe here, and that was checked rather than + // assumed.** A probe rendering a plain timer, a concatenated one, and + // this exact value-driven form advanced together once the simulator + // left Always On. Worth recording that the check nearly read the other + // way: in Always On the simulator freezes *all* timer text, including a + // plain `Text(timerInterval:)`, so a first look suggested concatenation + // had broken the countdown when nothing was updating at all. + // The lift is not decoration. "<" is centred on the font's math axis, + // which sits below the optical centre of lining digits, so unshifted it + // reads low against "0:23" — Adam saw it immediately, and a @2x render + // measured it at 3.5 px low. + // + // **One point, chosen by eye over the geometric optimum.** The same + // render put the bounding-box centres closest at 2 pt (−0.5 px, against + // +1.5 px at 1 pt), but 2 looked high on the wrist. That is the usual + // gap between geometric and optical centring: a chevron is a wide, + // pointed glyph and the eye weights its extremes, so squaring the boxes + // overshoots. The measurement bounds the answer to 1 or 2; the wrist + // picks between them, and it picked 1 — which is also an even 2 device + // pixels at @2x. + // + // Tied to the 24 pt size above; re-measure if that changes. + (Text(isLuminanceReduced ? "< " : "").baselineOffset(1) + + Text(timerInterval: date...endsAt, countsDown: true)) .font(.system(size: 24, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } } } - private func countdownText(_ value: String) -> some View { - Text(value) - .font(.system(size: 24, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func coarseCountdown(at date: Date) -> String? { - guard let endsAt = snapshot.phaseEndsAt else { return nil } - let remaining = endsAt.timeIntervalSince(date) - guard remaining > 0 else { return nil } - // These are upper bounds, not estimates. Remaining time only decreases, - // so a tight statement rendered just before Always-On stops refreshing - // stays true afterward. Resolve it from this phase's live deadline on each - // render; retaining a previous phase's bound could make that guarantee - // false when a new, longer countdown begins. - if remaining < 15 { return "<15 sec" } - if remaining < 30 { return "<30 sec" } - if remaining < 60 { return "<1 min" } - return "\(Int(ceil(remaining / 60))) min" - } - @MainActor private func trackDeadline() async { let now = Date() @@ -2291,8 +2354,9 @@ private struct ReadoutPhase: View { deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false } - // The minute timeline owns this boundary under Always-On. Sleeping for an - // exact second there would imply precision the display cannot present. + // Never sleeps while dimmed: waking a suspended app to retire a caption + // nobody is reading is exactly the cost this readout exists to avoid. The + // render-time clock check in `phase(at:)` covers that state instead. guard !isLuminanceReduced, let endsAt = snapshot.phaseEndsAt else { return } let remaining = endsAt.timeIntervalSince(now) guard remaining > 0 else { return } From f4ca85fa0e03e71bd63a11e00b7c64ccd1903846 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:39:05 -0700 Subject: [PATCH 236/360] Keep the delivery thread off rendered state, and let a failure be felt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WatchConnectivity documents that "the methods of this protocol are called on a background thread of your app". ingest() already redirected to the main thread; the activation path did not, and reached pendingRefresh, lastSentMapGeoNeeded and mapGeoSuppressionTask — all @Observable-tracked and all read by SwiftUI on the main actor — straight from the delivery queue. On the phone, sessionWatchStateDidChange cleared lastContextData while send(payload:urgent:) read and wrote it on the platform thread, racing the dedupe cache that decides whether the watch gets a snapshot at all. WatchSessionClient is now @MainActor with a nonisolated delegate. Decoding deliberately stays off the main actor: it is the expensive part, it touches no state, and moving a 12 kB JSON parse onto the wrist's main thread would trade one defect for a worse one. Nothing was going to catch this. Checked at -swift-version 6 and -strict-concurrency=complete: both report zero diagnostics against the old code, because WCSessionDelegate is an Objective-C protocol and the isolation never reaches the compiler. The fix does install a guard rail — reintroducing the old pattern now fails to compile even at Swift 5. sessionReachabilityDidChange was implemented on neither side, so the watch's isReachable was a computed read with no observation dependency and never invalidated a view, and the phone's status was only as current as its last activation. It is now stored and event-driven on both. Haptics close the last gap in the cue path: WatchHapticCue has carried kind "success"/"failure"/"notification" all along and play(_:) was never called, so a wrist command that failed after being accepted was announced only on a screen the wearer had usually stopped looking at. It fires from the cue branch of apply(), which is already the one place that establishes a cue is new, fresh and not a redelivery — a view would have to re-derive that gate and would double-fire on the redelivery WatchConnectivity does routinely. Wrist-local preference, default on, under Settings -> Controls. It stays on the wrist for a stronger reason than layout: the only cue the phone sends is the failure of the link itself, so silencing it must not depend on that link. The app now builds both objects in init() so the client and the environment share one WatchSettings — as separate property initialisers they were two instances, and turning haptics off would have silenced only the unread one. Two limits, stated: watchOS ignores play(_:) from a suspended app, so a failure during a long wrist-down is still only seen; and _emitWatchFailure is the sole emission site and hardcodes 'failure', so the other two kinds are unexercised until the phone sends them. --- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 14 +- ios/MeshMapperWatch/SettingsPage.swift | 6 + ios/MeshMapperWatch/WatchSessionClient.swift | 211 ++++++++++++++----- ios/MeshMapperWatch/WatchSettings.swift | 18 ++ ios/Runner/WatchSessionManager.swift | 19 +- 5 files changed, 210 insertions(+), 58 deletions(-) diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 48754fab..5d49818a 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -10,10 +10,20 @@ import SwiftUI /// dump, with wrist-local layout preferences in settings. @main struct MeshMapperWatchApp: App { - @State private var client = WatchSessionClient() - @State private var settings = WatchSettings() + @State private var client: WatchSessionClient + @State private var settings: WatchSettings @Environment(\.scenePhase) private var scenePhase + /// Built here rather than as two independent property initializers so both + /// the environment and the session client hold the *same* `WatchSettings`. + /// A second instance would keep its own copy of the haptic preference and + /// silence only one of them. + init() { + let settings = WatchSettings() + _settings = State(initialValue: settings) + _client = State(initialValue: WatchSessionClient(settings: settings)) + } + var body: some Scene { WindowGroup { ContentView() diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 855ecb09..d8497476 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -65,6 +65,12 @@ struct SettingsPage: View { "When available, show ping option", isOn: $settings.showPingWhenAvailable ) + + // Sits with Controls because the only cue the phone sends is the + // failure of a control the wearer used. It is the wearer's own wrist, + // so the switch stays on the wrist: silencing it must not depend on + // the phone link that just failed. + Toggle("Haptic feedback", isOn: $settings.haptics) } #if DEBUG diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 09377195..8db55a37 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -1,14 +1,36 @@ import Foundation import SwiftUI import WatchConnectivity +import WatchKit /// Receives snapshots from the iPhone and sends intents back. /// /// The watch never decides anything: it renders what the phone sent and asks /// for what the wearer tapped. The phone owns the BLE link, the GPS fix, and /// every guard around transmitting. +/// +/// **Main-actor isolated, because everything it stores is rendered.** +/// WatchConnectivity documents that "the methods of this protocol are called on +/// a background thread of your app", and every stored property here is read by +/// SwiftUI through `@Observable`. The delegate conformance below is therefore +/// `nonisolated` and hops explicitly; see the note on the extension for what +/// deliberately stays off the main actor. @Observable +@MainActor final class WatchSessionClient: NSObject { + /// Read for exactly one decision: whether a cue may play a haptic. + /// + /// Held rather than resolved from `UserDefaults` here so the preference has + /// one owner. Two surfaces resolving the same preference independently is + /// the failure `effectiveStartMode` exists to prevent, and a silenced watch + /// that still buzzes would be the same class of bug. + @ObservationIgnored private let settings: WatchSettings + + init(settings: WatchSettings) { + self.settings = settings + super.init() + } + /// Latest state from the phone, or nil before the first delivery. private(set) var snapshot: WatchSnapshot? @@ -61,7 +83,14 @@ final class WatchSessionClient: NSObject { WCSession.isSupported() ? WCSession.default : nil } - var isReachable: Bool { session?.isReachable ?? false } + /// Mirrors `WCSession.isReachable`, stored rather than computed. + /// + /// Reading `WCSession.default.isReachable` inside a computed property takes + /// no observation dependency, so a view rendering it was never invalidated + /// when reachability changed — it showed whatever happened to be true at the + /// last unrelated render. `sessionReachabilityDidChange` is the callback that + /// turns the change into an event, exactly as its documentation intends. + private(set) var isReachable = false /// A snapshot older than this is shown greyed with an age badge. The phone /// only sends on real change, so silence is normal — this threshold is @@ -93,6 +122,9 @@ final class WatchSessionClient: NSObject { session.delegate = self if session.activationState == .activated { + // An already-activated session fires no activation callback, so this is + // the only chance to seed reachability before the first render. + noteReachability(session.isReachable) ingest(context: session.receivedApplicationContext) requestFullSnapshot() if !mapGeoNeeded { scheduleMapGeoSuppression() } @@ -130,6 +162,9 @@ final class WatchSessionClient: NSObject { return } + // A reachability change during the suspension has no delegate callback to + // deliver, so re-read it alongside the retained context. + noteReachability(session.isReachable) ingest(context: session.receivedApplicationContext) // Read after ingesting: a context retained while the wrist was down may @@ -361,14 +396,54 @@ final class WatchSessionClient: NSObject { return age >= -clockTolerance && age <= cueFreshFor } + /// The main-actor half of the activation callback. + private func completeActivation() { + guard pendingRefresh else { return } + pendingRefresh = false + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } + } + + private func noteReachability(_ reachable: Bool) { + guard isReachable != reachable else { return } + isReachable = reachable + } + + /// Let the wearer feel a cue the phone raised. + /// + /// **This only fires while watchOS is actually running this app.** The system + /// ignores `play(_:)` from a suspended or background app, so a failure that + /// lands during a long wrist-down is still seen rather than felt. The case it + /// answers is the real one: a wearer taps Ping or Start, gets an accepted + /// ack, glances away, and the command fails a moment later with the app + /// still frontmost. Before this the only report was a banner on a screen the + /// wearer had stopped reading. + /// + /// Unknown kinds still play. The wire calls this "a one-shot event the watch + /// should feel", so an older watch meeting a newer phone should err toward + /// the generic notification rather than toward silence. + private func play(_ cue: WatchHapticCue) { + guard settings.haptics else { return } + let type: WKHapticType + switch cue.kind { + case "success": type = .success + case "failure": type = .failure + default: type = .notification + } + WKInterfaceDevice.current().play(type) + } + // MARK: - Ingest - private func ingest(context: [String: Any]) { + /// `nonisolated` on purpose: decoding is the expensive part, it needs no + /// isolation, and WatchConnectivity already hands it to us on a background + /// thread. Only `apply` crosses onto the main actor. + private nonisolated func ingest(context: [String: Any]) { guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return } ingest(data: data) } - private func ingest(data: Data) { + private nonisolated func ingest(data: Data) { guard let decoded = try? MeshMapperWatchWire.decoder.decode(WatchSnapshot.self, from: data) else { return @@ -377,52 +452,59 @@ final class WatchSessionClient: NSObject { // Refuse rather than render a payload whose fields may have changed // meaning — a wrong reading on the wrist is worse than a blank one. guard decoded.isSupportedVersion else { - Task { @MainActor in self.versionMismatch = true } + Task { @MainActor [weak self] in self?.versionMismatch = true } return } - Task { @MainActor in - let arrival = Date() - self.versionMismatch = false - self.snapshot = decoded - self.markSnapshotReceived(at: arrival, producedAt: decoded.updatedAt) - // A queued command has no ack. Any subsequent snapshot proves the phone - // has resumed communicating; a separate timeout covers the case where - // state dedupe means no snapshot follows. - self.clearPendingCommand() - - if self.mapGeoNeeded && !decoded.mapGeoIncluded { - // updateApplicationContext is latest-state-wins, but a context sent - // just before the map reappeared may still win the delivery race. The - // empty arrays are rendered honestly, then a full replacement is - // requested immediately rather than mixing old markers with new state. - let lastRequest = self.lastMapGeoRecoveryRequestAt - if lastRequest == nil || - arrival.timeIntervalSince(lastRequest ?? .distantPast) >= - Self.mapGeoRecoveryThrottle - { - self.lastMapGeoRecoveryRequestAt = arrival - self.sendMapGeoPreference(true, force: true) - } - } + Task { @MainActor [weak self] in self?.apply(decoded) } + } - if let cue = decoded.cue, - Self.isFresh(cue, at: arrival), - self.presentedCueIDs.insert(cue.id).inserted + private func apply(_ decoded: WatchSnapshot) { + let arrival = Date() + versionMismatch = false + snapshot = decoded + markSnapshotReceived(at: arrival, producedAt: decoded.updatedAt) + // A queued command has no ack. Any subsequent snapshot proves the phone + // has resumed communicating; a separate timeout covers the case where + // state dedupe means no snapshot follows. + clearPendingCommand() + + if mapGeoNeeded && !decoded.mapGeoIncluded { + // updateApplicationContext is latest-state-wins, but a context sent + // just before the map reappeared may still win the delivery race. The + // empty arrays are rendered honestly, then a full replacement is + // requested immediately rather than mixing old markers with new state. + let lastRequest = lastMapGeoRecoveryRequestAt + if lastRequest == nil || + arrival.timeIntervalSince(lastRequest ?? .distantPast) >= + Self.mapGeoRecoveryThrottle { - self.presentedCueIDOrder.append(cue.id) - // The phone bounds its command-ID cache for the same reason: a watch - // process can live for days, while only recent redelivery matters. - if self.presentedCueIDOrder.count > 64 { - self.presentedCueIDs.remove(self.presentedCueIDOrder.removeFirst()) - } - if let message = cue.message, !message.isEmpty { - // A cue may be a late wrist-command failure or an unrelated phone - // event; the current wire cannot distinguish them, so attribution - // here would be a guess. Correlating it later requires carrying the - // originating command on `WatchHapticCue` across the wire. - self.setLastRefusal(message, from: nil) - } + lastMapGeoRecoveryRequestAt = arrival + sendMapGeoPreference(true, force: true) + } + } + + if let cue = decoded.cue, + Self.isFresh(cue, at: arrival), + presentedCueIDs.insert(cue.id).inserted + { + presentedCueIDOrder.append(cue.id) + // The phone bounds its command-ID cache for the same reason: a watch + // process can live for days, while only recent redelivery matters. + if presentedCueIDOrder.count > 64 { + presentedCueIDs.remove(presentedCueIDOrder.removeFirst()) + } + // Played here rather than from a view: this branch is already the one + // place that decides a cue is new, fresh, and worth presenting. A view + // would have to re-derive that gate from exported state and would get + // it wrong on redelivery, which WatchConnectivity does routinely. + play(cue) + if let message = cue.message, !message.isEmpty { + // A cue may be a late wrist-command failure or an unrelated phone + // event; the current wire cannot distinguish them, so attribution + // here would be a guess. Correlating it later requires carrying the + // originating command on `WatchHapticCue` across the wire. + setLastRefusal(message, from: nil) } } } @@ -430,28 +512,49 @@ final class WatchSessionClient: NSObject { // MARK: - WCSessionDelegate +/// Every method here is `nonisolated`, because WatchConnectivity documents that +/// "the methods of this protocol are called on a background thread of your app" +/// and asks that any resulting interface change be redirected to the main +/// thread. `ingest` was already doing that for the payload; the activation path +/// was not, and reached `pendingRefresh`, `lastSentMapGeoNeeded` and +/// `mapGeoSuppressionTask` — all `@Observable`-tracked and all read on the main +/// actor — straight from the delivery queue. +/// +/// Decoding stays off the main actor deliberately. It is the only expensive +/// thing that happens here, it touches no state, and pushing a 12 kB JSON parse +/// onto the wrist's main thread on every snapshot would trade one defect for a +/// worse one. extension WatchSessionClient: WCSessionDelegate { - func session( + nonisolated func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error? ) { - if activationState == .activated { - ingest(context: session.receivedApplicationContext) - if pendingRefresh { - pendingRefresh = false - requestFullSnapshot() - if !mapGeoNeeded { scheduleMapGeoSuppression() } - } + guard activationState == .activated else { return } + ingest(context: session.receivedApplicationContext) + let reachable = session.isReachable + Task { @MainActor [weak self] in + self?.noteReachability(reachable) + self?.completeActivation() } } - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + nonisolated func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { ingest(context: applicationContext) } - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { guard let data = message[MeshMapperWatchWire.payloadKey] as? Data else { return } ingest(data: data) } + + /// The documented signal for `isReachable` changing. Without it the property + /// is only ever as current as the last activation. + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor [weak self] in self?.noteReachability(reachable) } + } } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 8ab5e9d6..92c3c35d 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -19,6 +19,7 @@ final class WatchSettings { static let nodeListPlacement = "layout.nodeListPlacement" static let defaultStartMode = "controls.defaultStartMode" static let showPingWhenAvailable = "controls.showPingWhenAvailable" + static let haptics = "controls.haptics" } /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. @@ -130,6 +131,9 @@ final class WatchSettings { showPingWhenAvailable = defaults.object( forKey: Key.showPingWhenAvailable ) as? Bool ?? false + // Absent means on, so `bool(forKey:)` cannot be used: it turns a fresh + // install into a silent one. Same inversion as `follow` above. + haptics = defaults.object(forKey: Key.haptics) as? Bool ?? true } /// Draw a line from the fix to each repeater that answered the last ping. @@ -281,6 +285,20 @@ final class WatchSettings { didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } } + /// Whether a phone-issued cue is allowed to reach the Taptic Engine. + /// + /// Wrist-local like everything else here, and for a stronger reason than + /// layout: the wearer is the person being buzzed, and a phone round-trip + /// would make silencing it depend on the very link that just failed. + /// + /// On by default because the only cue the phone currently sends is a + /// `failure` for a wrist command it had already accepted — rare, never part + /// of a routine ping cycle, and always the direct consequence of something + /// the wearer just tapped. + var haptics: Bool { + didSet { defaults.set(haptics, forKey: Key.haptics) } + } + #if DEBUG /// Freeze the countdown bar's drain, for measuring what that animation costs. /// diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index cca3a7c0..7b4d82a1 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -217,8 +217,23 @@ extension WatchSessionManager: WCSessionDelegate { } func sessionWatchStateDidChange(_ session: WCSession) { - // A newly installed or newly paired watch has no context yet. - lastContextData = nil + // Hopped, not written here. WatchConnectivity delivers this on a background + // thread while `send(payload:urgent:)` reads and writes the same property + // on the platform thread, which is a plain data race on the dedupe cache — + // and losing that race means a snapshot the watch needed gets suppressed as + // "identical" against a value another thread was midway through clearing. + DispatchQueue.main.async { [weak self] in + // A newly installed or newly paired watch has no context yet. + self?.lastContextData = nil + } + publishStatus() + } + + /// The documented signal for reachability changing. Without it, the status + /// this bridge reports to Dart was only as current as the last activation or + /// watch-state change, and the diagnostics screen looked live purely because + /// it polls every five seconds while it is open. + func sessionReachabilityDidChange(_ session: WCSession) { publishStatus() } From 6925b340372b8206b47122da39e3f4266c6d77ff Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:05:18 -0700 Subject: [PATCH 237/360] Walk the sample fix so a follow animation can be reviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample snapshot has always been stationary, which proves the map renders and can never show that it moves well. Motion is the one thing a follow camera exists for, and the simulator has no other way to produce it. `-MeshMapperSampleWalk YES` advances the fix 15 m every 11 s, which is what the wire actually delivers: `WatchWire.minMoveMeters` is 15 and a geo-only update is suppressed below it, so a walking wearer gets one step every eleven seconds rather than a smooth feed. Reproducing the step is the point — a continuous position would hide exactly the jump worth smoothing. The interval is overridable because the watch simulator renders about one frame per second and stops updating entirely a few seconds after launch. Measured: every frame byte-identical for fourteen seconds with `Text(timerInterval:)` frozen, only the system status-bar clock moving. That is the display sleeping rather than the app dimming — the readout never replaced the map, so the dim hold plainly never ran. A capture run therefore has a few seconds of live rendering, and an eleven second cadence puts no steps inside it. --- ios/MeshMapperWatch/SampleSnapshot.swift | 59 ++++++++++++++++++-- ios/MeshMapperWatch/WatchSessionClient.swift | 33 +++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index c2346878..485416d7 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -20,6 +20,13 @@ import Foundation /// - `-MeshMapperForceDimmed YES` renders the reduced-luminance readout. /// - `-MeshMapperForceRefusal ` presents the failure banner; capture /// within six seconds because it deliberately uses the production expiry. +/// - `-MeshMapperSampleWalk YES` advances the sample fix by 15 m every 11 s, +/// which is what the phone's movement gate and walking pace actually deliver. +/// Without it the fix never moves and the follow camera has nothing to +/// animate, so this is the only way to review that motion off a wrist. +/// Pass a number instead of `YES` to set the interval — the simulator stops +/// redrawing a few seconds after launch, so a capture run needs steps far +/// closer together than a walk delivers. See `walkStepInterval`. /// - `-MeshMapperAutoPageTo ` switches pages five seconds after launch. /// The simulator cannot swipe, and page transitions are a real defect /// surface: a sheet raised from the map once survived onto the next page, @@ -49,9 +56,53 @@ enum SampleSnapshot { private static let originLat = 47.6062 private static let originLon = -122.3321 - static func make() -> WatchSnapshot { + /// Metres travelled per emitted step, matching the phone's movement gate. + /// + /// `WatchWire.minMoveMeters` is 15, so a walking wearer's fix advances in 15 m + /// jumps rather than continuously. Reproducing the *step size* is the whole + /// point of this harness: a smaller one would make the follow animation look + /// fine for reasons the real wire does not provide. + static let walkStepMeters: Double = 15 + + /// Seconds between steps. Defaults to 15 m at an ordinary walking pace + /// (~1.4 m/s); `-MeshMapperSampleWalk ` overrides it. + /// + /// **The override exists because the simulator's display stops updating a few + /// seconds after launch** — measured at ~4.5 s, after which every screenshot + /// is the same frame and even a `Text(timerInterval:)` sits frozen. That is + /// the screen sleeping, not the app dimming: the readout never replaces the + /// map, so the dim hold plainly did not run. A capture run therefore has only + /// a few seconds of live rendering, and a real 11 s cadence puts zero steps + /// inside it. Step size is what the follow animation actually has to smooth, + /// so shortening the interval costs the review nothing. + static var walkStepInterval: TimeInterval { + let override = UserDefaults.standard.double(forKey: "MeshMapperSampleWalk") + return override > 0 ? override : 11 + } + + /// Whether to walk at all. The interval override doubles as the switch, so + /// `YES` reads as "walk at the real cadence" and a number as "walk this fast". + static var isWalking: Bool { + UserDefaults.standard.double(forKey: "MeshMapperSampleWalk") > 0 + || UserDefaults.standard.bool(forKey: "MeshMapperSampleWalk") + } + + /// Course held while walking, in degrees clockwise from north. Matches the + /// stationary sample's `headingDeg` so the puck glyph does not swing when the + /// harness is switched on. + private static let walkBearingDeg: Double = 72 + + static func make(stepsWalked: Int = 0) -> WatchSnapshot { let now = Date().timeIntervalSince1970 * 1000 + // Flat-earth offset, which at these distances is exact to well under a + // metre and keeps the harness free of a projection. + let travelled = Double(stepsWalked) * walkStepMeters + let bearing = walkBearingDeg * .pi / 180 + let walkLat = travelled * cos(bearing) / 111_320 + let walkLon = travelled * sin(bearing) + / (111_320 * cos((originLat + 0.006) * .pi / 180)) + let samplePhase: (name: String, title: String, endsAtMs: Double, durationMs: Int) switch UserDefaults.standard.string(forKey: "MeshMapperSamplePhase") { case "wait": @@ -212,9 +263,9 @@ enum SampleSnapshot { : ["passive", "hybrid"], geo: WatchGeo( you: WatchPosition( - lat: originLat + 0.006, - lon: originLon + 0.014, - headingDeg: 72, + lat: originLat + 0.006 + walkLat, + lon: originLon + 0.014 + walkLon, + headingDeg: walkBearingDeg, accuracyM: 8, fixedAtMs: now ), diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 8db55a37..7118cc56 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -114,6 +114,7 @@ final class WatchSessionClient: NSObject { if SampleSnapshot.isEnabled { snapshot = SampleSnapshot.make() markSnapshotReceived() + startSampleWalkIfRequested() return } #endif @@ -409,6 +410,38 @@ final class WatchSessionClient: NSObject { isReachable = reachable } + #if DEBUG + /// Advance the sample fix so the follow camera has something to track. + /// + /// The stationary sample snapshot can show that the map *renders*, never that + /// it moves well — and motion is the one thing the follow animation exists + /// for. This walks the fix at the real wire's step size and cadence rather + /// than smoothly, because a continuous feed would hide exactly the jump being + /// smoothed. + /// + /// Idempotent: `refresh()` runs on every scene activation and must not leave + /// a second walker behind, which would double the pace. + private func startSampleWalkIfRequested() { + guard sampleWalkTask == nil, SampleSnapshot.isWalking else { return } + + let interval = SampleSnapshot.walkStepInterval + sampleWalkTask = Task { @MainActor [weak self] in + var steps = 0 + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(interval)) + guard !Task.isCancelled, let self else { return } + steps += 1 + self.snapshot = SampleSnapshot.make(stepsWalked: steps) + self.markSnapshotReceived() + } + } + } + + /// Not observed: nothing renders the walker, and letting it invalidate views + /// would make the harness a source of the redraws it is meant to measure. + @ObservationIgnored private var sampleWalkTask: Task? + #endif + /// Let the wearer feel a cue the phone raised. /// /// **This only fires while watchOS is actually running this app.** The system From d8f3bbe095c9be9f6a8aa8cbbdc3e64eff684d52 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:05:48 -0700 Subject: [PATCH 238/360] Slide the map to a new fix instead of cutting to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone's movement gate is 15 m, so a walking wearer's fix advances in one step every eleven seconds rather than continuously. At the 250 m default zoom that is about 12 pt of screen arriving at once, which reads as a jump rather than as travel. Easing the camera alone was already rejected in a comment here, and correctly: `fix` comes from a snapshot the transport replaces in its own transaction, so the annotation has moved before this page reacts, and an eased camera would leave the puck sliding off centre and snapping back. `displayedFix` is the fix: the puck renders from view state that changes inside the same `withAnimation` as the camera, so both travel on one curve and the world moves beneath a stationary puck. `applyRegion`'s `animated: false` now means "add no animation of your own" — the follow path inherits the ambient transaction, while first placement, the anchor and the post-zoom correction still cut. Frozen under reduced luminance on the same rule the countdown bar follows. Geo can still arrive inside the dim hold, because `needsMapGeo` goes false at the dim while suppression is only scheduled fifteen seconds out, so the gate is load-bearing rather than decorative. A step over 100 m also cuts: updates are at least two seconds apart, so even a sprint stays well under that, and anything further means delivery was interrupted and sliding would draw a whoosh through ground nobody walked. Verified through the sample walk on a 46 mm simulator: the puck held (207.5, 233.5) px across three steps while a fixed ping translated west-south-west at a consistent 12.3 px per step, the correct direction for the harness's 72 degree bearing, with puck and camera in exact lockstep. That is the property the old comment predicted would break. Whether the motion eases rather than cuts is not verified — the simulator renders about one frame per second, so a 0.7 s animation has no intermediate frames to catch, and stretching it to six seconds failed the same way. Smoothness wants a wrist. --- ios/MeshMapperWatch/MapPage.swift | 113 +++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 8456aca6..548e9ad7 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -185,6 +185,22 @@ struct MapPage: View { @State private var camera: MapCameraPosition = .automatic + /// Where the puck is *drawn*, as opposed to where the phone says it is. + /// + /// **The puck cannot read `fix` directly if the camera is ever animated.** + /// `fix` is derived from `client.snapshot`, which the transport replaces in + /// its own transaction, so by the time this page reacts the annotation has + /// already moved. Easing the camera afterwards would leave the puck sliding + /// away from centre and snapping back — the wander that `recenterIfFollowing` + /// cuts to avoid. Holding the rendered position in `@State` lets both change + /// inside one `withAnimation`, so they travel on the same curve and the puck + /// stays visually still while the world moves beneath it. + /// + /// Scoped to the native map's lifetime like `hasAssertedRegion`: `mapContent` + /// seeds it on appear, so a rebuilt map opens on the truth rather than + /// animating in from wherever the last glance ended. + @State private var displayedFix: CLLocationCoordinate2D? + /// Non-nil once we have driven the camera. Assignment is not proof that /// MapKit rendered the request, but it is the first half of the span /// handshake that keeps `.automatic` from becoming the remembered zoom. @@ -1108,7 +1124,7 @@ struct MapPage: View { ) } #endif - recenterIfFollowing() + trackFix() // A first fix is the moment a map that had nothing to anchor to becomes // placeable. Idempotent, so this is a no-op on every later update. anchorCameraIfNeeded() @@ -1140,6 +1156,10 @@ struct MapPage: View { // panel has already left, and `adoptPanelHeightNow` recentres itself when // it actually changes anything. adoptPanelHeightNow() + // Before the recentre, and never animated: a fresh map must open on the + // truth. Animating in from the previous glance's position would slide the + // puck across the first frame of every wrist raise. + displayedFix = fix recenterIfFollowing() anchorCameraIfNeeded() #if DEBUG @@ -1703,8 +1723,11 @@ struct MapPage: View { @MapContentBuilder private var fixMarker: some MapContent { - if let fix, let you = snapshot?.geo.you { - Annotation("", coordinate: fix) { + // `displayedFix` for the position, `you` for the heading — the first is the + // eased rendering of the second's coordinate, and both must be present for + // the puck to mean anything. + if let displayedFix, let you = snapshot?.geo.you { + Annotation("", coordinate: displayedFix) { // The phone's fix, not the watch's. Rendering it ourselves keeps the // watch free of any location permission. FixPuck(headingDeg: you.headingDeg) @@ -1715,6 +1738,76 @@ struct MapPage: View { // MARK: - Camera + /// How a follow step should move, or `nil` to cut. + /// + /// **Reduced luminance cuts, on the same rule as `WatchPhaseBar.drainsFill`.** + /// Always-On repaints roughly once a minute, so an eased camera there buys no + /// visible motion and spends MapKit render work anyway — and the dim hold + /// deliberately keeps this map on screen for `dimmedMapHold` seconds past the + /// dim, which is exactly the window where it would otherwise be animating for + /// nobody. Geo can still arrive in that window: `needsMapGeo` goes false at + /// the dim but `WatchBridgeService` only schedules suppression 15 s out. + /// + /// 0.7 s is chosen against the update cadence, not by feel. The phone's + /// movement gate is 15 m and its non-urgent throttle is 2 s, so follow steps + /// arrive about 11 s apart at walking pace and never closer than 2 s. A curve + /// comfortably inside that floor means one step finishes before the next is + /// retargeted, so the common case is a clean glide rather than a chain of + /// interrupted animations. + private var followAnimation: Animation? { + guard !isLuminanceReduced else { return nil } + return .easeInOut(duration: 0.7) + } + + /// A step larger than this is treated as a resumed stream, not as travel. + /// + /// Updates are at least 2 s apart, so even a sprint moves well under this + /// between them. Anything further means delivery was interrupted — a + /// suppressed geo claim, a wrist-down, a dropped session — and sliding the + /// map across that gap would draw a whoosh through territory the wearer never + /// walked. Cut instead, which is what this page did for every step before. + private static let followAnimationCeilingM: CLLocationDistance = 100 + + /// Move the puck and the camera together, so a follow step slides the world + /// rather than cutting to it. + /// + /// The 15 m movement gate on the phone means a walking wearer gets one step + /// every ~11 s, and at the 250 m default zoom 15 m is around 12 pt of screen — + /// large enough to read as a jump rather than as motion. + /// + /// Both assignments have to sit in the same `withAnimation` for the reason + /// `displayedFix` exists at all. `recenterIfFollowing` reaches `applyRegion` + /// with `animated: false`, which here means "add no animation of your own" — + /// the assignment inherits this transaction instead. Its own 0.25 s ease is + /// still right for the paths that pass `animated: true`, which are discrete + /// wearer-visible events rather than tracking. + private func trackFix() { + let previous = displayedFix + guard let animation = followAnimation, + let previous, + let fix, + CLLocation(latitude: previous.latitude, longitude: previous.longitude) + .distance(from: CLLocation(latitude: fix.latitude, longitude: fix.longitude)) + <= Self.followAnimationCeilingM + else { + // Covers a cut for every reason: dimmed, a first fix with nothing to + // travel from, geo withdrawn, or a step too large to be travel. Clearing + // to `nil` matters — a stale rendered position left standing would become + // the origin of the next animation. + displayedFix = fix + recenterIfFollowing() + return + } + + withAnimation(animation) { + displayedFix = fix + // Follow off still animates the puck: it slides to its true place across + // a map the wearer chose to hold still, which is the same improvement for + // the case where the camera is deliberately not tracking. + recenterIfFollowing() + } + } + /// `animated` defaults to following `force` — a tap is the case that wants to /// be shown to the wearer. The panel settle passes it explicitly, because it /// is not a forced recentre (Follow off must still mean Follow off) and yet it @@ -1739,9 +1832,10 @@ struct MapPage: View { // corrected after the map reports back. // // A tap is a rare, explicit request to move the map, so animation shows the - // wearer what their action changed. Automatic follow is different: the fix - // coordinate has already changed in this frame, and animating the camera - // after it makes the puck wander before the map catches up. + // wearer what their action changed. Automatic follow passes `false` and is + // eased by its caller instead: `trackFix` wraps this call and the puck's + // position in one transaction, which is the only arrangement where the + // camera can move without the puck wandering away from centre first. applyRegion(center: fix, animated: animated ?? force) } @@ -1832,8 +1926,11 @@ struct MapPage: View { camera = .region(region) } } else { - // First placement and GPS steps cut, so the puck stays visually fixed - // while the world moves beneath it. + // "No animation of our own", not "no animation". First placement, the + // anchor and the post-zoom correction all reach here outside any + // transaction and cut, which is what they want. A follow step reaches it + // from inside `trackFix`'s `withAnimation` and inherits that curve, so + // the camera travels on exactly the same one as the puck. camera = .region(region) } } From 161a96ca18065177c988b286c276ee1707ae451a Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:50:22 -0700 Subject: [PATCH 239/360] Darken the held map to what a powerlog says it costs The dim hold keeps the basemap on an OLED display that is already dimmed, and the scrim over it was set to 0.45 by judgement. A watch sysdiagnose reports content brightness per sample as AvgAPL, so the surfaces can now be priced instead: across the 2026-08-16 walk a bright map sat at APL 135, the scrim plus Always-On took it to 72, and the readout that eventually replaces it sat at 30.5 in the same dim state. The held map was still 2.4x the readout's content brightness, and on an OLED that is the whole cost. Luminance scales as basemap * (1 - opacity), and the measurements agree with the model: 72 at 0.45 implies an unscrimmed 131 against a measured 135. Solving for the readout's 30.5 with a little margin gives 0.70. Estimated saving is around 7% of a walk, across the ~24.5 minutes of dimmed map time the same log measured. The hold's duration is deliberately untouched, because it is not what binds. The same walk found 63 map episodes with a median of 32.7 s against the ~17.9 s this hold is supposed to cap them at: the ticker is throttled to roughly one wake a minute under Always On, so the readout returns when watchOS gets round to it rather than when dimmedMapHold expires. The doc comment on dimmedMapHoldTicker predicted exactly this and warned that a shorter hold makes the overshoot proportionally worse. Shortening the constant would not shorten the episodes. Darkening the pixels they spend does. Wants a wrist: 0.70 is derived from power, not from legibility, and whether the held map still reads at arm's length is not something a powerlog can answer. --- ios/MeshMapperWatch/MapPage.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 548e9ad7..d7e2bb41 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1645,9 +1645,29 @@ struct MapPage: View { // re-identify the subtree beneath it and rebuild all of MapKit at exactly // the wrong moment — the defect `pageContent`'s background note records. // This one is always in the tree and only its opacity changes. + // **0.70, and the number is measured rather than chosen by eye.** A watch + // powerlog gives content brightness per sample as `AvgAPL`, and across a + // 90-minute walk the surfaces separate cleanly: a bright map sits at APL + // 135, this scrim plus Always-On took it to 72, and the readout that + // eventually replaces it sits at 30.5 in the same dim state. So the held + // map was still 2.4x the readout's content brightness — on an OLED, where + // power tracks lit pixels, that is the cost the hold was paying. + // + // Luminance scales as `basemap * (1 - opacity)`, and the numbers agree: 72 + // at 0.45 implies an unscrimmed 131, against a measured 135. Solving for + // the readout's 30.5 plus a little margin gives 0.70. + // + // **This is the lever, and the hold's duration is not.** Measured on the + // same walk: 63 map episodes, median 32.7 s, against the ~17.9 s this hold + // is supposed to cap them at — because the ticker below is throttled to + // roughly one wake a minute under Always On, so the readout returns when + // watchOS gets round to it and not when `dimmedMapHold` expires. Shortening + // the constant would not shorten the episodes; darkening the pixels they + // spend does. See `dimmedMapHoldTicker`, which already predicted the + // overshoot this measures. .overlay { Color.black - .opacity(isLuminanceReduced ? 0.45 : 0) + .opacity(isLuminanceReduced ? 0.70 : 0) .ignoresSafeArea() .allowsHitTesting(false) } From 8e8f421c2f833a656cc1a1c456c157cd52ce0bce Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:50:34 -0700 Subject: [PATCH 240/360] Stop a millisecond of deadline drift bypassing the update throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit urgencyKey carried phaseEndsAt at millisecond resolution, so any recomputation of the deadline made the key differ and forced an immediate ActivityKit update past the 15 s non-urgent throttle, even though the phase had not changed. The phone's powerlog measures what that cost. Across the 2026-08-16 walk, PLApplicationAgent_EventPoint_LiveActivityUpdates recorded 720 updates for this app in 90 minutes — one every 7.5 s, against Apple Fitness's 2 in the same window. 69% of the gaps were under the throttle and 116 of them were under one second, which no real phase change can produce and which the existing 200 ms debounce should already have absorbed. The extension is the second largest energy consumer on the phone and 67.9% of its draw is CPU, so this is update volume rather than per-update work: every @State, Task and withAnimation was already removed from that view. Rounding to seconds cannot lose anything the surface can show. The widget renders the deadline through Text(timerInterval:) and ProgressView(timerInterval:), both of which display whole seconds, while a genuine phase change moves the deadline by seconds at minimum and still bypasses the throttle as intended. It costs the watch too, which is why this is not only a phone fix: ActivityKit updates are mirrored to the paired watch for the Smart Stack and count against its Live Activity budget. Verification wants a fresh sysdiagnose after the next walk — the update count in that table is the number to watch. --- .../live_activity/live_activity_models.dart | 22 +++++++++++++++- .../live_activity_models_test.dart | 25 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index f8b7666b..95a897d0 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -149,7 +149,27 @@ class LiveActivitySnapshot { phase.wireValue, phaseTitle, phaseDetail ?? '', - phaseEndsAt?.millisecondsSinceEpoch ?? 0, + // Seconds, not milliseconds. **At millisecond resolution any + // recomputation of the deadline reads as news** and forces an immediate + // send past the 15 s non-urgent throttle, because the key differs even + // when the phase has not changed. + // + // Measured on the 2026-08-16 walk, from the phone's powerlog + // (`PLApplicationAgent_EventPoint_LiveActivityUpdates`): 720 updates in + // 90 minutes, one every 7.5 s, against Apple Fitness's 2 in the same + // window. 69 % of the gaps were under the throttle, and 116 of them + // were **under one second** — which no real phase change can produce, + // and which the 200 ms debounce should already have absorbed. + // + // A deadline that moves by milliseconds is not something a wearer can + // see: the widget renders it with `Text(timerInterval:)` and + // `ProgressView(timerInterval:)`, both of which show whole seconds. So + // rounding here cannot lose anything the surface could display, while a + // genuine phase change moves the deadline by seconds at minimum and + // still bypasses the throttle exactly as intended. + phaseEndsAt == null + ? 0 + : (phaseEndsAt!.millisecondsSinceEpoch / 1000).round(), phaseDurationMs ?? 0, pingColor?.r ?? '', pingColor?.g ?? '', diff --git a/test/services/live_activity/live_activity_models_test.dart b/test/services/live_activity/live_activity_models_test.dart index cb186dec..e9233cb3 100644 --- a/test/services/live_activity/live_activity_models_test.dart +++ b/test/services/live_activity/live_activity_models_test.dart @@ -89,6 +89,31 @@ void main() { make().urgencyKey, isNot(make(phaseEndsAt: now.add(const Duration(seconds: 15))).urgencyKey), ); + + // A deadline that shifts by milliseconds is not news, and must not buy a + // trip past the non-urgent throttle. The phone's powerlog measured 720 + // Live Activity updates across a 90-minute walk — one every 7.5 s, 116 of + // them less than a second apart — against Apple Fitness's 2 in the same + // window, because this key carried the deadline at millisecond resolution. + // + // Nothing visible is lost: the widget renders the deadline through + // `Text(timerInterval:)` and `ProgressView(timerInterval:)`, which show + // whole seconds. + expect( + make(phaseEndsAt: now).urgencyKey, + make(phaseEndsAt: now.add(const Duration(milliseconds: 120))).urgencyKey, + ); + // Rounding, not truncation, so the boundary is not a cliff either way. + expect( + make(phaseEndsAt: now).urgencyKey, + make(phaseEndsAt: now.subtract(const Duration(milliseconds: 120))) + .urgencyKey, + ); + // A whole second still counts as news. + expect( + make(phaseEndsAt: now).urgencyKey, + isNot(make(phaseEndsAt: now.add(const Duration(seconds: 1))).urgencyKey), + ); }); test('normalizes non-finite repeater values before encoding', () { From 31a95c919eea8950d44cb1d9058a2150e2ab4194 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 18 Aug 2026 16:11:49 -0700 Subject: [PATCH 241/360] Give the Smart Stack card the map panel's rows and none of its bar The .small family was a single-column stack asking for about 140 pt of a card the HIG gives 69.5 pt on a 40 mm watch and 81.5 pt on a 49 mm one, so everything below the second heard row fell off the bottom -- the wearer saw one and a half observations and no counters at all. Take the map panel's rows instead: [type dot] [hex ID] [SNR] in two columns, column-major, with the panel's width solver ported over. That spends the card's spare width on the room the rows needed. The 8 pt glanceability floor is inherited unchanged because it was measured on the wrist; the cap runs two points above the panel's ladder because this card is wider and has no map underneath to stay clear of. The panel's depleting bar does not come with them. Whatever draws a draining track has to be redrawn to drain it, and the display is where this app's marginal energy goes on the watch -- a measured 80 % of it. The countdown beside it already carried the same fact for free, so MeshMapperPhaseBar gains showsProgress and the card is the only caller that turns it off. Both dimensions are measured rather than assumed. This view is rendered by the iOS extension and mirrored to the wrist, so the WKInterfaceDevice bounds that size the equivalent panel are unreachable here: a GeometryReader feeds the row solver the real width, and ViewThatFits spends whatever height the card turns out to have. Rows always; then the heard summary and counters; then mode and zone. A second horizontal ViewThatFits drops the counters whole rather than truncating them to "RX...", and it carries no Spacer on purpose, since an HStack holding one reports that it fits any width it is offered. Raise the repeater cap to four so the grid fills, which also lets the RX slot survive beside the three top-SNR rows the way the map overlay shows them. The lock screen still takes three and the island two. Rendered at both card sizes across all three preview states. --- .../MeshMapperLiveActivity.swift | 380 +++++++++++++++--- ios/Runner/LiveActivityManager.swift | 4 +- lib/providers/app_state_provider.dart | 5 +- 3 files changed, 340 insertions(+), 49 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 14f37314..126d18f4 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -137,51 +137,285 @@ private struct MeshMapperLockScreenContent: View { } } +/// The watch Smart Stack card. +/// +/// **It is a widget-sized card, not a lock-screen banner**: 152x69.5 pt on a +/// 40 mm watch through 191x81.5 pt on a 49 mm one, per the HIG's watchOS +/// widget dimensions. The single-column stack this replaced asked for roughly +/// 140 pt of that, so the wearer saw the phase, the mode and about one and a +/// half heard rows, with the rest — including the counters — cut off the +/// bottom. +/// +/// The fix is the map page's status panel *rows*: `[type dot] [hex ID] [SNR]` +/// in two columns, which turns the card's spare *width* into the room the rows +/// needed and shows four observations in the height that fitted one and a half. +/// +/// **The panel's bar does not come with them.** Whatever draws a depleting +/// track has to be redrawn to deplete it, and the wrist is where this app's +/// energy goes; the countdown carries the same fact for free. +/// +/// **Both dimensions are measured rather than assumed.** This view is rendered +/// by the iOS extension and mirrored to the wrist, so `WKInterfaceDevice` +/// screen bounds — which size the equivalent panel in `MapPage` — are not +/// reachable from here. The `GeometryReader` hands the row solver the card's +/// real width, and `ViewThatFits` spends whatever height the card actually +/// turns out to have: the heard rows are always drawn, and the summary and +/// mode lines are added only on a card with room for them. @available(iOSApplicationExtension 18.0, *) private struct MeshMapperSmallActivityContent: View { let state: MeshMapperActivityAttributes.ContentState let isStale: Bool + /// Matches the map panel's own insets. The card has rounded corners the + /// system draws, so the horizontal value is clearance, not decoration. + private static let horizontalPadding: CGFloat = 8 + private static let verticalPadding: CGFloat = 5 + var body: some View { - VStack(alignment: .leading, spacing: 7) { + GeometryReader { geo in + let layout = MeshMapperHeardLayout( + contentWidth: geo.size.width - 2 * Self.horizontalPadding, + repeaters: state.repeaters + ) + // Ordered richest first: `ViewThatFits` takes the first candidate whose + // ideal height fits the card and falls back to the last, so the rows + // survive on the smallest watch and the extra lines appear on larger + // ones without either being a guess about which watch this is. + ViewThatFits(in: .vertical) { + card(layout, showsSummary: true, showsMode: true) + card(layout, showsSummary: true, showsMode: false) + card(layout, showsSummary: false, showsMode: false) + } + .padding(.horizontal, Self.horizontalPadding) + .padding(.vertical, Self.verticalPadding) + } + .foregroundStyle(.white) + } + + private func card( + _ layout: MeshMapperHeardLayout, + showsSummary: Bool, + showsMode: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 3) { + phaseLine + if showsSummary { + summaryLine + } + MeshMapperHeardGrid(state: state, layout: layout) + // Rows from a finished cycle are still worth showing, but they are not + // a claim about now. The summary line says so in words when the card + // has room for it; this says it in the one currency the rows always + // have space for. + .opacity(state.repeatersAreCurrent ? 1 : 0.55) + if showsMode { + modeLine + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// The phase, or the fact that the phone has stopped confirming it. + /// + /// Staleness replaces the bar rather than sitting beside it, as it does in + /// the map panel: a track still draining against a deadline nobody is + /// refreshing is worse than no track, and on this card the swap also buys + /// back the line the warning would otherwise need. + @ViewBuilder + private var phaseLine: some View { + if isStale { + Label("Update delayed", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.orange) + .lineLimit(1) + } else { MeshMapperPhaseBar( state: state, - titleFont: .caption.weight(.semibold), - countdownFont: .caption2.monospacedDigit().weight(.bold), - countdownWidth: 40 + titleFont: .system(size: 12, weight: .semibold), + countdownFont: .system(size: 11, weight: .bold).monospacedDigit(), + countdownWidth: 38, + showsProgress: false ) + } + } + /// Heard-list currency and total on one line with the session counters. + /// + /// They share a line because the card can afford one of them, not two, and + /// separating "how many were heard" from "how many were sent" costs more + /// vertical space than the distinction is worth at this size. + private var summaryLine: some View { + // **Measured, and deliberately without a `Spacer`.** An `HStack` holding + // one reports that it fits whatever width it is offered, so a candidate + // built that way would always be chosen and the counters would truncate to + // "RX…" on the narrow cards instead of standing down. A plain gap keeps the + // ideal width honest, which is the only thing `ViewThatFits` can act on. + ViewThatFits(in: .horizontal) { + summaryLine(showsMetrics: true) + summaryLine(showsMetrics: false) + } + } + + private func summaryLine(showsMetrics: Bool) -> some View { + HStack(spacing: 5) { + Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD") + .font(.system(size: 9, weight: .bold)) + .tracking(0.5) + if state.totalHeardCount > 0 { + Text("\(state.totalHeardCount)") + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + } + if showsMetrics { + MeshMapperMetrics( + state: state, + compact: true, + font: .system(size: 9, weight: .medium).monospacedDigit(), + showsQueue: false + ) + .padding(.leading, 5) + } + } + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + /// Session mode, latest outcome and zone — the first line to go, because it + /// is the only one whose content the wearer can also read off the app. + private var modeLine: some View { + HStack(spacing: 5) { + Text(state.mode.uppercased()) + .font(.system(size: 9, weight: .bold)) + .tracking(0.5) + MeshMapperOutcomeDot(state: state, diameter: 6) + if let zone = state.zoneCode { + Text(zone) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + } else if !state.isConnected { + Text("Disconnected") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.orange) + } + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +/// The map panel's Top Heard block, rendered from Live Activity state. +/// +/// Rows are `[type dot] [hex ID] [SNR]` and read down the left column before +/// the right, matching `MapPage.statusPanel`. The hex path hash stays primary +/// and unabbreviated: a 1-byte hash is frequently ambiguous, so it — not a +/// resolved name — is the observation's identity, and names are dropped here +/// rather than truncated. +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperHeardGrid: View { + let state: MeshMapperActivityAttributes.ContentState + let layout: MeshMapperHeardLayout + + var body: some View { + if state.repeaters.isEmpty { 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) - } - } + // 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("Nothing heard") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } else if layout.usesTwoColumns { + HStack(alignment: .top, spacing: MeshMapperHeardLayout.columnGap) { + column(Array(rows.prefix(2))) + column(Array(rows.dropFirst(2))) } + } else { + column(rows) + } + } - MeshMapperRepeaterSummary( - state: state, - limit: 2, - showsNames: false, - rowFont: .caption2 - ) + /// Four rows are what two columns hold; one column keeps the two it can show + /// without pushing the lines below it off the card. + private var rows: [MeshMapperActivityAttributes.HeardRepeater] { + Array(state.repeaters.prefix(layout.usesTwoColumns ? 4 : 2)) + } - MeshMapperMetrics(state: state, compact: true) + private func column( + _ repeaters: [MeshMapperActivityAttributes.HeardRepeater] + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(repeaters) { row($0) } } - .padding(12) - .foregroundStyle(.white) + } + + /// Content-sized on purpose, so the row's intrinsic measurement stays equal + /// to the width model that chose the font and the column count. + private func row( + _ repeater: MeshMapperActivityAttributes.HeardRepeater + ) -> some View { + HStack(spacing: 3) { + Circle() + .fill(repeater.typeColor.map(Color.init) ?? state.outcomeColor) + .frame(width: 6, height: 6) + Text(repeater.id.uppercased()) + .font( + .system(size: layout.rowFontSize, weight: .semibold, design: .monospaced) + ) + // No unit and no plus sign, as on the map panel: the column is all SNR, + // and "dB" on every row costs the width a fourth observation needs. + Text(repeater.snr, format: .number.precision(.fractionLength(1))) + .font( + .system(size: layout.rowFontSize, weight: .semibold, design: .monospaced) + ) + .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) + // Fixed width so SNRs line up down a column despite varying digits. + .frame(width: layout.rowFontSize * 3.1, alignment: .trailing) + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .ignore) + .accessibilityLabel(repeater.accessibilitySummary) + } +} + +/// Column count and row font, solved from the card's measured width the way +/// `MapPage` solves them from the watch's screen bounds. +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperHeardLayout { + /// A stable gutter keeps the width equation and the rendered columns in + /// agreement; deriving it from card size would silently spend the room the + /// font calculation just recovered on the smallest watch. + static let columnGap: CGFloat = 8 + + let usesTwoColumns: Bool + let rowFontSize: CGFloat + + init( + contentWidth: CGFloat, + repeaters: [MeshMapperActivityAttributes.HeardRepeater] + ) { + let idChars = CGFloat(repeaters.map(\.id.count).max() ?? 2) + let columnWidth = (contentWidth - Self.columnGap) / 2 - 2 + // The constants mirror the row: 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 put measurement error on + // smaller type rather than on a row that overflows its column. + let unconstrained = (columnWidth - 12) / (0.62 * idChars + 3.1) + + // Eight points is the wrist's measured glanceability floor and is + // inherited unchanged — below it the extra width stops paying for itself + // and one clearer column wins. Truncating the ID is never the trade, + // because the ID is the identity. + usesTwoColumns = unconstrained >= 8 + + // Two points above the app panel's ladder. The card is wider than the + // panel and has no map beneath it to stay out of the way of, so the same + // arm's-length reading distance affords larger type here. + let cap: CGFloat = idChars > 4 ? 12 : idChars > 2 ? 13 : 14 + rowFontSize = usesTwoColumns ? min(max(unconstrained, 8), cap) : cap } } @@ -202,6 +436,12 @@ private struct MeshMapperPhaseBar: View { let titleFont: Font let countdownFont: Font let countdownWidth: CGFloat + /// Off for the watch Smart Stack card. The track costs the wrist a redraw + /// every time the system advances the fill, and on the watch the display is + /// where this app's marginal energy goes — a measured 80 % of it. The + /// countdown beside it was already the authoritative statement of time + /// remaining, so what is given up is the glanceable duplicate, not the fact. + var showsProgress: Bool = true var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -217,7 +457,9 @@ private struct MeshMapperPhaseBar: View { MeshMapperCountdown(state: state, font: countdownFont) .frame(width: countdownWidth, alignment: .trailing) } - MeshMapperPhaseProgress(state: state) + if showsProgress { + MeshMapperPhaseProgress(state: state) + } } } } @@ -360,16 +602,21 @@ private struct MeshMapperRepeaterRow: View { private struct MeshMapperMetrics: View { let state: MeshMapperActivityAttributes.ContentState let compact: Bool + /// Both defaulted so the lock screen and the island keep what they had. The + /// watch card overrides them to share a line with the heard-list summary, + /// where the queue is the first thing worth giving up for the room. + var font: Font = .caption2.monospacedDigit().weight(.medium) + var showsQueue: Bool = true var body: some View { HStack(spacing: compact ? 9 : 11) { Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") Text("RX \(state.rxCount)") - if state.queueSize > 0 { + if showsQueue, state.queueSize > 0 { Label("\(state.queueSize)", systemImage: "arrow.triangle.2.circlepath") } } - .font(.caption2.monospacedDigit().weight(.medium)) + .font(font) .foregroundStyle(.secondary) .lineLimit(1) } @@ -725,10 +972,14 @@ private struct MeshMapperActivityPreview: View { .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)) + // Both ends of the watch range, because the card's row solver and + // its `ViewThatFits` ladder can only be reviewed against real sizes. + ForEach(MeshMapperSmartStackSize.allCases, id: \.self) { size in + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: size.width, height: size.height) + .background(MeshMapperPalette.background) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } } } .padding() @@ -737,6 +988,38 @@ private struct MeshMapperActivityPreview: View { } } +/// The Smart Stack card at both ends of the watch range, from the HIG's +/// watchOS widget dimensions. A card this view is reviewed in must be given a +/// height: overflowing it is the defect being guarded against, and a harness +/// that lets the content pick its own height cannot show that. +enum MeshMapperSmartStackSize: CaseIterable { + /// 40 mm — the smallest card, and the one the ladder must still fit. + case small + /// 49 mm — the largest, where the summary and mode lines earn their place. + case large + + var label: String { + switch self { + case .small: return "40mm" + case .large: return "49mm" + } + } + + var width: CGFloat { + switch self { + case .small: return 152 + case .large: return 191 + } + } + + var height: CGFloat { + switch self { + case .small: return 69.5 + case .large: return 81.5 + } + } +} + /// Renders each layout to a PNG so the design can be reviewed without a live /// session. /// @@ -756,9 +1039,10 @@ private struct MeshMapperActivityPreview: View { /// /// 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. + /// Sizes are the real ones: a Live Activity on the lock screen spans about + /// 360 pt, the island's shared bottom region about 360, and the watch's small + /// family a Smart Stack card — whose height is the constraint, so it is given + /// rather than left to the content to choose. @MainActor static func writeRenders(to directory: URL) -> [String] { let states: [(String, MeshMapperActivityAttributes.ContentState)] = [ @@ -787,13 +1071,15 @@ enum MeshMapperActivityRenderHarness { ].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) + for size in MeshMapperSmartStackSize.allCases { + if let path = render( + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: size.width, height: size.height) + .background(MeshMapperPalette.background), + named: "small-\(size.label)-\(name)", in: directory + ) { + written.append(path) + } } } } diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index 16433871..737f7c9c 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -87,7 +87,9 @@ final class LiveActivityManager { } let repeaterPayloads = payload["repeaters"] as? [Any] ?? [] - let repeaters = repeaterPayloads.prefix(3).compactMap { + // Four, because the watch Smart Stack card draws them two columns by two + // rows. The lock screen and the island still take fewer. + let repeaters = repeaterPayloads.prefix(4).compactMap { rawItem -> MeshMapperActivityAttributes.HeardRepeater? in guard let item = rawItem as? [String: Any], let id = boundedString(item["id"], maxLength: 16), diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 185fbfdd..2c97a4da 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2115,7 +2115,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (totalCount < repeaters.length) totalCount = repeaters.length; return ( - repeaters: repeaters.take(3).toList(growable: false), + // Four so the watch Smart Stack card's two-by-two grid fills, which also + // lets the RX slot survive alongside the three top-SNR rows the way the + // map overlay shows them. Surfaces that want fewer trim their own. + repeaters: repeaters.take(4).toList(growable: false), totalCount: totalCount, isCurrent: hasCurrent, ); From f1d5fdfed9ee7141278e9f2fdf62bc6d3b0df373 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 18 Aug 2026 18:56:35 -0700 Subject: [PATCH 242/360] Give the Smart Stack card one header line and bigger rows The card carried four stacked elements: phase and countdown, a heard summary sharing a line with the session counters, the rows, and a mode line. The wearer read that at 11.9 pt on a 45 mm watch, which is smaller than anything else this app puts in front of them. Collapse it to two. The phase names itself on the left of one header line and the mode, outcome and zone move to the right of that same line, where they cost no line of their own. Everything under it is the rows. The countdown does not come back. Text(timerInterval:) is redrawn by the system every second it is visible, and the display is where this app's marginal energy goes on the wrist -- 80.5 % of the measured +0.094 W. The phase title already says what is happening, and the exact second it ends is not a wrist-glance fact. MeshMapperPhaseBar loses showsProgress with it: this card was the flag's only caller, so the view goes back to the shape it had before that flag existed. Freeing those two lines is not what enlarges the type, and it is worth recording why, because the obvious reading is wrong. The row solver derives its font from width alone, so the recovered height would not have moved it by a point. The actual cost was the SNR column, which reserved a flat 3.1 em -- five characters, enough for "-12.4" -- on every card whatever the data held. Sizing it from the widest value actually present is what pays for the rows: 40 mm 9.0 pt -> 10.1 pt 49 mm 12.5 pt -> 14.0 pt The 45 mm device sits between those two corners at roughly 13.4 pt. The font cap rises to 14/15/16 by ID length now that the header is the only line above the grid. The solver also takes the height left under the header and bounds the font by it. Overflowing the card is the defect this view was rebuilt to fix once already, so the dimension that caused it is a bound rather than an assumption about which watch this is. Two facts leave the card. The session counters shared the summary line and go with it, remaining on the lock screen and the island where there is room. repeatersAreCurrent now reaches the wearer only as the grid's opacity, which is already where it was carried on cards too narrow to spell it out. The preview state gains a fourth repeater. It held three, so the two by two grid the card has to fit was never actually reviewable. Rendered at both card sizes across all three preview states. --- .../MeshMapperLiveActivity.swift | 266 +++++++++--------- 1 file changed, 131 insertions(+), 135 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 126d18f4..7210eb7b 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -141,26 +141,31 @@ private struct MeshMapperLockScreenContent: View { /// /// **It is a widget-sized card, not a lock-screen banner**: 152x69.5 pt on a /// 40 mm watch through 191x81.5 pt on a 49 mm one, per the HIG's watchOS -/// widget dimensions. The single-column stack this replaced asked for roughly -/// 140 pt of that, so the wearer saw the phase, the mode and about one and a -/// half heard rows, with the rest — including the counters — cut off the -/// bottom. +/// widget dimensions. /// -/// The fix is the map page's status panel *rows*: `[type dot] [hex ID] [SNR]` -/// in two columns, which turns the card's spare *width* into the room the rows -/// needed and shows four observations in the height that fitted one and a half. +/// **One header line, then the observations.** The phase names itself on the +/// left; mode, latest outcome and zone sit at the right of that same line, +/// where they cost no line of their own. Everything under it is the map page's +/// Top Heard block — `[type dot] [hex ID] [SNR]` in two columns, column-major, +/// four observations deep. /// -/// **The panel's bar does not come with them.** Whatever draws a depleting -/// track has to be redrawn to deplete it, and the wrist is where this app's -/// energy goes; the countdown carries the same fact for free. +/// **What it deliberately no longer carries.** The countdown is gone: a +/// `Text(timerInterval:)` is redrawn by the system every second it is visible, +/// and the display is where this app's marginal energy goes on the wrist — a +/// measured 80 % of it. The phase title already says what is happening, and +/// the exact second it ends is not a wrist-glance fact. The heard-summary line +/// went with it, and the session counters that shared it. `repeatersAreCurrent` +/// still reaches the wearer as the grid's opacity, which is where that fact was +/// already carried on cards too narrow to spell it out. /// /// **Both dimensions are measured rather than assumed.** This view is rendered /// by the iOS extension and mirrored to the wrist, so `WKInterfaceDevice` /// screen bounds — which size the equivalent panel in `MapPage` — are not /// reachable from here. The `GeometryReader` hands the row solver the card's -/// real width, and `ViewThatFits` spends whatever height the card actually -/// turns out to have: the heard rows are always drawn, and the summary and -/// mode lines are added only on a card with room for them. +/// real width *and* the height left under the header, so the type is as large +/// as the smaller of the two allows. Height alone cannot enlarge it: at these +/// widths the rows are width-bound, which is why freeing two lines buys +/// headroom for the cap rather than bigger type on its own. @available(iOSApplicationExtension 18.0, *) private struct MeshMapperSmallActivityContent: View { let state: MeshMapperActivityAttributes.ContentState @@ -170,21 +175,30 @@ private struct MeshMapperSmallActivityContent: View { /// system draws, so the horizontal value is clearance, not decoration. private static let horizontalPadding: CGFloat = 8 private static let verticalPadding: CGFloat = 5 + private static let headerSpacing: CGFloat = 4 + /// The 13 pt semibold title's line height, pinned rather than measured so + /// the height the row solver is given and the height the header actually + /// takes cannot drift apart. + private static let headerHeight: CGFloat = 16 var body: some View { GeometryReader { geo in let layout = MeshMapperHeardLayout( contentWidth: geo.size.width - 2 * Self.horizontalPadding, + contentHeight: geo.size.height - 2 * Self.verticalPadding + - Self.headerHeight - Self.headerSpacing, repeaters: state.repeaters ) - // Ordered richest first: `ViewThatFits` takes the first candidate whose - // ideal height fits the card and falls back to the last, so the rows - // survive on the smallest watch and the extra lines appear on larger - // ones without either being a guess about which watch this is. - ViewThatFits(in: .vertical) { - card(layout, showsSummary: true, showsMode: true) - card(layout, showsSummary: true, showsMode: false) - card(layout, showsSummary: false, showsMode: false) + VStack(alignment: .leading, spacing: Self.headerSpacing) { + headerLine + .frame(height: Self.headerHeight) + MeshMapperHeardGrid(state: state, layout: layout) + // Rows from a finished cycle are still worth showing, but they are + // not a claim about now. With the summary line gone this opacity is + // the only thing left saying so, which it already was on the cards + // that never had room for the words. + .opacity(state.repeatersAreCurrent ? 1 : 0.55) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .padding(.horizontal, Self.horizontalPadding) .padding(.vertical, Self.verticalPadding) @@ -192,114 +206,57 @@ private struct MeshMapperSmallActivityContent: View { .foregroundStyle(.white) } - private func card( - _ layout: MeshMapperHeardLayout, - showsSummary: Bool, - showsMode: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 3) { - phaseLine - if showsSummary { - summaryLine - } - MeshMapperHeardGrid(state: state, layout: layout) - // Rows from a finished cycle are still worth showing, but they are not - // a claim about now. The summary line says so in words when the card - // has room for it; this says it in the one currency the rows always - // have space for. - .opacity(state.repeatersAreCurrent ? 1 : 0.55) - if showsMode { - modeLine - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - /// The phase, or the fact that the phone has stopped confirming it. + /// Phase on the left, session identity on the right. /// - /// Staleness replaces the bar rather than sitting beside it, as it does in - /// the map panel: a track still draining against a deadline nobody is - /// refreshing is worse than no track, and on this card the swap also buys - /// back the line the warning would otherwise need. - @ViewBuilder - private var phaseLine: some View { - if isStale { - Label("Update delayed", systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.orange) - .lineLimit(1) - } else { - MeshMapperPhaseBar( - state: state, - titleFont: .system(size: 12, weight: .semibold), - countdownFont: .system(size: 11, weight: .bold).monospacedDigit(), - countdownWidth: 38, - showsProgress: false - ) - } - } - - /// Heard-list currency and total on one line with the session counters. - /// - /// They share a line because the card can afford one of them, not two, and - /// separating "how many were heard" from "how many were sent" costs more - /// vertical space than the distinction is worth at this size. - private var summaryLine: some View { - // **Measured, and deliberately without a `Spacer`.** An `HStack` holding - // one reports that it fits whatever width it is offered, so a candidate - // built that way would always be chosen and the counters would truncate to - // "RX…" on the narrow cards instead of standing down. A plain gap keeps the - // ideal width honest, which is the only thing `ViewThatFits` can act on. - ViewThatFits(in: .horizontal) { - summaryLine(showsMetrics: true) - summaryLine(showsMetrics: false) - } - } - - private func summaryLine(showsMetrics: Bool) -> some View { - HStack(spacing: 5) { - Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD") - .font(.system(size: 9, weight: .bold)) - .tracking(0.5) - if state.totalHeardCount > 0 { - Text("\(state.totalHeardCount)") - .font(.system(size: 9, weight: .semibold).monospacedDigit()) - } - if showsMetrics { - MeshMapperMetrics( - state: state, - compact: true, - font: .system(size: 9, weight: .medium).monospacedDigit(), - showsQueue: false - ) - .padding(.leading, 5) + /// Staleness replaces the phase title rather than sitting beside it, as it + /// does in the map panel: a phase that nobody is refreshing should not keep + /// announcing itself, and the swap costs no width the right side needs. + private var headerLine: some View { + HStack(spacing: 4) { + if isStale { + Label("Update delayed", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + .lineLimit(1) + .minimumScaleFactor(0.85) + } else { + Text(state.phaseTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1)) + .lineLimit(1) + // Shrink before truncating. Sharing the line with the mode leaves + // "Listening…" about four points short on a 40 mm card, and a title + // clipped to "Liste…" reads as a different word; a title one point + // smaller reads as the same one. + .minimumScaleFactor(0.8) + .truncationMode(.tail) } + Spacer(minLength: 4) + trailingIdentity } - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) } - /// Session mode, latest outcome and zone — the first line to go, because it - /// is the only one whose content the wearer can also read off the app. - private var modeLine: some View { - HStack(spacing: 5) { + /// Mode, latest outcome and zone — the facts that used to own the bottom + /// line. They are `fixedSize` so the phase title, which can be truncated + /// without becoming wrong, is what gives way on a narrow card. + private var trailingIdentity: some View { + HStack(spacing: 4) { Text(state.mode.uppercased()) - .font(.system(size: 9, weight: .bold)) - .tracking(0.5) + .font(.system(size: 10, weight: .bold)) + .tracking(0.3) MeshMapperOutcomeDot(state: state, diameter: 6) if let zone = state.zoneCode { Text(zone) - .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) } else if !state.isConnected { Text("Disconnected") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.orange) } - Spacer(minLength: 0) } .foregroundStyle(.secondary) .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } @@ -346,7 +303,7 @@ private struct MeshMapperHeardGrid: View { private func column( _ repeaters: [MeshMapperActivityAttributes.HeardRepeater] ) -> some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: MeshMapperHeardLayout.rowGap) { ForEach(repeaters) { row($0) } } } @@ -372,7 +329,7 @@ private struct MeshMapperHeardGrid: View { ) .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) // Fixed width so SNRs line up down a column despite varying digits. - .frame(width: layout.rowFontSize * 3.1, alignment: .trailing) + .frame(width: layout.rowFontSize * layout.snrWidthEm, alignment: .trailing) } .lineLimit(1) .fixedSize() @@ -389,33 +346,71 @@ private struct MeshMapperHeardLayout { /// agreement; deriving it from card size would silently spend the room the /// font calculation just recovered on the smallest watch. static let columnGap: CGFloat = 8 + /// Vertical gap between the two rows of a column. + static let rowGap: CGFloat = 2 + /// Advance of one semibold monospaced character, in ems. Closer to 0.62 than + /// the nominal 0.6, measured off the rendered row. + private static let charAdvance: CGFloat = 0.62 + /// A rendered line box against its nominal point size, for the height bound. + private static let lineHeightRatio: CGFloat = 1.25 let usesTwoColumns: Bool let rowFontSize: CGFloat + /// Width of the aligned SNR column, in ems of `rowFontSize`. Solved from the + /// widest value actually present rather than from the widest one possible: + /// a fixed five-character reservation charged every card for "-12.4" when + /// the data was "2.1", and that reservation was the single largest consumer + /// of the width the type needed. + let snrWidthEm: CGFloat init( contentWidth: CGFloat, + contentHeight: CGFloat, repeaters: [MeshMapperActivityAttributes.HeardRepeater] ) { let idChars = CGFloat(repeaters.map(\.id.count).max() ?? 2) + let snrChars = CGFloat(repeaters.map { Self.snrCharacterCount($0.snr) }.max() ?? 4) + snrWidthEm = Self.charAdvance * snrChars + let columnWidth = (contentWidth - Self.columnGap) / 2 - 2 - // The constants mirror the row: 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 put measurement error on - // smaller type rather than on a row that overflows its column. - let unconstrained = (columnWidth - 12) / (0.62 * idChars + 3.1) + // The constants mirror the row: dot and its two gaps consume 12 pt, and + // both text runs advance at `charAdvance`. Two points of slack put + // measurement error on smaller type rather than on a row that overflows. + let widthBound = + (columnWidth - 12) / (Self.charAdvance * idChars + snrWidthEm) // Eight points is the wrist's measured glanceability floor and is // inherited unchanged — below it the extra width stops paying for itself // and one clearer column wins. Truncating the ID is never the trade, // because the ID is the identity. - usesTwoColumns = unconstrained >= 8 + usesTwoColumns = widthBound >= 8 + + // Two rows deep either way: two columns hold four, one column keeps the + // two it can show. Type that overflows this is the defect the card was + // rebuilt to fix, so the height is a bound and not a preference. + let heightBound = + (contentHeight - Self.rowGap) / 2 / Self.lineHeightRatio + + // Raised now that the header is the only line above the grid. The card is + // wider than the app's panel and has no map beneath it to stay clear of, + // so the same arm's-length reading distance affords larger type here; the + // ladder still steps down as the ID lengthens, because a longer ID spends + // the same width on identity that the size would otherwise take. + let cap: CGFloat = idChars > 4 ? 14 : idChars > 2 ? 15 : 16 + let bound = min(usesTwoColumns ? widthBound : cap, heightBound) + rowFontSize = min(max(bound, 8), cap) + } - // Two points above the app panel's ladder. The card is wider than the - // panel and has no map beneath it to stay out of the way of, so the same - // arm's-length reading distance affords larger type here. - let cap: CGFloat = idChars > 4 ? 12 : idChars > 2 ? 13 : 14 - rowFontSize = usesTwoColumns ? min(max(unconstrained, 8), cap) : cap + /// Characters in an SNR as the row formats it: one fraction digit, its + /// separator, the integer digits, and a minus sign when there is one. The + /// monospaced face advances all of them equally, so counting them is the + /// same measurement the ID gets. + private static func snrCharacterCount( + _ snr: Double + ) -> Int { + let magnitude = abs(snr) + let integerDigits = magnitude < 10 ? 1 : magnitude < 100 ? 2 : 3 + return integerDigits + 2 + (snr < 0 ? 1 : 0) } } @@ -436,12 +431,6 @@ private struct MeshMapperPhaseBar: View { let titleFont: Font let countdownFont: Font let countdownWidth: CGFloat - /// Off for the watch Smart Stack card. The track costs the wrist a redraw - /// every time the system advances the fill, and on the watch the display is - /// where this app's marginal energy goes — a measured 80 % of it. The - /// countdown beside it was already the authoritative statement of time - /// remaining, so what is given up is the glanceable duplicate, not the fact. - var showsProgress: Bool = true var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -457,9 +446,7 @@ private struct MeshMapperPhaseBar: View { MeshMapperCountdown(state: state, font: countdownFont) .frame(width: countdownWidth, alignment: .trailing) } - if showsProgress { - MeshMapperPhaseProgress(state: state) - } + MeshMapperPhaseProgress(state: state) } } } @@ -914,6 +901,15 @@ extension MeshMapperActivityAttributes.ContentState { typeColor: .init(r: 0.64, g: 0.42, b: 0.94), snrColor: .init(r: 0.94, g: 0.30, b: 0.28) ), + // A fourth, because the Smart Stack card draws two columns by two rows + // and a three-row sample cannot show the grid it has to fit. + .init( + id: "5E02", + name: "Beacon Hill", + snr: 6.8, + typeColor: .init(r: 0.20, g: 0.84, b: 0.45), + snrColor: .init(r: 0.34, g: 0.90, b: 0.44) + ), ], totalHeardCount: 5, repeatersAreCurrent: true, From c6f0fc3bb0983ed862cfbb991fa2b3b7ebf168ae Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 19 Aug 2026 08:30:14 -0400 Subject: [PATCH 243/360] Log Custom API host only, quiet disabled path, note offline iata semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the offline Custom API forward: - Log the endpoint host instead of the full URL — debug log files are uploaded with bug reports and custom endpoint URLs may embed tokens. - Skip the "Forward requested/skipped" logging entirely when the Custom API feature is disabled (no per-flush noise for users who never enabled it). - Document that offline batches are stamped with the upload-location zone, not the record-location zone. --- lib/providers/app_state_provider.dart | 4 ++++ lib/services/custom_api_service.dart | 14 ++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c903f009..70ba024d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5242,6 +5242,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Forward a successfully uploaded offline batch to the Custom API endpoint. + /// + /// The whole batch is stamped with the upload-location zone (the fresh + /// /auth at the current GPS position), not where the pings were recorded — + /// the client cannot compute per-ping zones for sessions that span regions. void _forwardOfflineBatchToCustomApi( List> batch, { required int batchNum, diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index 4615921a..a657c86b 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -52,13 +52,11 @@ class CustomApiService { if (pings.isEmpty) return; final prefs = _prefsGetter(); + if (!prefs.customApiEnabled) return; + debugLog( '[CUSTOM API] Forward requested ($source): ${pings.length} item(s)'); - if (!prefs.customApiEnabled) { - debugLog('[CUSTOM API] Forward skipped ($source): Custom API disabled'); - return; - } if (prefs.customApiUrl == null || prefs.customApiUrl!.isEmpty) { debugLog('[CUSTOM API] Forward skipped ($source): URL not configured'); return; @@ -74,8 +72,12 @@ class CustomApiService { : null; final iata = iataOverride ?? iataGetter?.call(); - debugLog( - '[CUSTOM API] Forwarding ($source) → ${prefs.customApiUrl} ' + // Log the endpoint host only — the full URL may embed auth tokens, and + // debug log files are uploaded verbatim with bug reports. + var endpointHost = Uri.tryParse(prefs.customApiUrl!)?.host ?? ''; + if (endpointHost.isEmpty) endpointHost = 'custom endpoint'; + + debugLog('[CUSTOM API] Forwarding ($source) → $endpointHost ' '(contact=${contact ?? "off"}, iata=${iata ?? "none"})'); final enriched = pings.map((ping) { From 82c2110099cdff5467da4682e5789b9dacd72426 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:12:50 -0700 Subject: [PATCH 244/360] Give the watch target the version numbers it ships with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three MeshMapperWatch configurations set MARKETING_VERSION and CURRENT_PROJECT_VERSION from $(FLUTTER_BUILD_NAME)/$(FLUTTER_BUILD_NUMBER), but the target had no baseConfigurationReference and those variables live only in Generated.xcconfig. Both expanded to empty, so CFBundleShortVersionString and CFBundleVersion shipped blank — an empty CFBundleVersion is invalid, and an embedded watch app's version has to match its companion's, so App Store validation rejects the build. Watch.xcconfig includes Generated.xcconfig, mirroring LiveActivity.xcconfig, which is how the extension target already reaches the same values. --- ios/Flutter/Watch.xcconfig | 1 + ios/Runner.xcodeproj/project.pbxproj | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 ios/Flutter/Watch.xcconfig diff --git a/ios/Flutter/Watch.xcconfig b/ios/Flutter/Watch.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Watch.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f34448d8..1a837241 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 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 = ""; }; + A20000000000000000000008 /* Watch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Watch.xcconfig; path = Flutter/Watch.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 = ""; }; @@ -250,6 +251,7 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, A20000000000000000000007 /* LiveActivity.xcconfig */, + A20000000000000000000008 /* Watch.xcconfig */, ); name = Flutter; sourceTree = ""; @@ -821,6 +823,7 @@ }; 705BC20CAD6B880CACD9EA74 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000008 /* Watch.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -1014,6 +1017,7 @@ }; A64C5101607B9C9371C99AB5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000008 /* Watch.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -1128,6 +1132,7 @@ }; FBECC62B604ABFF4A75248F1 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000008 /* Watch.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; From e7c2ce55628adad173d9221c8302f91f5c168590 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:12:56 -0700 Subject: [PATCH 245/360] Format two of the three files the format gate flags dart format only; no behaviour change. watch_wire_contract_test.dart is formatted in the commit that also adds tests to it. The gate still fails on dev's 31 pre-existing dirty files, which stay for their own cleanup. --- lib/services/watch/watch_geo_builder.dart | 1 - test/services/watch/watch_geo_builder_test.dart | 14 ++++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 3bd455c3..5c9c9e90 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -359,5 +359,4 @@ class WatchGeoBuilder { index.removeWhere((prefix, _) => (counts[prefix] ?? 0) > 1); return index; } - } diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 6c657b4c..0fbf20af 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -81,9 +81,11 @@ void main() { // 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)); + final older = List.generate( + 5, (i) => _disc(base.add(Duration(minutes: i)), discovered: true)); - List idsFor(List entries) => WatchGeoBuilder.buildPings( + List idsFor(List entries) => + WatchGeoBuilder.buildPings( txPings: const [], rxPings: const [], discLogEntries: entries, @@ -91,7 +93,8 @@ void main() { ).map((p) => p.id).toList(); final before = idsFor(older.reversed.toList()); - final arrival = _disc(base.add(const Duration(minutes: 9)), discovered: true); + final arrival = + _disc(base.add(const Duration(minutes: 9)), discovered: true); final after = idsFor([arrival, ...older.reversed]); expect(after.length, before.length + 1); @@ -107,7 +110,10 @@ void main() { final pings = WatchGeoBuilder.buildPings( txPings: const [], rxPings: const [], - discLogEntries: [_disc(at, discovered: true), _disc(at, discovered: false)], + discLogEntries: [ + _disc(at, discovered: true), + _disc(at, discovered: false) + ], traceLogEntries: const [], ); expect(pings.map((p) => p.id).toSet().length, 2); From 33bfc14e92896e6a665e01221a3f3de49ff7e12b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:13:15 -0700 Subject: [PATCH 246/360] Stop the wrist starting Passive inside a manual ping's window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveSessionStartAvailability skipped the isPingSending, rxWindowActive and cooldownActive guards for non-transmit modes, on the premise that Passive monitoring does not transmit. It does: _startDiscoveryMode sends a discovery request immediately on start and every 30 s after. So tapping Send Ping on the phone and then Start (Passive) from the watch put a discovery TX on air inside the manual ping's TxTracker window — an interleaving the phone's own Passive button blocks, and one that attributes the response to the wrong request. Those three guards now apply to every mode. Offline mode, passive-only zones and the TX validation reason stay transmit-only, which is also what the phone's Passive button does. --- lib/services/watch/watch_models.dart | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index fc63c51c..2ae40bed 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -516,11 +516,21 @@ 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. +/// Passive monitoring transmits too: `_startDiscoveryMode` puts a discovery +/// request on air the moment it starts and every 30 s after. So a manual ping +/// already in flight, its receive window, and the TX cooldown gate every mode +/// — a discovery TX interleaved into a manual ping's `TxTracker` window +/// attributes the response to the wrong request, which is exactly why the +/// phone's own Passive button is dead during all three. +/// +/// What stays transmit-only is *policy* rather than timing: offline mode, a +/// passive-only zone, and the auto-mode validation reason all describe whether +/// the wearer may originate a TX ping, and the phone's Passive button ignores +/// them too. +/// +/// 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, @@ -549,15 +559,17 @@ SessionStartAvailability resolveSessionStartAvailability({ 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'); + // Timing guards: every mode reaches the radio, so every mode waits. 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 (!isTransmitMode) return (allowed: true, reason: null); + + if (txBlockedByOffline) return (allowed: false, reason: 'Offline Mode'); + if (txNotAllowed) return (allowed: false, reason: 'Passive Only'); if (transmitValidationReason != null) { return (allowed: false, reason: transmitValidationReason); } From eb9ad9338c4dc4cdc32994a8ad65e7894962cf24 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:13:35 -0700 Subject: [PATCH 247/360] Keep an early teardown from skipping the rest of dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _timerListenable is late final and assigned partway through initialize(), but dispose() called removeListener on it unconditionally as its first statement. A provider torn down before that point — a widget test, an early init failure — threw LateInitializationError there and never reached removeObserver or any of the subscription cancels below. Guarded by whether the listener was actually attached, which covers both halves: assigned, and added. The late final timers disposed at the bottom of the method have the same exposure, but that predates this branch and is left for its own change. --- lib/providers/app_state_provider.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2c97a4da..8e2bc246 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -132,6 +132,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveryWindowTimer; // Discovery listening window (Passive Mode) late final Listenable _timerListenable; + /// Whether [_timerListenable] has been assigned *and* had the Live Activity + /// listener attached. + /// + /// `late final` throws on read before assignment, and the assignment happens + /// partway through [initialize]. A provider torn down before that point — + /// a widget test, or an early init failure — would otherwise throw on the + /// first line of [dispose] and skip every cancel below it. + bool _timerListenerAttached = false; + final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); bool _hasEverPairedWatch = false; @@ -2235,6 +2244,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ]); if (_liveActivityService.isSupportedPlatform) { _timerListenable.addListener(_handleLiveActivityTimerChange); + _timerListenerAttached = true; } if (_watchBridge.isSupportedPlatform) { _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged); @@ -9050,7 +9060,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { @override void dispose() { _isDisposed = true; - _timerListenable.removeListener(_handleLiveActivityTimerChange); + if (_timerListenerAttached) { + _timerListenable.removeListener(_handleLiveActivityTimerChange); + } _liveActivityService.dispose(); _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); From 7e59e112cd5693034a5a583631ae0c05c293796d Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:13:46 -0700 Subject: [PATCH 248/360] Decide the Live Activity throttle before paying to build a snapshot _flush built and JSON-encoded a snapshot before any throttle or dedupe check, and the 500 ms countdown listenable drives it at roughly 2 Hz for the length of a session. Each of those calls walked up to four 500-entry ping histories in latestPingColor, then usually discarded the result as a duplicate. Given this app's wardriving overheat history, sustained per-tick work is worth removing. WatchBridgeService._flush already had the shape: a cheap urgency-key preflight and a minimum-interval floor ahead of any construction. This mirrors it. buildPreflightUrgencyKey is shared by the model's own urgencyKey and the provider's cheap builder so the preflight and the eventual decision cannot drift on what counts as news. It omits only the ping colour, which is the expensive field; in practice a ping outcome moves the phase with it, so the key changes anyway. The unavailable retry gets an explicit bypass. It is a delivery failure rather than a lack of news, and the build floor would otherwise reason that nothing urgent moved and never re-ask ActivityKit. --- lib/providers/app_state_provider.dart | 28 ++++++ .../live_activity/live_activity_models.dart | 45 ++++++++- .../live_activity/live_activity_service.dart | 56 +++++++++++ .../live_activity_service_test.dart | 97 ++++++++++++++++++- 4 files changed, 219 insertions(+), 7 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 8e2bc246..7ff38adb 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1229,10 +1229,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!_liveActivityService.isSupportedPlatform) return; _liveActivityService.schedule( _buildLiveActivitySnapshot, + urgencyKeyBuilder: _buildLiveActivityUrgencyKey, immediate: immediate, ); } + /// The urgent half of the next Live Activity payload, without building one. + /// + /// [_buildLiveActivitySnapshot] resolves the latest ping colour by walking + /// the whole TX, RX, discovery and trace history, and the 500 ms countdown + /// listenable asks for a flush twice a second for the length of a session. + /// This reads scalars only, so the service can decide the flush may wait + /// before paying for any of that. Null means no activity should exist, which + /// keeps ending immediate. + String? _buildLiveActivityUrgencyKey() { + final sessionId = _liveActivitySessionId; + if (_isDisposed || !_liveActivitySessionActive || sessionId == null) { + return null; + } + final phase = _resolveLiveActivityPhase(); + return LiveActivitySnapshot.buildPreflightUrgencyKey( + sessionId: sessionId, + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + phaseDurationMs: _phaseDurationMsFor(phase.endsAt), + isConnected: isConnected, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + ); + } + void _scheduleWatchSync({bool immediate = false, bool forceDelivery = false}) { if (_isDisposed || !_watchBridge.canSync) return; _watchBridge.schedule( diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index 95a897d0..d7f90ff6 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -144,6 +144,46 @@ class LiveActivitySnapshot { /// Fields that must bypass the normal update throttle. String get urgencyKey => [ + buildPreflightUrgencyKey( + sessionId: sessionId, + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: phaseDetail, + phaseEndsAt: phaseEndsAt, + phaseDurationMs: phaseDurationMs, + isConnected: isConnected, + zoneCode: zoneCode, + ), + pingColor?.r ?? '', + pingColor?.g ?? '', + pingColor?.b ?? '', + ].join('|'); + + /// Every urgent field that can be resolved without walking a ping history. + /// + /// [LiveActivityService] needs to decide whether a flush may wait *before* it + /// builds a snapshot, and building one resolves the latest ping colour by + /// scanning the full TX, RX, discovery and trace logs. Sharing this formatter + /// with [urgencyKey] keeps the cheap preflight and the eventual decision from + /// drifting on what counts as news. + /// + /// The ping colour is the one urgent field left out, because it is the + /// expensive one. In practice a ping outcome moves the phase with it, so this + /// key changes anyway; a colour that somehow moved alone is held for at most + /// one throttle interval rather than being dropped. + static String buildPreflightUrgencyKey({ + required String sessionId, + required String mode, + required LiveActivityPhase phase, + required String phaseTitle, + required String? phaseDetail, + required DateTime? phaseEndsAt, + required int? phaseDurationMs, + required bool isConnected, + required String? zoneCode, + }) => + [ sessionId, mode, phase.wireValue, @@ -169,11 +209,8 @@ class LiveActivitySnapshot { // still bypasses the throttle exactly as intended. phaseEndsAt == null ? 0 - : (phaseEndsAt!.millisecondsSinceEpoch / 1000).round(), + : (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 index f41dc6fb..3ed6fa42 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -9,6 +9,12 @@ import 'live_activity_models.dart'; typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function(); +/// The urgent half of the next snapshot, resolved without building one. +/// +/// Returns null when no activity should exist, which is how ending stays +/// immediate rather than waiting out the non-urgent floor. +typedef LiveActivityUrgencyKeyBuilder = String? Function(); + /// Owns the Flutter-to-ActivityKit bridge and coalesces noisy app-state changes. /// /// Timer ticks are represented by absolute phase deadlines, allowing SwiftUI to @@ -51,11 +57,24 @@ class LiveActivityService { Timer? _scheduledUpdate; LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; + LiveActivityUrgencyKeyBuilder? _pendingUrgencyKeyBuilder; + DateTime? _lastBuiltAt; String? _lastPayload; String? _lastUrgencyKey; + + /// The preflight key as of the last build, kept apart from [_lastUrgencyKey] + /// because the two are different projections: this one omits the ping colour + /// so it can be produced without walking a ping history. + String? _lastPreflightUrgencyKey; DateTime? _lastSentAt; String? _unavailableSessionId; DateTime? _unavailableRetryAt; + + /// Set when a flush was deferred for a reason unrelated to how much changed — + /// ActivityKit reporting itself unavailable, or refusing a `request` from the + /// background. Those retries must not be swallowed by the build floor below, + /// which would otherwise reason "nothing urgent moved" and never re-ask. + bool _bypassBuildFloor = false; bool _disposed = false; bool _didReconcileNativeState = false; Future _operationChain = Future.value(); @@ -65,11 +84,13 @@ class LiveActivityService { void schedule( LiveActivitySnapshotBuilder snapshotBuilder, { + required LiveActivityUrgencyKeyBuilder urgencyKeyBuilder, bool immediate = false, }) { if (_disposed || !isSupportedPlatform) return; _pendingSnapshotBuilder = snapshotBuilder; + _pendingUrgencyKeyBuilder = urgencyKeyBuilder; _scheduledUpdate?.cancel(); if (immediate) { @@ -94,6 +115,35 @@ class LiveActivityService { if (_disposed || !isSupportedPlatform) return; + // The 500 ms countdown listenable drives this at ~2 Hz for a whole session, + // and building a snapshot resolves the latest ping colour by walking the + // TX, RX, discovery and trace logs — up to 500 entries each — only for the + // result to be discarded as a duplicate. So decide whether this flush can + // wait *before* constructing or encoding anything, from a key that reads + // scalars off the provider. Mirrors `WatchBridgeService._flush`. + final pendingUrgencyKey = _pendingUrgencyKeyBuilder?.call(); + final endAlreadyDelivered = + pendingUrgencyKey == null && _lastPayload == null; + if (endAlreadyDelivered && _didReconcileNativeState) return; + + final predictedUrgent = pendingUrgencyKey != _lastPreflightUrgencyKey; + final lastBuiltAt = _lastBuiltAt; + if (!_bypassBuildFloor && !predictedUrgent && lastBuiltAt != null) { + final elapsed = DateTime.now().difference(lastBuiltAt); + if (elapsed < _minimumNonUrgentInterval) { + // The deferral keeps any outstanding bypass, so a retry that is waiting + // on this floor still gets its turn rather than being dropped. + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return; + } + } + + _bypassBuildFloor = false; + _lastBuiltAt = DateTime.now(); + _lastPreflightUrgencyKey = pendingUrgencyKey; final snapshot = _pendingSnapshotBuilder?.call(); if (snapshot == null) { if (_lastPayload == null && _didReconcileNativeState) return; @@ -108,6 +158,7 @@ class LiveActivityService { // 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. + _bypassBuildFloor = true; _scheduledUpdate = Timer(retryAt.difference(now), _enqueueFlush); return; } @@ -147,6 +198,7 @@ class LiveActivityService { if (result == false) { _unavailableSessionId = snapshot.sessionId; _unavailableRetryAt = DateTime.now().add(_unavailableRetryDelay); + _bypassBuildFloor = true; _scheduledUpdate = Timer(_unavailableRetryDelay, _enqueueFlush); debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); return; @@ -187,9 +239,12 @@ class LiveActivityService { _didReconcileNativeState = true; _lastPayload = null; _lastUrgencyKey = null; + _lastPreflightUrgencyKey = null; _lastSentAt = null; + _lastBuiltAt = null; _unavailableSessionId = null; _unavailableRetryAt = null; + _bypassBuildFloor = false; } } @@ -198,6 +253,7 @@ class LiveActivityService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; _pendingSnapshotBuilder = null; + _pendingUrgencyKeyBuilder = null; _unavailableRetryAt = null; } } diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart index a27d2e02..b27470c0 100644 --- a/test/services/live_activity/live_activity_service_test.dart +++ b/test/services/live_activity/live_activity_service_test.dart @@ -25,6 +25,21 @@ LiveActivitySnapshot _snapshot({ updatedAt: DateTime.utc(2026, 8, 13), ); +/// The cheap projection the service preflights on, derived from a built +/// snapshot so a test reads the same way the provider's builder does. +String _preflightKey(LiveActivitySnapshot snapshot) => + LiveActivitySnapshot.buildPreflightUrgencyKey( + sessionId: snapshot.sessionId, + mode: snapshot.mode, + phase: snapshot.phase, + phaseTitle: snapshot.phaseTitle, + phaseDetail: snapshot.phaseDetail, + phaseEndsAt: snapshot.phaseEndsAt, + phaseDurationMs: snapshot.phaseDurationMs, + isConnected: snapshot.isConnected, + zoneCode: snapshot.zoneCode, + ); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -49,7 +64,11 @@ void main() { .setMockMethodCallHandler(channel, null); }); - service.schedule(_snapshot, immediate: true); + service.schedule( + _snapshot, + urgencyKeyBuilder: () => _preflightKey(_snapshot()), + immediate: true, + ); await Future.delayed(const Duration(milliseconds: 80)); expect(syncCalls, 2, @@ -77,11 +96,19 @@ void main() { .setMockMethodCallHandler(channel, null); }); - service.schedule(_snapshot, immediate: true); + service.schedule( + _snapshot, + urgencyKeyBuilder: () => _preflightKey(_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); + service.schedule( + () => _snapshot(rxCount: 3), + urgencyKeyBuilder: () => _preflightKey(_snapshot(rxCount: 3)), + immediate: true, + ); await Future.delayed(const Duration(milliseconds: 20)); expect(phases, ['listening_discovery'], @@ -90,10 +117,74 @@ void main() { // A phase change is news, and news is not throttled. service.schedule( () => _snapshot(phase: LiveActivityPhase.waitingDiscovery, rxCount: 3), + urgencyKeyBuilder: () => + _preflightKey(_snapshot(phase: LiveActivityPhase.waitingDiscovery)), immediate: true, ); await Future.delayed(const Duration(milliseconds: 20)); expect(phases, ['listening_discovery', 'waiting_discovery']); }); + + test('a throttled flush costs nothing to build', () async { + // The 500 ms countdown listenable asks for a flush twice a second for the + // length of a session, and building a snapshot walks the whole TX, RX, + // discovery and trace history to resolve the latest ping colour. The + // throttle has to be decided before any of that, not after. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_preflight_test'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => true); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(seconds: 30), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + var builds = 0; + final steadyKey = _preflightKey(_snapshot()); + LiveActivitySnapshot? countedBuild() { + builds++; + return _snapshot(rxCount: builds); + } + + service.schedule( + countedBuild, + urgencyKeyBuilder: () => steadyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(builds, 1); + + for (var tick = 0; tick < 5; tick++) { + service.schedule( + countedBuild, + urgencyKeyBuilder: () => steadyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 5)); + } + + expect(builds, 1, + reason: 'a tick that changes nothing urgent must not build a snapshot'); + + // News still bypasses the floor, and still pays to build. + final news = _snapshot(phase: LiveActivityPhase.waitingDiscovery); + service.schedule( + () { + builds++; + return news; + }, + urgencyKeyBuilder: () => _preflightKey(news), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(builds, 2, reason: 'a phase change is news and is never deferred'); + }); } From 2b9b9bb09d1293d0b0ac0f4257988c2b2b74bf73 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:14:23 -0700 Subject: [PATCH 249/360] Let a late stop off the transmit-age window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued stopSession delivered outside the 30 s admission window — the phone out of range, the watch suspended mid-transfer — was refused with "Took too long to reach iPhone", leaving the radio transmitting after the wearer had asked it to stop. That window exists so a late command cannot put a transmit on air from the wrong place. Stopping takes the radio off air and can mis-attribute nothing, so it is exempt for the same reason requestSnapshot already is. --- lib/services/watch/watch_bridge_service.dart | 12 +++++- .../watch/watch_wire_contract_test.dart | 39 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 4d0ed6d6..6bcfec7d 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -219,7 +219,17 @@ class WatchBridgeService { _lastMapGeoClaimIssuedAtMs = issuedAtMs; } } - if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { + // Stopping is exempt for the same reason as requestSnapshot: the window + // exists so a late command cannot put a transmit on air from the wrong + // place, and a stop takes the radio *off* air. Refusing a queued stop that + // was slow to arrive — the phone out of range, the watch suspended mid + // transfer — leaves the session transmitting after the wearer asked it to + // end, which is the failure the window is supposed to prevent. + const ageExemptKinds = { + WatchCommandKind.requestSnapshot, + WatchCommandKind.stopSession, + }; + if (!ageExemptKinds.contains(kind) && 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 diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 93a87fcc..ada49735 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -1009,7 +1009,9 @@ void main() { // 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']; + // Stop is absent on purpose: it takes the radio off air, so a late one + // is always the safe direction and is exempt below. + const transmitting = ['startSession', 'manualPing']; double nowMs() => DateTime.now().millisecondsSinceEpoch.toDouble(); @@ -1103,6 +1105,41 @@ void main() { reason: 'asking for state transmits nothing and cannot go stale'); expect(handled, [WatchCommandKind.requestSnapshot]); }); + + test('an aged stopSession is exempt, because stopping is the safe way', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'aged-stop', + 'stopSession', + issuedAtMs: nowMs() - const Duration(minutes: 5).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'refusing a late stop leaves the radio transmitting'); + expect(handled, [WatchCommandKind.stopSession]); + }); + + test('a stopSession from a fast watch clock is exempt too', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'future-stop', + 'stopSession', + issuedAtMs: nowMs() + const Duration(minutes: 10).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'skew cannot make stopping unsafe; it can only delay it'); + expect(handled, [WatchCommandKind.stopSession]); + }); }); // requestSnapshot carries two intents down one wire. Only a genuine plea From ffb0beeae15cb9c9f70a10cfb593206a56bbd2e3 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:14:30 -0700 Subject: [PATCH 250/360] Pin the Passive start guards in the wire contract tests Splits the old blanket assertion in two: Passive still ignores the transmit policy blockers, and now waits out the manual-ping, RX-window and cooldown timing guards it shares with every other mode. --- .../watch/watch_wire_contract_test.dart | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index ada49735..b188bc26 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -494,13 +494,10 @@ void main() { } }); - test('Passive ignores blockers that only constrain a transmitting start', + test('Passive ignores the policy blockers that only constrain a TX start', () { final passive = startAvailability( isTransmitMode: false, - cooldownActive: true, - isPingSending: true, - rxWindowActive: true, txBlockedByOffline: true, txNotAllowed: true, transmitValidationReason: 'Waiting for GPS lock', @@ -508,6 +505,26 @@ void main() { expect(passive, (allowed: true, reason: null)); }); + + test('Passive still waits out a manual ping, because Passive transmits', + () { + // Passive Mode starts by sending a discovery request and repeats it every + // 30 s. Letting the wrist start one inside a manual ping's TX window puts + // a second transmit on air where the phone's own Passive button is dead. + final blocked = { + 'Cooling down': + startAvailability(isTransmitMode: false, cooldownActive: true), + 'Ping in progress': + startAvailability(isTransmitMode: false, isPingSending: true), + 'Listening for ping response': + startAvailability(isTransmitMode: false, rxWindowActive: true), + }; + + for (final entry in blocked.entries) { + expect(entry.value.allowed, isFalse, reason: entry.key); + expect(entry.value.reason, entry.key); + } + }); }); group('bridge command handling', () { From 8f6680213844813189839dfdc4756db3d217ad4b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:15:49 -0700 Subject: [PATCH 251/360] Fix three ways the native watch bridge stayed stuck or spent the radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy sendMessage reply forwarded Dart null as NSNull. relayCommand passed the reply dict through unmodified, and for every accepted command that dict is {'reason': null}, which the standard codec decodes to NSNull — not a property-list type, so WCSession's replyHandler failed on exactly the success case of the compatibility path it exists to preserve. Nil-valued keys are now stripped; an absent key reads the same as a null one on the watch. A failed activation was permanent. Nothing retried it: Dart's canSync stays false so send() — the only other activateIfNeeded caller — is never reached, and the diagnostics screen polls status without re-activating. Activation failure now backs off from 2 s to 60 s, and the status poll takes its own second chance. activateIfNeeded also no longer fires while .inactive, which belongs to sessionDidDeactivate. Reachability flips wiped the dedupe caches. Every availabilityChanged push was handled as refreshNativeState: true, clearing the payload fingerprint, movement gate, throttle timestamps and map-geo lease — but native only drops lastContextData in sessionWatchStateDidChange and clear, never on reachability, which flips on every wrist raise and lower. Each glance therefore forced a full updateApplicationContext resend and silently voided the lease. The push now says whether native cleared its own cache, and only that invalidates Dart's. --- ios/Runner/WatchSessionManager.swift | 64 +++++++++++++++++-- lib/services/watch/watch_bridge_service.dart | 18 ++++-- .../watch/watch_wire_contract_test.dart | 44 +++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index 7b4d82a1..2177fb99 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -47,6 +47,17 @@ final class WatchSessionManager: NSObject { /// identical payload doesn't churn the radio. private var lastContextData: Data? + /// Backoff for retrying a failed activation, doubling to a cap. + /// + /// A failed activation used to be permanent: Dart's `canSync` stays false, so + /// `send(payload:urgent:)` — the only other caller of `activateIfNeeded()` — + /// is never reached, and the diagnostics screen polls `status` without ever + /// asking for another attempt. The watch then stayed unreachable for the life + /// of the process even after whatever caused the failure went away. + private var activationRetryDelay: TimeInterval = 2 + private static let maximumActivationRetryDelay: TimeInterval = 60 + private var activationRetryScheduled = false + func attach(channel: FlutterMethodChannel) { self.channel = channel activateIfNeeded() @@ -54,12 +65,26 @@ final class WatchSessionManager: NSObject { private func activateIfNeeded() { guard let session else { return } - if session.activationState != .activated { + // `.inactive` belongs to `sessionDidDeactivate`, which reactivates on its + // own; activating from here would race it. + if session.activationState == .notActivated { session.delegate = self session.activate() } } + private func scheduleActivationRetry() { + guard !activationRetryScheduled, session != nil else { return } + activationRetryScheduled = true + let delay = activationRetryDelay + activationRetryDelay = min(delay * 2, Self.maximumActivationRetryDelay) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.activationRetryScheduled = false + self.activateIfNeeded() + } + } + // MARK: - Flutter → watch func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -79,6 +104,9 @@ final class WatchSessionManager: NSObject { result(nil) case "status": + // Dart polls this while the diagnostics screen is open, which makes it a + // natural second chance at an activation that failed at launch. + activateIfNeeded() result(statusDictionary()) default: @@ -149,13 +177,36 @@ final class WatchSessionManager: NSObject { /// 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() { + /// + /// - Parameter nativeCacheCleared: whether `lastContextData` was dropped + /// alongside this push. Dart mirrors that cache to decide what it can + /// dedupe, so it needs to know which pushes invalidate it and which do not. + /// Only `sessionWatchStateDidChange` clears it here; reachability flips on + /// every wrist raise and lower, and telling Dart to forget everything that + /// often forced a full context resend per glance and voided the map-geo + /// lease. + private func publishStatus(nativeCacheCleared: Bool = false) { guard let channel else { return } DispatchQueue.main.async { - channel.invokeMethod("availabilityChanged", arguments: self.statusDictionary()) + var status = self.statusDictionary() + status["nativeCacheCleared"] = nativeCacheCleared + channel.invokeMethod("availabilityChanged", arguments: status) } } + /// Drop the keys whose value is Dart `null`. + /// + /// The standard method codec decodes Dart's `null` as `NSNull`, which is not + /// a property-list type, and `WCSession`'s `replyHandler` raises on anything + /// that isn't. Every *accepted* command replies `{"accepted": true, "reason": + /// null}`, so without this the legacy reply path failed on precisely its + /// success case — the one case the overload is kept around to serve. An + /// absent key reads the same as a null one on the watch, which decodes the + /// reply into optionals. + private static func propertyListSafe(_ dict: [String: Any]) -> [String: Any] { + dict.filter { !($0.value is NSNull) } + } + private func flutterError(_ error: Error, code: String) -> FlutterError { FlutterError( code: code, @@ -185,7 +236,7 @@ final class WatchSessionManager: NSObject { channel.invokeMethod("command", arguments: payload) { response in if let dict = response as? [String: Any] { NSLog("[WATCH] Command \(kind) acked: accepted=\(dict["accepted"] ?? "?")") - reply(dict) + reply(Self.propertyListSafe(dict)) } else { NSLog("[WATCH] Command \(kind) got no response from Dart") reply(["accepted": false, "reason": "No response"]) @@ -205,6 +256,9 @@ extension WatchSessionManager: WCSessionDelegate { ) { if let error { NSLog("[WATCH] Activation failed: \(error.localizedDescription)") + DispatchQueue.main.async { [weak self] in self?.scheduleActivationRetry() } + } else if activationState == .activated { + DispatchQueue.main.async { [weak self] in self?.activationRetryDelay = 2 } } publishStatus() } @@ -226,7 +280,7 @@ extension WatchSessionManager: WCSessionDelegate { // A newly installed or newly paired watch has no context yet. self?.lastContextData = nil } - publishStatus() + publishStatus(nativeCacheCleared: true) } /// The documented signal for reachability changing. Without it, the status diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 6bcfec7d..3c368bca 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -159,7 +159,15 @@ class WatchBridgeService { Future _handleNativeCall(MethodCall call) async { if (call.method == 'availabilityChanged') { - _applyAvailability(call.arguments, refreshNativeState: true); + // Only a push that says native dropped its own application-context cache + // invalidates ours. Reachability flips on every wrist raise and lower, + // and treating those as cache invalidation forced a full + // `updateApplicationContext` resend per glance and silently voided the + // map-geo lease — the two costs this bridge exists to avoid. + final args = call.arguments; + final nativeCacheCleared = + args is Map && args['nativeCacheCleared'] == true; + _applyAvailability(args, refreshNativeState: nativeCacheCleared); return null; } if (call.method != 'command') return null; @@ -318,10 +326,10 @@ class WatchBridgeService { } 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. + // Native forgets its application-context cache when WatchConnectivity + // reports a *watch-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; diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index b188bc26..40c13172 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -728,6 +728,7 @@ void main() { 'paired': true, 'installed': true, 'reachable': true, + 'nativeCacheCleared': true, }, )), null, @@ -740,6 +741,49 @@ void main() { reason: 'native cleared its context cache on the state change'); }); + test('a reachability flip alone leaves the caches intact', () async { + // `sessionReachabilityDidChange` fires on every wrist raise and lower, + // and native does not drop `lastContextData` there. Treating it as cache + // invalidation made each glance force a full `updateApplicationContext` + // resend and silently voided the map-geo lease. + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + void schedule() => bridge.schedule( + _snapshot, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + for (final reachable in [false, true]) { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': reachable, + 'nativeCacheCleared': false, + }, + )), + null, + ); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + } + + expect(syncCalls, 1, + reason: 'an unchanged payload must stay deduplicated across glances'); + }); + // 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 From fe2c1e6690a57e1674937b40c6c96ea8f6e26549 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:16:02 -0700 Subject: [PATCH 252/360] Stop a backgrounded session logging a failed Live Activity every 30 s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityKit only permits Activity.request from a foreground app, and a session can start backgrounded — auto-ping is restored after a BLE auto-reconnect under the background service. The visibility error was rethrown as a PlatformException, which bypasses Dart's 30 s backoff because that only arms on a false result. So every throttled sync retried and logged a failure for as long as the app stayed backgrounded. Returning false is also the honest answer: there is nowhere to put an activity right now. --- ios/Runner/LiveActivityManager.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index 737f7c9c..c4cec686 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -39,6 +39,16 @@ final class LiveActivityManager { let parsed = try parse(payload) try await sync(attributes: parsed.attributes, state: parsed.state) result(true) + } catch ActivityAuthorizationError.visibility { + // ActivityKit only permits `request` from a foreground app, and a + // session can begin backgrounded — auto-ping is restored after a BLE + // auto-reconnect under the background service. Reported as an error + // this bypassed Dart's 30 s backoff, which only arms on a `false` + // result, so every throttled sync retried and logged a failure for as + // long as the app stayed in the background. `false` is the honest + // answer anyway: there is nowhere to put an activity right now. + NSLog("[LIVE ACTIVITY] Cannot start while backgrounded; will retry") + result(false) } catch { result(self.flutterError(error, code: "sync_failed")) } From b299d619b9c49a32d1c3590286b3284e799f798e Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:16:02 -0700 Subject: [PATCH 253/360] Let resume see the context it just ingested, and read the wire version first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resume() computed needsPhone synchronously right after ingest(context:), but the decoded context is applied through a queued Task { @MainActor } — and the 90 s stale-boundary flip is queued the same way. The decision never saw the just-ingested context, so the outcome depended on main-actor queue ordering: either every glance past the boundary sent a forceRefresh full snapshot despite a seconds-old retained context, or the request was skipped and the wearer sat on stale UI. Both are what the method exists to prevent. This path now applies the context on the calling actor; every other ingest keeps the background decode. Separately, ingest(data:) fully decoded WatchSnapshot before checking isSupportedVersion. A future v3 that removes or renames a required field — one of the version constant's own documented bump triggers — fails the throwing decode and returns before the check, so the "update the iPhone app" prompt never fires and the watch just goes permanently stale. wireVersion is now read through a minimal probe struct first. --- ios/MeshMapperWatch/WatchSessionClient.swift | 76 +++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 7118cc56..e5da59ac 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -166,7 +166,15 @@ final class WatchSessionClient: NSObject { // 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) + + // Applied synchronously, unlike every other ingest. The queued variant + // lands after this method returns, so the decision below was made against + // pre-resume state — and the 90 s staleness flip inside `apply` is queued + // the same way. Whichever way main-actor ordering happened to fall, one of + // the two failures this method exists to prevent came back: a `forceRefresh` + // full snapshot on every glance past the boundary despite holding a + // seconds-old context, or a wearer left looking at stale UI. + ingestNow(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. @@ -468,6 +476,42 @@ final class WatchSessionClient: NSObject { // MARK: - Ingest + /// What a delivered payload turned out to be. + private enum Ingested { + case snapshot(WatchSnapshot) + case unsupportedVersion + case undecodable + } + + /// Just the version, so it can be read from a payload the full model cannot + /// decode. + /// + /// `WatchSnapshot` decodes its required fields with a throwing decoder, so a + /// future wire that removes or renames one — a documented reason to bump + /// `MeshMapperWatchWire.version` — fails to decode before the version is ever + /// examined. That is precisely the case the refusal exists for: without this + /// the watch would silently drop every payload and go permanently stale + /// instead of telling the wearer to update the iPhone app. + private struct WireVersionProbe: Decodable { + let wireVersion: Int + } + + private nonisolated static func decode(_ data: Data) -> Ingested { + let decoder = MeshMapperWatchWire.decoder + guard let probe = try? decoder.decode(WireVersionProbe.self, from: data) else { + return .undecodable + } + // 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 probe.wireVersion == MeshMapperWatchWire.version else { + return .unsupportedVersion + } + guard let decoded = try? decoder.decode(WatchSnapshot.self, from: data) else { + return .undecodable + } + return .snapshot(decoded) + } + /// `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. @@ -477,19 +521,33 @@ final class WatchSessionClient: NSObject { } private nonisolated func ingest(data: Data) { - guard let decoded = try? MeshMapperWatchWire.decoder.decode(WatchSnapshot.self, from: data) - else { + switch Self.decode(data) { + case .undecodable: return + case .unsupportedVersion: + Task { @MainActor [weak self] in self?.versionMismatch = true } + case .snapshot(let decoded): + Task { @MainActor [weak self] in self?.apply(decoded) } } + } - // 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 } + /// Ingest without the main-actor hop, for a caller that has to see the result + /// before it can decide anything. + /// + /// The asynchronous path above is the right default — a 12 kB parse does not + /// belong on the wrist's main thread on every snapshot. This one runs once + /// per wrist raise, where the alternative is deciding whether to spend the + /// radio against state the queued apply has not delivered yet. + private func ingestNow(context: [String: Any]) { + guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return } + switch Self.decode(data) { + case .undecodable: return + case .unsupportedVersion: + versionMismatch = true + case .snapshot(let decoded): + apply(decoded) } - - Task { @MainActor [weak self] in self?.apply(decoded) } } private func apply(_ decoded: WatchSnapshot) { From 28ba3d152976ad19fed31ce16fc386939a5b0337 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:16:13 -0700 Subject: [PATCH 254/360] Take one reading of now for the ping cooldown countdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pingLabel read Date() to test the cooldown deadline and again to build the timer's range, so a deadline crossing between the two produced lowerBound > upperBound — a runtime trap. MapPage's activeCountdownRange documents and avoids the same hazard; this mirrors it. DebugPage's session() has the pattern too, but it is DEBUG-only and left alone. --- ios/MeshMapperWatch/ControlsPage.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 37196f59..31759624 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -40,11 +40,18 @@ struct ControlsPage: View { ) } - /// Manual-ping cooldown deadline, if one is still ahead of us. - private var cooldownEndsAt: Date? { + /// The manual-ping cooldown as a native timer range, if one is still ahead. + /// + /// One `now` for both bounds. Reading `Date()` to test the deadline and again + /// to build the range let a deadline crossing between the two produce + /// `lower > upper`, which traps at runtime. MapPage's `activeCountdownRange` + /// documents and avoids the same hazard. + private var cooldownCountdownRange: ClosedRange? { guard let ms = controls?.manualCooldownEndsAtMs else { return nil } + let now = Date() let endsAt = Date(timeIntervalSince1970: ms / 1000) - return endsAt > Date() ? endsAt : nil + guard endsAt > now else { return nil } + return now...endsAt } var body: some View { @@ -283,10 +290,10 @@ struct ControlsPage: View { /// snapshot arrives. @ViewBuilder private func pingLabel(isPending: Bool) -> some View { - if let endsAt = cooldownEndsAt { + if let range = cooldownCountdownRange { HStack(spacing: 5) { Text("Ping in") - Text(timerInterval: Date()...endsAt, countsDown: true) + Text(timerInterval: range, countsDown: true) .monospacedDigit() .frame(width: 38, alignment: .leading) } From 26c2f8f4c25e36e7ed9749be002a86750e0fb9b0 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:16:13 -0700 Subject: [PATCH 255/360] Close the two wire-contract gaps redelivery and renames slip through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key-set pins for WatchPing, WatchPosition and WatchHeardNode, matching the existing WatchRepeater test. Every field in these is non-optional on the Swift side, so renaming e.g. fixedAtMs does not degrade — it fails WatchSnapshot's throwing decode and the watch stops receiving anything. Today that rename passes the whole suite. And a refused *timestamped* command now has its dedupe pinned. The invariant is already stated in the bridge's own comment — redelivery after conditions change must never turn yesterday's tap into a transmit — but only the legacy untimestamped branch was covered, which is the branch no shipping watch uses. --- .../watch/watch_wire_contract_test.dart | 105 +++++++++++++++++- 1 file changed, 101 insertions(+), 4 deletions(-) diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 40c13172..3919d09f 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -60,7 +60,8 @@ WatchSnapshot _snapshot({ pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, cue: cue, - updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(1759999999000), + updatedAt: + updatedAt ?? DateTime.fromMillisecondsSinceEpoch(1759999999000), ); void main() { @@ -278,6 +279,63 @@ void main() { }); }); + test('geo wire objects pin the key names the watch decodes', () { + // Every one of these is non-optional on the Swift side, so a rename here + // does not degrade — it fails `WatchSnapshot`'s throwing decode and the + // watch stops receiving anything at all. Renaming `fixedAtMs` otherwise + // passes this entire suite. + final position = WatchPosition( + lat: 47.6, + lon: -122.3, + headingDeg: 90, + accuracyM: 5, + fixedAt: DateTime.utc(2026, 8, 12), + ); + expect(position.toMap().keys.toSet(), { + 'lat', + 'lon', + 'headingDeg', + 'accuracyM', + 'fixedAtMs', + }); + + final ping = WatchPing( + id: 'tx-1', + lat: 47.6, + lon: -122.3, + kind: 'tx', + color: const WatchColor(1, 0, 0), + at: DateTime.utc(2026, 8, 12), + ); + expect(ping.toMap().keys.toSet(), { + 'id', + 'lat', + 'lon', + 'kind', + 'color', + 'atMs', + }); + + final heard = WatchHeardNode( + id: '4E5D', + name: 'Capitol Hill', + snr: 7.5, + at: DateTime.utc(2026, 8, 12), + distanceM: 1200, + snrColor: const WatchColor(0, 1, 0), + typeColor: const WatchColor(0, 0, 1), + ); + expect(heard.toMap().keys.toSet(), { + 'id', + 'name', + 'snr', + 'atMs', + 'distanceM', + 'snrColor', + 'typeColor', + }); + }); + test('failure cues carry their creation time for restart filtering', () { final issuedAt = DateTime.utc(2026, 8, 12, 10); final cue = WatchHapticCue( @@ -884,7 +942,8 @@ void main() { updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), forceDelivery: true, ); - expect(syncCalls, 2, reason: 'the refused attempt still reached native'); + expect(syncCalls, 2, + reason: 'the refused attempt still reached native'); syncSucceeds = true; // Note the absent forceDelivery: the obligation has to be the thing @@ -1063,6 +1122,42 @@ void main() { expect(handled, hasLength(2)); }); + test('a refused queued command is never retried once conditions change', + () async { + // The counterpart to the test above, and the branch that matters on a + // shipping watch: transferUserInfo has no reply to act on a refusal, so + // WatchConnectivity redelivers the same tap on its own schedule. + // Forgetting the ID the way the legacy reply path does would let a stale + // wrist action transmit the moment the phone became willing. + var refuse = true; + final issuedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return refuse ? 'Not connected' : null; + }); + + final first = await sendCommand( + 'queued-2', + 'startSession', + issuedAtMs: issuedAtMs, + ); + expect(first?['accepted'], isFalse); + expect(first?['reason'], 'Not connected'); + + refuse = false; + final redelivered = await sendCommand( + 'queued-2', + 'startSession', + issuedAtMs: issuedAtMs, + ); + + expect(handled, hasLength(1), + reason: 'redelivery must never turn yesterday\'s tap into a ' + 'transmit'); + expect(redelivered?['accepted'], isTrue, + reason: 'the dedupe reply acks delivery, not a fresh admission'); + }); + // 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 @@ -1428,7 +1523,8 @@ void main() { // 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); + 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 @@ -1499,7 +1595,8 @@ void main() { // 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 pushExpectingSuppression( + geoAt(47.6001, fixedAtMs: 1760000030000), 1); await pushExpectingSend(geoAt(47.6002, fixedAtMs: 1760000060000), 2); expect(sentLat(1), 47.6002); }); From 2808dde7c5f3d4f3e658723887b572358bf6d84c Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:16:23 -0700 Subject: [PATCH 256/360] Write down the watch bridge's invariants, and correct three stale claims DEVELOPMENT.md gains the [WATCH] and [LIVE ACTIVITY] rows in the Required Tags table, the new Dart files in the Key File Reference, and an architecture section for the companion surfaces: the five suppression gates in order and what each one is for, cache invalidation, the map-geo lease, command admission, wire versioning, and the geography caps. docs/LIVE_ACTIVITIES.md only ever covered the Live Activity half, and these are the invariants a future contributor breaks if nothing states them. Also corrected: the small activity family is primarily the paired watch's Smart Stack card rather than CarPlay, per the implementation's own comments; the WakeLog devicectl command named dev.agessaman.meshmapper.watchkitapp where the project builds net.meshmapper.app.watchkitapp; and the payload caps say they are validated on receive, which no receive-side truncation does. --- DEVELOPMENT.md | 92 +++++++++++++++++++++++++ docs/LIVE_ACTIVITIES.md | 6 +- ios/MeshMapperWatch/MapPage.swift | 2 +- ios/Shared/MeshMapperWatchPayload.swift | 6 +- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ae072d18..00c34404 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -403,6 +403,89 @@ fan-out logic (`MeshMapper_Server/dev/index.php`): `repeaterCoverageCells`, `RepeaterStats.fromCoverageWithPoints`), `lib/utils/coverage_tile_palette.dart` (`colorsForStatus`). +### Apple Companion Surfaces (Watch + Live Activity) + +Both surfaces are **projections of phone-owned state**. The phone keeps the +MeshCore connection, the GPS fix, the session lifecycle, the transmit policy and +command admission; the wrist and the Live Activity render that state and send +intent back. Nothing on either surface may decide that a transmit is legal. + +`docs/LIVE_ACTIVITIES.md` covers the ActivityKit half. The invariants below +belong to the watch bridge, and breaking one of them costs battery on two +devices or puts a packet on air from the wrong place. + +**Delivery and suppression** (`lib/services/watch/watch_bridge_service.dart`) + +Every gate runs before the next, and each exists for a different failure: + +1. **Debounce** — 200 ms. Coalesces a burst of `notifyListeners()`. +2. **Urgency preflight** — the flush decides whether it may wait *before* + building anything. `WatchSnapshot.buildUrgencyKey` is a small scalar + projection (session, mode, phase, connection, control enablement, cue ID, + map-geo inclusion); if it hasn't moved, the 2 s floor applies and no + geography is constructed, sorted or encoded. `LiveActivityService` mirrors + this with `LiveActivitySnapshot.buildPreflightUrgencyKey` and a 15 s floor. + **Sustained per-tick work is the thing to avoid here** — the countdown + timers drive a flush at ~2 Hz for a whole session, and this app has a + wardriving overheat history. +3. **Payload fingerprint** — JSON of the payload minus `updatedAtMs`. Timestamp + metadata must never defeat dedupe; the watch renders countdowns from the + absolute `phaseEndsAt` deadline instead. An explicit `forceRefresh` is the + one thing that may send an identical payload again. +4. **Movement gate** — `WatchWire.minMoveMeters` (15 m). Expressed as "nothing + but the fix changed, and the fix didn't move far enough", measured against + the fix the watch last *received*. A refused or dropped send must not + consume the wearer's next 15 m. +5. **Send throttle** — the same 2 s floor, applied to delivery. A forced + refresh outlives a deferral rather than being dropped. + +Urgent updates use `sendMessage` and *always* fall through to +`updateApplicationContext`, so a missed message can't strand the watch. + +**Cache invalidation.** Dart's dedupe caches mirror native's `lastContextData`. +Native clears that only in `sessionWatchStateDidChange` and on `clear`, and says +so with `nativeCacheCleared` on the `availabilityChanged` push. Reachability +flips on every wrist raise and lower — treating those as invalidation forces a +full context resend per glance and voids the map-geo lease. + +**Map-geo lease.** While the map isn't visible the watch asks the phone to omit +geography. The phone treats that as a *lease*, not a latch: suppression expires +after `_mapGeoClaimFreshFor` (10 min) back to full geography, and the watch +renews it every 5 minutes. A lost command therefore fails safe — toward sending +too much rather than a permanently blank map. Renewals are deduplicatable; +only a stated `forceRefresh` defeats the payload fingerprint. + +**Command admission.** Wrist commands are intent, revalidated by the phone: + +- IDs make WatchConnectivity's redelivery idempotent. A *queued* command refused + once stays remembered — redelivery after conditions change must never turn + yesterday's tap into a transmit. Only the untimestamped legacy path forgets. +- Timestamped commands must land inside `_maximumCommandAge` (30 s), with + `_clockTolerance` (5 s) of slack in both directions. A fast watch clock is the + same bug as a slow queue. +- `requestSnapshot` and `stopSession` are exempt from that window: one transmits + nothing, and the other takes the radio *off* air, so lateness can only make + refusing it worse. +- `resolveSessionStartAvailability` is the single start gate for both the + offered button and the admitted command. **Passive counts as transmitting** — + it sends a discovery request on start and every 30 s — so the manual-ping, + RX-window and cooldown guards apply to every mode. Only offline mode, + passive-only zones and TX validation are transmit-only. + +**Wire versioning** (`WatchWire.version`, mirrored in +`ios/Shared/MeshMapperWatchPayload.swift`) + +Bump only when a field **changes meaning or is removed**. Additive optional +fields must not bump it: a bump strands compatible pairs, and older peers are +required to default absent fields safely. The watch reads `wireVersion` with a +minimal probe struct *before* attempting the full decode, so a payload that a +future breaking change makes undecodable still reaches the "update the iPhone +app" prompt instead of going silently stale. + +**Geography caps** — `maxPings` 60, `maxRepeaters` 20, `maxHeard` 4. Applied by +`WatchGeoBuilder` on the sending side, after merging every source and sorting by +recency, so a busy TX history cannot erase discovery or trace markers. + ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` - RX Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (write to device) @@ -546,6 +629,8 @@ debugError('[API] Failed to post batch: $error'); | `[OFFLINE]` | Offline mode operations | | `[SCAN]` | BLE device scanning | | `[WAKELOCK]` | Wake lock acquisition/release | +| `[WATCH]` | WatchConnectivity bridge: availability, snapshot delivery, wrist commands | +| `[LIVE ACTIVITY]` | ActivityKit bridge: sync, end, and authorization failures | Never log without a tag. @@ -671,6 +756,13 @@ All API endpoints may return maintenance mode: - `lib/services/debug_submit_service.dart` - Bug report submission (4-step workflow) - `lib/services/gps_simulator_service.dart` - GPS simulation for testing - `lib/services/wakelock_service.dart` - Screen wake lock during auto-ping +- `lib/services/watch/watch_bridge_service.dart` - WatchConnectivity transport: throttle, dedupe, movement gate, map-geo lease, command admission +- `lib/services/watch/watch_models.dart` - Watch wire contract and shared start-admission resolver +- `lib/services/watch/watch_geo_builder.dart` - Ping/repeater/heard geography for the wrist, with wire caps +- `lib/services/watch/watch_color.dart` - Wire colour projection shared with the phone map +- `lib/services/live_activity/live_activity_service.dart` - ActivityKit bridge: preflight urgency, throttle, dedupe, unavailable backoff +- `lib/services/live_activity/live_activity_models.dart` - Live Activity snapshot model and urgency keys +- `lib/screens/watch_diagnostics_screen.dart` - Watch transport diagnostics (Settings) - `lib/services/meshcore/packet_validator.dart` - Packet validation and carpeater filtering - `lib/models/noise_floor_session.dart` - Noise floor session data models - `lib/widgets/noise_floor_chart.dart` - Noise floor graph visualization diff --git a/docs/LIVE_ACTIVITIES.md b/docs/LIVE_ACTIVITIES.md index f0f6075a..1e4b5a10 100644 --- a/docs/LIVE_ACTIVITIES.md +++ b/docs/LIVE_ACTIVITIES.md @@ -8,7 +8,7 @@ MeshMapper starts one read-only Live Activity while an automatic wardriving sess - 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 +- Lock Screen, Dynamic Island, and an iOS 18 small-family layout — primarily the paired watch's Smart Stack card, and CarPlay on supported systems ## Architecture @@ -22,8 +22,8 @@ No push server or App Group is required. The Live Activity does not replace the ## Platform requirements - Live Activity: iOS 16.2 or later -- Small supplemental activity family: iOS 18 or later +- Small supplemental activity family (watch Smart Stack): 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 +- A physical iPhone is recommended for final background, Dynamic Island, and CarPlay validation; the Smart Stack card needs a paired watch The main Runner target keeps its existing deployment target. Unsupported devices skip ActivityKit creation. diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index d7e2bb41..e02110c4 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -2548,7 +2548,7 @@ private struct RepeaterPin: View { /// /// xcrun devicectl device copy from --device \ /// --domain-type appDataContainer \ -/// --domain-identifier dev.agessaman.meshmapper.watchkitapp \ +/// --domain-identifier net.meshmapper.app.watchkitapp \ /// --source Documents/wake-timings.log --destination . /// /// Lines still go to `NSLog` as well, so Console.app keeps working for anyone diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 98a3bde8..337e5a8b 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -31,7 +31,11 @@ enum MeshMapperWatchWire { /// Rejecting that pair would add no protection. static let version = 2 - /// Caps, mirrored in Dart. Enforced on send *and* validated on receive. + /// Caps, mirrored in Dart and enforced by `WatchGeoBuilder` on send. + /// + /// The receiving side does not truncate: these values describe what the phone + /// promises to send, and the views size themselves from what actually + /// arrives. A peer that ignored them would render more rows, not crash. static let maxPings = 60 static let maxRepeaters = 20 From 7660f0e96204e4b452ad7cb9e25f7bb86d6fe75b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:44:15 -0700 Subject: [PATCH 257/360] Resolve heard-row names at every hash width, not just the first row's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _buildWatchGeo took a single hex length from the first top row — or the RX slot's when there were no top rows — and indexed the repeater catalogue at that one width. The RX slot's path hash comes from a different ping than the top rows', and the zone's hop-byte count is what sets the width, so whenever the two disagreed the odd row out silently lost its name and its distance while every other row resolved normally. resolveUniqueHexPrefixes already indexes per distinct length with identical uniqueness semantics, so this is a delegation rather than new logic. Removing the length choice removes the branch that could get it wrong. --- lib/providers/app_state_provider.dart | 32 +++++---- .../watch/watch_geo_builder_test.dart | 65 +++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7ff38adb..4d6c6d4c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1376,14 +1376,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 {}; + // Overlay IDs are hex path hashes, resolved to names by prefix. + // + // Indexed per distinct length, not at one length taken from the first row. + // The RX slot's hash can be a different width than the top rows' — the two + // come from different pings, and the zone's hop-byte count is what sets the + // width — so a single-length index silently dropped the odd row's name and + // distance while every other row resolved normally. + final repeaterByHex = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: _repeaters, + prefixes: [ + ...top.map((row) => row.repeaterId), + if (rxSlot != null) rxSlot.repeaterId, + ], + ); final heard = WatchGeoBuilder.buildHeard( top: top, rxSlot: rxSlot, @@ -1574,10 +1580,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return _autoMode; } - List get _availableWatchStartModes => [ - WatchStartMode.passive, - if (isConnected && txAllowed) WatchStartMode.hybrid, - ]; + List get _availableWatchStartModes => + resolveAvailableWatchStartModes( + isConnected: isConnected, + txAllowed: txAllowed, + offlineMode: offlineMode, + ); String get _resolvedWatchSessionModeTitle => switch (_resolvedWatchSessionMode) { diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 0fbf20af..473f716f 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -504,6 +504,43 @@ void main() { expect(built[2].typeColor, WatchColor.fromColor(PingColors.rx)); }); + test('an RX slot of a different hash width still gets its name', () { + final capitol = _repeater( + id: '01', + hexId: '4E5D82', + lat: 47.6, + lon: -122.3, + name: 'Capitol Hill'); + final beacon = _repeater( + id: '02', + hexId: '77A1B0', + lat: 47.6, + lon: -122.3, + name: 'Beacon Hill'); + + // Exactly what the provider now hands in: resolved per distinct length. + final repeaterByHex = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [capitol, beacon], + prefixes: const ['4E5D', '77A1B0'], + ); + + final built = WatchGeoBuilder.buildHeard( + top: const [(repeaterId: '4E5D', snr: 8.5, type: OverlayPingType.tx)], + rxSlot: (repeaterId: '77A1B0', snr: 9.9), + repeaterByHex: repeaterByHex, + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12, 0, 1), + lat: 47.6, + lon: -122.3, + ); + + expect(built[0].name, 'Capitol Hill'); + expect(built[1].name, 'Beacon Hill', + reason: 'the RX row lost its name to a single-length index'); + expect(built[1].distanceM, isNotNull, + reason: 'distance is resolved from the same repeater match'); + }); + test('never exceeds the wire cap of three rows plus the RX slot', () { final built = WatchGeoBuilder.buildHeard( top: const [ @@ -571,6 +608,34 @@ void main() { expect(linked['4E5D'], same(repeater)); }); + test('resolves every distinct prefix length in one pass', () { + // The RX slot's path hash can be a wider or narrower hash than the top + // rows', because the two come from different pings and the zone's + // hop-byte count sets the width. Indexing at one length left the odd row + // out with no name and no distance while every other row resolved. + final short = _repeater( + id: '01', + hexId: '4E5D82', + lat: 47.6, + lon: -122.3, + name: 'Capitol Hill'); + final long = _repeater( + id: '02', + hexId: '77A1B0', + lat: 47.6, + lon: -122.3, + name: 'Beacon Hill'); + + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [short, long], + prefixes: const ['4E', '77A1B0'], + ); + + expect(linked['4E']?.name, 'Capitol Hill'); + expect(linked['77A1B0']?.name, 'Beacon Hill', + reason: 'a row of a different hash width must still resolve'); + }); + test('an ambiguous link prefix resolves to nothing', () { final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( repeaters: [ From 0f35798e656233d83c503bfece6295b135afe773 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:44:15 -0700 Subject: [PATCH 258/360] Let a wearer read a failure cue they were too slow to feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone drops a cue as soon as WatchConnectivity accepts the snapshot carrying it, which happens while the watch is still suspended — so the retained application context is the only copy that will ever exist. The watch then discarded it outright past the 30 s freshness window: no haptic and no message. A wearer who tapped Start, dropped their wrist and looked back a minute later was never told why the session had not begun. Freshness now decides how much to present rather than whether to present at all. Inside 30 s the cue buzzes and shows its message as before; past it the message still appears, because buzzing for something that old is wrong but staying silent about it is worse. The upper bound is staleAfter, the same 90 s boundary that greys the rest of the screen. Past that the wearer is already being told the whole surface is old, and a banner asserting a stale failure as current would be its own lie. --- ios/MeshMapperWatch/WatchSessionClient.swift | 37 +++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index e5da59ac..c9200196 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -397,12 +397,38 @@ final class WatchSessionClient: NSObject { } } - private static func isFresh(_ cue: WatchHapticCue, at arrival: Date) -> Bool { - guard let issuedAt = cue.issuedAt else { return false } + /// How much of a cue is still worth presenting, given its age. + private enum CuePresentation { + /// Recent enough that the wearer can connect the buzz to what they did. + case feel + /// Too old to buzz for, but still the only account of the failure they + /// will ever get: the phone drops a cue once WatchConnectivity accepts the + /// snapshot carrying it, which happens while the watch is still suspended. + case read + case drop + } + + /// A wrist-down interval longer than [cueFreshFor] used to discard the cue + /// outright — no haptic *and* no message — so a wearer who tapped Start, + /// dropped their wrist and looked back a minute later was never told why the + /// session had not begun. Buzzing for something that old is wrong, but + /// staying silent about it is worse. + /// + /// The upper bound is [staleAfter], the same boundary that greys the rest of + /// the screen: past it the wearer is already being told the whole surface is + /// old, and a banner asserting a stale failure as current would be its own + /// lie. + private static func presentation( + for cue: WatchHapticCue, + at arrival: Date + ) -> CuePresentation { + guard let issuedAt = cue.issuedAt else { return .drop } 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 + guard age >= -clockTolerance else { return .drop } + if age <= cueFreshFor { return .feel } + return age <= staleAfter ? .read : .drop } /// The main-actor half of the activation callback. @@ -576,7 +602,8 @@ final class WatchSessionClient: NSObject { } if let cue = decoded.cue, - Self.isFresh(cue, at: arrival), + case let presentation = Self.presentation(for: cue, at: arrival), + presentation != .drop, presentedCueIDs.insert(cue.id).inserted { presentedCueIDOrder.append(cue.id) @@ -589,7 +616,7 @@ final class WatchSessionClient: NSObject { // 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 presentation == .feel { 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 From a3eb306f5fab061f58424cc9ecf9586077e5f56b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:44:22 -0700 Subject: [PATCH 259/360] Stop offering the wrist a Hybrid start that Offline Mode always refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _availableWatchStartModes advertised Hybrid whenever the phone was connected and TX was allowed, but ignored Offline Mode — where sessionStartAvailability refuses every start with 'Offline Mode'. On a screen with room for two options, one of them was permanently dead. Moved beside its sibling resolvers as resolveAvailableWatchStartModes so the policy is testable and so advertisement and admission cannot drift on this again, which is the same reason the start gate itself is shared. Transient guards deliberately stay out: a cooldown should not make an option vanish and reappear. --- lib/services/watch/watch_models.dart | 20 +++++++ .../watch/watch_wire_contract_test.dart | 60 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 2ae40bed..e44b0728 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -510,6 +510,26 @@ WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ return (mode: mode, refusal: null); } +/// Which start modes the wrist may offer. +/// +/// Advertisement, not admission. Transient conditions — a ping in flight, a +/// cooldown — belong to [resolveSessionStartAvailability] and would only make +/// the option list flicker; what belongs here is whether a mode could start at +/// all in the current configuration. +/// +/// Offline Mode is part of that configuration. Advertising Hybrid while it is +/// on offered the wearer a button every start refuses with 'Offline Mode' — a +/// permanently dead option on a screen with room for two. +List resolveAvailableWatchStartModes({ + required bool isConnected, + required bool txAllowed, + required bool offlineMode, +}) => + [ + WatchStartMode.passive, + if (isConnected && txAllowed && !offlineMode) WatchStartMode.hybrid, + ]; + typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); typedef SessionStartAvailability = ({bool allowed, String? reason}); diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 3919d09f..06adabfb 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -528,6 +528,66 @@ void main() { expect(result, (mode: null, refusal: null)); }); + test('the wrist is never offered a mode every start would refuse', () { + // Hybrid used to be advertised whenever the phone was connected and TX + // was allowed, ignoring Offline Mode — where sessionStartAvailability + // refuses every start with 'Offline Mode'. That is a permanently dead + // button on a screen with room for two. + expect( + resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: true, + ), + [WatchStartMode.passive], + ); + + expect( + resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: false, + ), + [WatchStartMode.passive, WatchStartMode.hybrid], + ); + + // Passive is always offered: it is what the wrist falls back to, and the + // phone still re-validates the start. + for (final connected in [true, false]) { + for (final tx in [true, false]) { + expect( + resolveAvailableWatchStartModes( + isConnected: connected, + txAllowed: tx, + offlineMode: false, + ), + contains(WatchStartMode.passive), + ); + } + } + }); + + test('an advertised Hybrid is one the start gate actually admits', () { + // The pairing the two resolvers must agree on: whenever Hybrid is + // offered, a start in that mode is not refused for a configuration + // reason. Transient timing guards are deliberately not checked here. + for (final offline in [true, false]) { + final offered = resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: offline, + ).contains(WatchStartMode.hybrid); + + final admitted = startAvailability( + isTransmitMode: true, + txBlockedByOffline: offline, + ); + + expect(offered, admitted.allowed, + reason: 'offered and admitted must not drift on Offline Mode'); + } + }); + test('start admission names every shared blocked precondition', () { final blocked = { 'Not connected': startAvailability(isConnected: false), From 44ee7fb9f34b7c9c89a7b8c57f0330fbf092f0b5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 08:49:48 -0700 Subject: [PATCH 260/360] Give WatchBridgeService the interval seam LiveActivityService already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every throttle assertion had to wait out the real 2 s interval, so the refresh-dedupe group alone spent ~35 s of wall clock racing a live timer — a race this suite has already lost once. The ten-minute map-geo lease and the disconnect clear path could not be covered at all. The debounce, the non-urgent interval and the lease window are now constructor parameters defaulting to the wire's real values. The command-age window and clock tolerance deliberately stay constants: they are compared against timestamps a test already chooses freely, so shortening them would buy nothing and would let a test pass against a window the wire does not use. Full suite: 39 s to 8 s, and five tests longer. Five consecutive runs pass, as do three under eight competing busy loops — the margins are no longer luck. Newly covered, all previously unreachable: - an unrenewed suppression lease expires back to full geography, and a renewal resets it - a null snapshot clears native, does not clear twice, and does not dedupe the next real snapshot against state the watch no longer holds - a burst of eight notifications coalesces into one build, which is the first of the five suppression gates and the only one nothing tested --- lib/services/watch/watch_bridge_service.dart | 35 +++- .../watch/watch_wire_contract_test.dart | 197 +++++++++++++++++- 2 files changed, 217 insertions(+), 15 deletions(-) diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 3c368bca..0fe2ae4e 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -64,18 +64,43 @@ class WatchDiagnosticStatus { /// 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); + WatchBridgeService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + @visibleForTesting + Duration mapGeoClaimFreshFor = defaultMapGeoClaimFreshFor, + }) : _channel = channel ?? const MethodChannel(_channelName), + _debounceDelay = debounceDelay, + _minimumNonUrgentInterval = minimumNonUrgentInterval, + _mapGeoClaimFreshFor = mapGeoClaimFreshFor; static const String _channelName = 'meshmapper/watch'; - static const Duration _debounceDelay = Duration(milliseconds: 200); - static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + /// The three durations a test may shorten. + /// + /// Without a seam here every throttle assertion had to wait out the real + /// interval, so one group of them spent ~35 s of wall clock and raced a live + /// timer on loaded CI — and the ten-minute lease expiry simply could not be + /// covered at all. [LiveActivityService] already took its intervals this way; + /// this closes the gap between the two. + /// + /// Deliberately *not* injectable: [_maximumCommandAge] and [_clockTolerance] + /// are compared against timestamps a test can choose freely, so shortening + /// them would buy nothing and would let a test pass against a window the + /// wire does not actually use. + static const Duration defaultDebounceDelay = Duration(milliseconds: 200); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 2); + static const Duration defaultMapGeoClaimFreshFor = Duration(minutes: 10); + static const Duration _maximumCommandAge = Duration(seconds: 30); - static const Duration _mapGeoClaimFreshFor = Duration(minutes: 10); static const Duration _clockTolerance = Duration(seconds: 5); final MethodChannel _channel; + final Duration _debounceDelay; + final Duration _minimumNonUrgentInterval; + final Duration _mapGeoClaimFreshFor; Timer? _scheduledUpdate; WatchSnapshotBuilder? _pendingSnapshotBuilder; diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 06adabfb..145683e7 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -652,16 +652,31 @@ void main() { late bool nativeAvailable; late bool syncSucceeds; late int syncCalls; + late int clearCalls; + + /// Short enough that a suite does not spend its life asleep, long enough + /// that a loaded machine cannot cross it between two adjacent statements. + /// The behaviour under test is the ordering the throttle imposes, never the + /// length of the wire's own 2 s interval. + const throttle = Duration(milliseconds: 80); + + /// Comfortably past [throttle], including the retry it schedules. + const settle = Duration(milliseconds: 200); setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; channel = const MethodChannel('meshmapper/watch_test'); - bridge = WatchBridgeService(channel: channel); + bridge = WatchBridgeService( + channel: channel, + minimumNonUrgentInterval: throttle, + mapGeoClaimFreshFor: const Duration(milliseconds: 250), + ); handled = []; nativeAvailable = true; syncSucceeds = true; syncCalls = 0; + clearCalls = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { if (call.method == 'status') { @@ -677,6 +692,10 @@ void main() { syncCalls++; return syncSucceeds; } + if (call.method == 'clear') { + clearCalls++; + return null; + } return null; }); }); @@ -909,11 +928,9 @@ void main() { // 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 + // `settle` clears 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, @@ -1036,6 +1053,108 @@ void main() { }); }); + group('a session that ends clears the watch', () { + // The disconnect path: the provider stops producing a snapshot, and the + // bridge must tell native to drop its retained application context so the + // wrist is not left rendering a session that no longer exists. + // + // Untested before the throttle became injectable, because reaching the + // clear meant waiting out the real interval twice. + test('a null snapshot clears native and does not clear twice', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + WatchSnapshot? pending = _snapshot(); + void schedule() => bridge.schedule( + () => pending, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(settle); + expect(syncCalls, 1); + expect(clearCalls, 0); + + pending = null; + schedule(); + await Future.delayed(settle); + + expect(clearCalls, 1, + reason: 'native must forget the context when the session ends'); + + // Still nothing to send, and native has already been reconciled. + schedule(); + await Future.delayed(settle); + + expect(clearCalls, 1, + reason: 'an already-cleared bridge must not keep clearing'); + }); + + test('a snapshot after a clear is delivered, not deduped away', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + WatchSnapshot? pending = _snapshot(); + void schedule() => bridge.schedule( + () => pending, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(settle); + expect(syncCalls, 1); + + pending = null; + schedule(); + await Future.delayed(settle); + expect(clearCalls, 1); + + // The same payload as the first send. The clear dropped the + // fingerprint, so this is new information to a watch holding nothing. + pending = _snapshot(); + schedule(); + await Future.delayed(settle); + + expect(syncCalls, 2, + reason: 'a cleared watch must not be deduped against stale state'); + }); + }); + + test('a burst of notifications coalesces into one build', () async { + // The 200 ms debounce, which nothing covered. It is the first of the five + // suppression gates and the only one that acts on scheduling rather than + // on content, so a regression here would be invisible to every other test + // in this file. + final debounced = WatchBridgeService( + channel: channel, + debounceDelay: const Duration(milliseconds: 60), + minimumNonUrgentInterval: throttle, + ); + addTearDown(debounced.dispose); + debounced.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + var builds = 0; + for (var i = 0; i < 8; i++) { + debounced.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + ); + } + + expect(builds, 0, reason: 'nothing is built while the burst is arriving'); + + await Future.delayed(const Duration(milliseconds: 200)); + + expect(builds, 1, + reason: 'eight notifications must cost one snapshot, not eight'); + }); + test('the nonurgent throttle runs before the snapshot builder', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); @@ -1115,6 +1234,56 @@ void main() { expect(bridge.shouldIncludeMapGeo, isTrue); }); + test('an unrenewed suppression lease expires back to full geography', + () async { + // Suppression is a lease, not a latch. The watch renews it every five + // minutes while its map stays hidden; if those renewals stop — the watch + // app dies, the command is lost — the phone has to fail safe toward + // sending too much rather than leaving a future map blank. + // + // Untestable before the interval became injectable: the real lease is ten + // minutes long. + bridge.attachCommandHandler((_) => null); + + await sendCommand( + 'lease-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + expect(bridge.shouldIncludeMapGeo, isFalse, + reason: 'a fresh claim suppresses the expensive payload'); + + await Future.delayed(const Duration(milliseconds: 320)); + + expect(bridge.shouldIncludeMapGeo, isTrue, + reason: 'an unrenewed lease must expire, not persist'); + }); + + test('a renewal keeps the lease alive without the wearer asking', () async { + bridge.attachCommandHandler((_) => null); + + await sendCommand( + 'lease-1', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await Future.delayed(const Duration(milliseconds: 150)); + + // The renewal the watch sends every five minutes on the real wire. + await sendCommand( + 'lease-2', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await Future.delayed(const Duration(milliseconds: 150)); + + expect(bridge.shouldIncludeMapGeo, isFalse, + reason: 'renewal must reset the lease, or a hidden map still pays'); + }); + test('stale suppression cannot blank a newly visible map', () async { bridge.attachCommandHandler((_) => null); final staleMs = DateTime.now() @@ -1481,18 +1650,26 @@ void main() { 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); + /// The throttle is injected rather than waited out. What this group tests + /// is which payloads the movement gate lets through, and that answer does + /// not depend on the wire's interval being two seconds. + const throttle = Duration(milliseconds: 80); + + /// Waiting out the 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(milliseconds: 240); const sendTimeout = Duration(seconds: 8); setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; channel = const MethodChannel('meshmapper/watch_move_test'); - bridge = WatchBridgeService(channel: channel); + bridge = WatchBridgeService( + channel: channel, + minimumNonUrgentInterval: throttle, + ); sent = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { From bb354809ea18e931fa9749871bd133321f189bbc Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 09:10:19 -0700 Subject: [PATCH 261/360] Let an unshared clock degrade instead of disabling the wrist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two devices do not share a clock and nothing makes them agree. Every phone-stamped time the watch read, and every watch-stamped time the phone read back, was compared across that gap with five seconds of slack — so a larger disagreement did not degrade. It refused every timestamped command and marked every payload stale, for as long as the skew lasted. A live sendMessage crosses in milliseconds, so the phone's updatedAt on one of those is effectively a reading of the phone's clock taken now. The watch learns the offset from that and corrects by it: snapshot age, cue age, and — sent alongside each command as the additive optional clockOffsetMs — the phone's transmit-age window. Learned only from live deliveries. An application context can sit retained for hours, so its timestamp says nothing about the current offset. Not folded into issuedAtMs, deliberately. That value doubles as the ordering key for map-geo suppression claims, and rewriting it into phone time would make the key jump backwards the first time an offset was learned — turning a monotonic sequence into one where a newer claim can look older than the watermark and be ignored for the rest of the session. Absent offset means zero, so an older watch build and a watch that has not yet seen a live delivery behave exactly as before. The five seconds now cover the residual — transit and measurement error — rather than the skew itself, and a genuinely stale command is still refused once the clocks agree. --- ios/MeshMapperWatch/WatchSessionClient.swift | 67 ++++++++++++++++---- ios/Shared/MeshMapperWatchPayload.swift | 18 ++++++ lib/services/watch/watch_bridge_service.dart | 22 ++++++- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index c9200196..99deb8f6 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -70,6 +70,25 @@ final class WatchSessionClient: NSObject { /// Set when a payload arrives from a wire version this build predates. private(set) var versionMismatch = false + /// Phone clock minus watch clock, learned from a live delivery. + /// + /// The two devices do not share a clock, and nothing guarantees they agree. + /// Every phone-stamped time this class reads — how old a snapshot is, how old + /// a cue is — and every watch-stamped time the phone reads back was compared + /// across that gap with five seconds of slack, so a larger disagreement did + /// not degrade: it refused every command and marked every payload stale, for + /// as long as the skew lasted. + /// + /// A live `sendMessage` crosses in milliseconds, so the phone's `updatedAt` + /// on one of those is effectively a reading of the phone's clock taken now. + /// The difference from the local clock is the offset, and correcting by it + /// turns an unshared clock back into a measurement problem with a small + /// residual — which is what the five seconds were always meant to cover. + /// + /// Nil until a live delivery has been seen, where every comparison falls back + /// to the raw timestamps exactly as before. + private(set) var clockOffset: TimeInterval? + /// 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. @@ -222,7 +241,12 @@ final class WatchSessionClient: NSObject { mapGeoNeeded: mapGeoNeeded, forceRefresh: forceRefresh ? true : nil, id: UUID().uuidString, - issuedAtMs: Date().timeIntervalSince1970 * 1000 + issuedAtMs: Date().timeIntervalSince1970 * 1000, + // Sent alongside the watch-clock stamp rather than folded into it: the + // phone needs this to measure age, but uses the raw stamp to order + // map-geo claims, and rewriting that key would make it jump backwards + // the first time an offset is learned. + clockOffsetMs: clockOffset.map { $0 * 1000 } ) guard let data = try? MeshMapperWatchWire.encoder.encode(command), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] @@ -377,7 +401,9 @@ final class WatchSessionClient: NSObject { // 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 + producedAt + .map { inLocalTime($0) } + .map { min(arrival, $0.addingTimeInterval(Self.clockTolerance)) } ?? arrival receivedAt = origin let remaining = Self.staleAfter - arrival.timeIntervalSince(origin) @@ -397,6 +423,12 @@ final class WatchSessionClient: NSObject { } } + /// A phone-stamped instant expressed in this watch's clock. + private func inLocalTime(_ phoneInstant: Date) -> Date { + guard let clockOffset else { return phoneInstant } + return phoneInstant.addingTimeInterval(-clockOffset) + } + /// How much of a cue is still worth presenting, given its age. private enum CuePresentation { /// Recent enough that the wearer can connect the buzz to what they did. @@ -418,17 +450,17 @@ final class WatchSessionClient: NSObject { /// the screen: past it the wearer is already being told the whole surface is /// old, and a banner asserting a stale failure as current would be its own /// lie. - private static func presentation( + private func presentation( for cue: WatchHapticCue, at arrival: Date ) -> CuePresentation { guard let issuedAt = cue.issuedAt else { return .drop } - let age = arrival.timeIntervalSince(issuedAt) + let age = arrival.timeIntervalSince(inLocalTime(issuedAt)) // A few seconds of skew must not suppress a real failure that has just // crossed the radio. - guard age >= -clockTolerance else { return .drop } - if age <= cueFreshFor { return .feel } - return age <= staleAfter ? .read : .drop + guard age >= -Self.clockTolerance else { return .drop } + if age <= Self.cueFreshFor { return .feel } + return age <= Self.staleAfter ? .read : .drop } /// The main-actor half of the activation callback. @@ -546,14 +578,17 @@ final class WatchSessionClient: NSObject { ingest(data: data) } - private nonisolated func ingest(data: Data) { + /// - Parameter live: whether this arrived by `sendMessage`, whose transit is + /// milliseconds. Only those can date the phone's clock; an application + /// context may have been retained for hours before this process read it. + private nonisolated func ingest(data: Data, live: Bool = false) { switch Self.decode(data) { case .undecodable: return case .unsupportedVersion: Task { @MainActor [weak self] in self?.versionMismatch = true } case .snapshot(let decoded): - Task { @MainActor [weak self] in self?.apply(decoded) } + Task { @MainActor [weak self] in self?.apply(decoded, live: live) } } } @@ -576,8 +611,14 @@ final class WatchSessionClient: NSObject { } } - private func apply(_ decoded: WatchSnapshot) { + private func apply(_ decoded: WatchSnapshot, live: Bool = false) { let arrival = Date() + + // Learned before anything below reads a phone-stamped time. + if live { + clockOffset = decoded.updatedAt.timeIntervalSince(arrival) + } + versionMismatch = false snapshot = decoded markSnapshotReceived(at: arrival, producedAt: decoded.updatedAt) @@ -602,7 +643,7 @@ final class WatchSessionClient: NSObject { } if let cue = decoded.cue, - case let presentation = Self.presentation(for: cue, at: arrival), + case let presentation = presentation(for: cue, at: arrival), presentation != .drop, presentedCueIDs.insert(cue.id).inserted { @@ -666,7 +707,9 @@ extension WatchSessionClient: WCSessionDelegate { nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { guard let data = message[MeshMapperWatchWire.payloadKey] as? Data else { return } - ingest(data: data) + // The one delivery path whose transit is short enough to date the phone's + // clock against this one. + ingest(data: data, live: true) } /// The documented signal for `isReachable` changing. Without it the property diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 337e5a8b..3c92b5c5 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -397,7 +397,25 @@ struct WatchCommand: Codable, Hashable { let id: String /// Queued delivery can outlive the place where a transmit was requested. /// The phone uses this to reject stale actions before admission. + /// + /// Stamped in the *watch's* clock, deliberately. It doubles as the ordering + /// key for map-geo suppression claims, and rewriting it into phone time + /// would make that key jump the moment an offset is first learned — turning + /// a monotonic sequence into one where a newer claim can look older. let issuedAtMs: Double + /// The watch's estimate of phone-clock minus watch-clock, in milliseconds. + /// + /// Optional and additive, so an older phone ignores it and behaves exactly as + /// before. It lets the phone measure this command's *age* in its own clock + /// without the two devices having to agree on what time it is: an unshared + /// clock is a normal condition, and the transmit-age window used to turn any + /// disagreement past five seconds into "every command refused" rather than + /// into a slightly wider margin. + /// + /// Learned only from a live `sendMessage`, whose transit is milliseconds. An + /// application context can sit retained for hours, so its timestamp says + /// nothing about the current offset. + let clockOffsetMs: Double? } // There is deliberately no acknowledgement model: queued commands have no diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 0fe2ae4e..ad660906 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -95,6 +95,11 @@ class WatchBridgeService { static const Duration defaultMapGeoClaimFreshFor = Duration(minutes: 10); static const Duration _maximumCommandAge = Duration(seconds: 30); + + /// Residual slack after the watch's own clock-offset correction, not the + /// whole budget for two devices disagreeing about the time. A watch that has + /// measured the offset sends it with every command, so what has to fit inside + /// this is transit and measurement error — not the skew itself. static const Duration _clockTolerance = Duration(seconds: 5); final MethodChannel _channel; @@ -225,9 +230,24 @@ class WatchBridgeService { final rawIssuedAtMs = args['issuedAtMs']; final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; + + // The two devices do not share a clock. `issuedAtMs` is stamped in the + // watch's, and the watch tells us how far that runs from ours when it has + // been able to measure it — from a live `sendMessage`, whose transit is + // milliseconds. Correcting by it means the age below is a real elapsed + // time rather than an elapsed time plus however far the clocks disagree, + // which used to turn any skew past `_clockTolerance` into "every command + // refused" instead of a slightly wider margin. + // + // Absent from older watch builds, and from a watch that has not yet seen a + // live delivery. Zero then, which is exactly the previous behaviour. + final rawClockOffsetMs = args['clockOffsetMs']; + final clockOffsetMs = + rawClockOffsetMs is num ? rawClockOffsetMs.toDouble() : 0.0; final ageMs = issuedAtMs == null ? null - : DateTime.now().millisecondsSinceEpoch - issuedAtMs; + : DateTime.now().millisecondsSinceEpoch - + (issuedAtMs + clockOffsetMs); final requestedMapGeo = args['mapGeoNeeded']; final mapGeoNeeded = requestedMapGeo is bool ? requestedMapGeo : null; final freshMapGeoSuppression = ageMs != null && From 34a2f1de5f73e7e184e73a885d5068829e0e3e1a Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 09:10:45 -0700 Subject: [PATCH 262/360] Retire the legacy command transport, and make the ack tell the truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the review flagged, and they turn out to be the same thing. The phone kept two didReceiveMessage overloads for commands sent by watch builds predating the queued transport. No such build has ever shipped — the companion app is landing for the first time with the queued path already in place, and WatchSessionClient.send calls transferUserInfo exclusively. So they were a second entry point into transmit admission serving nobody, and the part of the system worth being strict about is exactly the one that reaches the radio. The queued path is deliberate, not incidental: sendMessage can execute a command and still fail its reply as undeliverable, leaving the wrist unable to tell a refusal from a lost ack. Keeping a shim for the transport that has that flaw is the wrong direction. That removal also takes away the only place the redelivery ack was observable, which is where the second half comes in. The bridge answered any redelivery with accepted: true regardless of what it had recorded, so the redelivery of a refused command described a transmit that never happened. Outcomes are now recorded per ID and echoed, bounded exactly as the ID set was. Fixed rather than left to rot with the path removed: a future reply channel would otherwise inherit a wrong answer. The NSNull sanitising added earlier in this branch goes with the reply path it existed for — the remaining queued path discards the reply, so nothing can hand NSNull to WCSession any more. --- ios/Runner/WatchSessionManager.swift | 70 +++++++------------- lib/services/watch/watch_bridge_service.dart | 41 ++++++++---- 2 files changed, 53 insertions(+), 58 deletions(-) diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index 2177fb99..8b8714c1 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -194,19 +194,6 @@ final class WatchSessionManager: NSObject { } } - /// Drop the keys whose value is Dart `null`. - /// - /// The standard method codec decodes Dart's `null` as `NSNull`, which is not - /// a property-list type, and `WCSession`'s `replyHandler` raises on anything - /// that isn't. Every *accepted* command replies `{"accepted": true, "reason": - /// null}`, so without this the legacy reply path failed on precisely its - /// success case — the one case the overload is kept around to serve. An - /// absent key reads the same as a null one on the watch, which decodes the - /// reply into optionals. - private static func propertyListSafe(_ dict: [String: Any]) -> [String: Any] { - dict.filter { !($0.value is NSNull) } - } - private func flutterError(_ error: Error, code: String) -> FlutterError { FlutterError( code: code, @@ -217,13 +204,16 @@ final class WatchSessionManager: NSObject { // 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) { + /// Relays a command to Dart. Dart owns the synchronous admission decision and + /// starts accepted work separately; this side never evaluates whether a + /// transmit is legal or waits for BLE/network completion. + /// + /// The ack is logged rather than returned. Queued delivery has no reply + /// channel to return it on, which is the whole reason the wire carries + /// outcomes as snapshots and one-shot cues instead. + private func relayCommand(_ payload: [String: Any]) { guard let channel else { NSLog("[WATCH] Command dropped: no method channel") - reply(["accepted": false, "reason": "App not ready"]) return } @@ -235,11 +225,12 @@ final class WatchSessionManager: NSObject { 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(Self.propertyListSafe(dict)) + NSLog( + "[WATCH] Command \(kind) acked: accepted=\(dict["accepted"] ?? "?") " + + "reason=\(dict["reason"] ?? "none")" + ) } else { NSLog("[WATCH] Command \(kind) got no response from Dart") - reply(["accepted": false, "reason": "No response"]) } } } @@ -296,31 +287,18 @@ extension WatchSessionManager: WCSessionDelegate { NSLog("[WATCH] Malformed queued command payload: \(Array(userInfo.keys))") return } - relayCommand(command) { _ in } + relayCommand(command) } - /// 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 } - } } + +// The `didReceiveMessage` overloads that used to sit here are gone. They +// existed for watch builds predating the queued transport, and no such build +// has ever shipped — the companion app is landing for the first time with the +// queued path already in place, and `WatchSessionClient.send(_:)` calls +// `transferUserInfo` exclusively. +// +// Removing them takes away a second entry point into transmit admission, which +// is the part worth being strict about: the queued path is deliberate, because +// `sendMessage` can execute a command and still fail its reply as +// undeliverable, leaving the wrist unable to tell a refusal from a lost ack. diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index ad660906..896a6970 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -152,8 +152,10 @@ class WatchBridgeService { double? _lastMapGeoClaimIssuedAtMs; Future _operationChain = Future.value(); - /// Commands already handled, so redelivery can't fire a second transmit. - final Set _handledCommandIds = {}; + /// Outcome of every command already handled, keyed by ID, so redelivery + /// cannot fire a second transmit — and is answered with what actually + /// happened rather than a blanket acceptance. Null means accepted. + final Map _handledCommandOutcomes = {}; bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; @@ -212,8 +214,14 @@ class WatchBridgeService { } // WatchConnectivity redelivers; a duplicate must not transmit twice. - if (_handledCommandIds.contains(id)) { - return {'id': id, 'accepted': true, 'reason': null}; + // + // Answered with the outcome recorded the first time. Replying "accepted" + // to the redelivery of something this bridge refused would describe a + // transmit that never happened, and be wrong in exactly the case the + // caller most needs the truth. + if (_handledCommandOutcomes.containsKey(id)) { + final refusal = _handledCommandOutcomes[id]; + return {'id': id, 'accepted': refusal == null, 'reason': refusal}; } final kind = WatchCommandKind.fromWire(rawKind); @@ -226,7 +234,7 @@ class WatchBridgeService { return {'id': id, 'accepted': false, 'reason': 'App not ready'}; } - _rememberCommandId(id); + _rememberCommand(id); final rawIssuedAtMs = args['issuedAtMs']; final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; @@ -293,6 +301,7 @@ class WatchBridgeService { // 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. + _rememberCommand(id, refusal: reason); _commandRefusalHandler?.call(reason); return {'id': id, 'accepted': false, 'reason': reason}; } @@ -311,17 +320,20 @@ class WatchBridgeService { )); final refusal = admission is Future ? await admission : admission; + _rememberCommand(id, refusal: refusal); 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 + // An untimestamped command cannot be aged, so it is also the one whose + // sender may legitimately retry it under changed conditions. Queued, + // timestamped commands must stay remembered: redelivery after // conditions change must never turn yesterday's tap into a transmit. - if (issuedAtMs == null) _handledCommandIds.remove(id); + if (issuedAtMs == null) _handledCommandOutcomes.remove(id); } return {'id': id, 'accepted': refusal == null, 'reason': refusal}; } catch (error) { debugError('[WATCH] Command $rawKind failed: $error'); const reason = 'Command failed'; + _rememberCommand(id, refusal: reason); _commandRefusalHandler?.call(reason); return {'id': id, 'accepted': false, 'reason': reason}; } @@ -404,11 +416,16 @@ class WatchBridgeService { ); } - void _rememberCommandId(String id) { - _handledCommandIds.add(id); + /// Record a command and, once known, what it resolved to. + /// + /// Called before admission so a re-entrant redelivery cannot slip past the + /// dedupe, then again with the outcome. Updating an existing key leaves the + /// insertion order alone, so the bound below still evicts the oldest. + void _rememberCommand(String id, {String? refusal}) { + _handledCommandOutcomes[id] = refusal; // Unbounded growth would leak across a long session. - if (_handledCommandIds.length > 64) { - _handledCommandIds.remove(_handledCommandIds.first); + if (_handledCommandOutcomes.length > 64) { + _handledCommandOutcomes.remove(_handledCommandOutcomes.keys.first); } } From a541b13cd66b2c7de89e219f669ab3c28d0d34e8 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 09:10:58 -0700 Subject: [PATCH 263/360] Stop asking a host that can never show a Live Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An iPhone below iOS 16.2 answered sync the same way as one whose owner had switched Live Activities off, so it earned the same 30 s retry — a timer, a build and a channel round trip every half minute for the whole session, on a device that was never going to display anything. The OS version does not change while the app is running; authorization does, which is why that one keeps its backoff. Native now answers "unsupported" for the version guard, and Dart stops scheduling for the rest of the process. A MissingPluginException is the same permanent condition — an iOS project generated before this feature existed has no such channel and will not grow one — so it stops too, where it used to be swallowed and retried. --- ios/Runner/LiveActivityManager.swift | 13 ++- .../live_activity/live_activity_service.dart | 43 +++++++++- .../live_activity_service_test.dart | 86 +++++++++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index c4cec686..ffd2c0c8 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -7,6 +7,12 @@ import Foundation /// 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. +/// Sentinels shared with the Dart side of the channel. +enum MeshMapperLiveActivityStatus { + /// This OS cannot host a Live Activity at all, at any point in this session. + static let unsupported = "unsupported" +} + final class LiveActivityManager { private enum BridgeError: LocalizedError { case invalidArguments @@ -25,10 +31,15 @@ final class LiveActivityManager { result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments")) return } + // Distinct from `false`, which means "not right now" and earns a retry. + // This host will never gain ActivityKit, so a retry loop for the life of + // every session is pure waste; Dart stops asking for good. guard #available(iOS 16.2, *) else { - result(false) + result(MeshMapperLiveActivityStatus.unsupported) return } + // Genuinely "not right now": the wearer can enable Live Activities in + // Settings while a session is running, so this one keeps its backoff. guard ActivityAuthorizationInfo().areActivitiesEnabled else { result(false) return diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart index 3ed6fa42..8feddb31 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -51,6 +51,11 @@ class LiveActivityService { /// updates that change nothing. static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 15); + /// Native's answer when the host can never show a Live Activity — iOS below + /// 16.2. Distinct from `false`, which means "not right now" and is what the + /// authorization backoff exists for. + static const String unsupportedHost = 'unsupported'; + final MethodChannel _channel; final Duration _unavailableRetryDelay; final Duration _minimumNonUrgentInterval; @@ -77,6 +82,15 @@ class LiveActivityService { bool _bypassBuildFloor = false; bool _disposed = false; bool _didReconcileNativeState = false; + + /// Set once native reports a condition that cannot change while this process + /// lives: an OS without ActivityKit, or a host with no such channel at all. + /// + /// Before this, both answered every 30 s retry with the same guaranteed + /// failure, and kept doing it for the whole session — a timer, a build and a + /// channel round trip apiece, on devices that were never going to show + /// anything. + bool _hostCannotHostActivities = false; Future _operationChain = Future.value(); bool get isSupportedPlatform => @@ -87,7 +101,7 @@ class LiveActivityService { required LiveActivityUrgencyKeyBuilder urgencyKeyBuilder, bool immediate = false, }) { - if (_disposed || !isSupportedPlatform) return; + if (_disposed || !isSupportedPlatform || _hostCannotHostActivities) return; _pendingSnapshotBuilder = snapshotBuilder; _pendingUrgencyKeyBuilder = urgencyKeyBuilder; @@ -113,7 +127,7 @@ class LiveActivityService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; - if (_disposed || !isSupportedPlatform) return; + if (_disposed || !isSupportedPlatform || _hostCannotHostActivities) return; // The 500 ms countdown listenable drives this at ~2 Hz for a whole session, // and building a snapshot resolves the latest ping colour by walking the @@ -195,6 +209,10 @@ class LiveActivityService { try { final result = await _channel.invokeMethod('sync', payload); _didReconcileNativeState = true; + if (result == unsupportedHost) { + _stopForUnsupportedHost('iOS 16.2 is required for Live Activities'); + return; + } if (result == false) { _unavailableSessionId = snapshot.sessionId; _unavailableRetryAt = DateTime.now().add(_unavailableRetryDelay); @@ -209,7 +227,10 @@ class LiveActivityService { _lastUrgencyKey = snapshot.urgencyKey; _lastSentAt = DateTime.now(); } on MissingPluginException { - // Expected on non-iOS test hosts and older generated iOS projects. + // A host with no such channel — a non-iOS test host, or an iOS project + // generated before this feature existed. Neither gains one at runtime, + // so this is the same permanent condition as an OS below 16.2. + _stopForUnsupportedHost('no Live Activity channel on this host'); } on PlatformException catch (error) { debugError( '[LIVE ACTIVITY] ActivityKit sync failed: ' @@ -220,6 +241,22 @@ class LiveActivityService { } } + /// Stop asking, permanently. Nothing about the condition can change without + /// the app being relaunched, at which point this starts out false again. + void _stopForUnsupportedHost(String reason) { + if (_hostCannotHostActivities) return; + _hostCannotHostActivities = true; + _didReconcileNativeState = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _pendingSnapshotBuilder = null; + _pendingUrgencyKeyBuilder = null; + _unavailableSessionId = null; + _unavailableRetryAt = null; + _bypassBuildFloor = false; + debugLog('[LIVE ACTIVITY] Disabled for this session: $reason'); + } + Future _endCurrentActivity({required bool immediate}) async { try { await _channel.invokeMethod( diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart index b27470c0..8b2bbcfd 100644 --- a/test/services/live_activity/live_activity_service_test.dart +++ b/test/services/live_activity/live_activity_service_test.dart @@ -126,6 +126,92 @@ void main() { expect(phases, ['listening_discovery', 'waiting_discovery']); }); + test('an unsupported host is asked once, not every 30 s for a session', + () async { + // iOS below 16.2 cannot show a Live Activity and never will while the app + // is running, but it answered the same way as "authorization is off" — so + // it earned the same 30 s retry, forever, on a device that was never going + // to display anything. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_unsupported_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + syncCalls++; + return LiveActivityService.unsupportedHost; + }); + final service = LiveActivityService( + channel: channel, + unavailableRetryDelay: const Duration(milliseconds: 20), + minimumNonUrgentInterval: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule( + _snapshot, + urgencyKeyBuilder: () => _preflightKey(_snapshot()), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 40)); + expect(syncCalls, 1); + + // Everything that would normally re-arm it: the retry it used to schedule, + // and a caller that keeps scheduling regardless. + for (var tick = 0; tick < 4; tick++) { + service.schedule( + () => _snapshot(rxCount: tick), + urgencyKeyBuilder: () => + _preflightKey(_snapshot(phase: LiveActivityPhase.waitingDiscovery)), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 30)); + } + + expect(syncCalls, 1, + reason: 'a host that cannot host one must be asked exactly once'); + }); + + test('a missing channel also stops for good', () async { + // The other permanent condition: an iOS project generated before this + // feature existed has no such channel, and will not grow one at runtime. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_missing_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'sync') syncCalls++; + throw MissingPluginException('no channel'); + }); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + for (var tick = 0; tick < 4; tick++) { + service.schedule( + () => _snapshot(rxCount: tick), + urgencyKeyBuilder: () => _preflightKey(_snapshot(rxCount: tick)), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 30)); + } + + expect(syncCalls, 1, + reason: 'a host with no channel must be asked exactly once'); + }); + test('a throttled flush costs nothing to build', () async { // The 500 ms countdown listenable asks for a flush twice a second for the // length of a session, and building a snapshot walks the whole TX, RX, From cc0a4c0561dd48a5705da1da31b681af0eac3b3d Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 09:10:58 -0700 Subject: [PATCH 264/360] Show the watch diagnostics when nothing is working, not only when it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry required paired to have been observed true at least once, which got the purpose backwards: the screen explains why the phone is or is not talking to a watch, so the case it most needs to be reachable in is the one where nothing works. A session whose activation failed reports paired: false for a phone that may well have a watch on the wrist, and the entry then stayed invisible for exactly the wearer who needed it. It now also appears when WatchConnectivity is supported but the session never came up. That is not a claim a watch exists — only an admission that we cannot say it does not. A healthy session reporting no watch still hides it, which is the common case on an iPhone with no Apple Watch. Moved beside the other resolvers so the rule is testable, and DEVELOPMENT.md picks up the invariants this branch changed: the clock-offset correction, the single command transport, the redelivery echo, per-length heard-row resolution, and the three-way Live Activity host answer. --- DEVELOPMENT.md | 45 ++++- lib/providers/app_state_provider.dart | 13 +- lib/services/watch/watch_models.dart | 30 ++++ .../watch/watch_wire_contract_test.dart | 154 +++++++++++++++++- 4 files changed, 230 insertions(+), 12 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 00c34404..d663e179 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -455,17 +455,35 @@ renews it every 5 minutes. A lost command therefore fails safe — toward sendin too much rather than a permanently blank map. Renewals are deduplicatable; only a stated `forceRefresh` defeats the payload fingerprint. -**Command admission.** Wrist commands are intent, revalidated by the phone: +**Command admission.** Wrist commands are intent, revalidated by the phone. +`transferUserInfo` is the *only* transport — there is deliberately no +`sendMessage` path into admission, because that can execute a command and still +fail its reply as undeliverable, leaving the wrist unable to tell a refusal from +a lost ack. - IDs make WatchConnectivity's redelivery idempotent. A *queued* command refused once stays remembered — redelivery after conditions change must never turn - yesterday's tap into a transmit. Only the untimestamped legacy path forgets. + yesterday's tap into a transmit. Only an untimestamped command forgets, since + it cannot be aged and its sender may legitimately retry. +- A redelivery is answered with the **outcome recorded the first time**, not a + blanket acceptance. Replying "accepted" to the redelivery of something the + bridge refused describes a transmit that never happened. - Timestamped commands must land inside `_maximumCommandAge` (30 s), with - `_clockTolerance` (5 s) of slack in both directions. A fast watch clock is the - same bug as a slow queue. -- `requestSnapshot` and `stopSession` are exempt from that window: one transmits - nothing, and the other takes the radio *off* air, so lateness can only make - refusing it worse. + `_clockTolerance` (5 s) of slack in both directions. +- **The two devices do not share a clock, and that is a normal condition.** + `issuedAtMs` is stamped in the watch's clock and the command carries + `clockOffsetMs` beside it, so the phone measures a real elapsed age rather + than an age plus the skew. The watch learns that offset only from a live + `sendMessage`, whose transit is milliseconds; an application context may have + sat retained for hours and says nothing about the current offset. Absent + offset means zero, which is the old behaviour exactly. `_clockTolerance` now + covers the residual — transit and measurement error — not the skew itself. + The offset is *not* folded into `issuedAtMs`, because that value doubles as + the ordering key for map-geo suppression claims and rewriting it would make + the key jump backwards the first time an offset is learned. +- `requestSnapshot` and `stopSession` are exempt from the age window: one + transmits nothing, and the other takes the radio *off* air, so lateness can + only make refusing it worse. - `resolveSessionStartAvailability` is the single start gate for both the offered button and the admitted command. **Passive counts as transmitting** — it sends a discovery request on start and every 30 s — so the manual-ping, @@ -486,6 +504,19 @@ app" prompt instead of going silently stale. `WatchGeoBuilder` on the sending side, after merging every source and sorting by recency, so a busy TX history cannot erase discovery or trace markers. +**Heard-row names** are resolved per *distinct* hex-prefix length. The RX slot's +path hash can be a different width than the top rows' — they come from different +pings, and the zone's hop-byte count sets the width — so a single-length index +silently drops the odd row's name and distance. `resolveUniqueHexPrefixes` is +the only correct entry point. + +**Live Activity host support.** ActivityKit answers `sync` with `true`, `false`, +or `"unsupported"`. `false` means *not right now* — authorization is off, or +`Activity.request` was refused because the app is backgrounded — and earns the +30 s backoff. `"unsupported"` (and a `MissingPluginException`) means this host +can never show one, and Dart stops asking for the rest of the process rather +than running a guaranteed-fail retry loop all session. + ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` - RX Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (write to device) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4d6c6d4c..7c154e3a 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -570,9 +570,16 @@ 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); + bool get shouldShowWatchDiagnostics { + final status = _watchBridge.diagnostics.value; + return resolveShouldShowWatchDiagnostics( + isSupportedPlatform: _watchBridge.isSupportedPlatform, + supported: status.supported, + paired: status.paired, + activated: status.activated, + hasEverPaired: _hasEverPairedWatch, + ); + } TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index e44b0728..341f864e 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -510,6 +510,36 @@ WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ return (mode: mode, refusal: null); } +/// Whether Settings should offer the watch diagnostics screen. +/// +/// The screen explains why the phone is or is not talking to a watch, so the +/// case it most needs to be reachable in is the one where nothing is working. +/// Requiring [paired] to have been observed true got that exactly backwards: +/// a session whose activation failed reports `paired: false` for a phone that +/// may well have a watch on the wrist, and the entry then stayed invisible for +/// precisely the wearer who needed it. +/// +/// So it appears when a watch can be seen or has ever been seen, and also when +/// WatchConnectivity is supported but the session never came up — which is not +/// a claim that a watch exists, only an admission that we cannot say it does +/// not. A healthy session reporting no watch hides it, which is the common +/// case on an iPhone with no Apple Watch at all. +/// +/// [activated] is briefly false while activation is in flight at launch. That +/// window is not reachable from here: the provider activates during its own +/// initialization, long before Settings can be pushed. +bool resolveShouldShowWatchDiagnostics({ + required bool isSupportedPlatform, + required bool supported, + required bool paired, + required bool activated, + required bool hasEverPaired, +}) { + if (!isSupportedPlatform) return false; + if (paired || hasEverPaired) return true; + return supported && !activated; +} + /// Which start modes the wrist may offer. /// /// Advertisement, not admission. Transient conditions — a ping in flight, a diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 145683e7..f863814d 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -528,6 +528,42 @@ void main() { expect(result, (mode: null, refusal: null)); }); + test('diagnostics stay reachable exactly when they are needed', () { + bool show({ + bool isSupportedPlatform = true, + bool supported = true, + bool paired = false, + bool activated = true, + bool hasEverPaired = false, + }) => + resolveShouldShowWatchDiagnostics( + isSupportedPlatform: isSupportedPlatform, + supported: supported, + paired: paired, + activated: activated, + hasEverPaired: hasEverPaired, + ); + + // The case the old rule got backwards: activation failed, so `paired` + // reads false for a phone that may well have a watch — and the screen + // that would explain the failure was the thing being hidden. + expect(show(activated: false), isTrue, + reason: 'a session that never came up cannot deny a watch exists'); + + // A healthy session that genuinely sees no watch. The common iPhone. + expect(show(), isFalse); + + // Seen now, or seen once. Pairing history stays one-way on purpose: an + // unpaired watch is when this is most useful. + expect(show(paired: true), isTrue); + expect(show(hasEverPaired: true), isTrue); + + // Not an iOS host at all, and a host without WatchConnectivity. + expect(show(isSupportedPlatform: false, activated: false), isFalse); + expect(show(supported: false, activated: false), isFalse, + reason: 'no WatchConnectivity means no watch to diagnose'); + }); + test('the wrist is never offered a mode every start would refuse', () { // Hybrid used to be advertised whenever the phone was connected and TX // was allowed, ignoring Offline Mode — where sessionStartAvailability @@ -713,6 +749,7 @@ void main() { String? mode, bool? mapGeoNeeded, double? issuedAtMs, + double? clockOffsetMs, bool? forceRefresh, }) async { final result = await TestDefaultBinaryMessengerBinding @@ -726,6 +763,7 @@ void main() { if (mode != null) 'mode': mode, if (mapGeoNeeded != null) 'mapGeoNeeded': mapGeoNeeded, if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, + if (clockOffsetMs != null) 'clockOffsetMs': clockOffsetMs, if (forceRefresh != null) 'forceRefresh': forceRefresh, }), ), @@ -1334,6 +1372,35 @@ void main() { reason: 'WatchConnectivity redelivers; a second ping must not fire'); }); + test('a redelivered acceptance still acks as accepted', () async { + // The other half of the echo: an accepted command must not start + // reporting itself refused just because the conditions moved on. + var refuse = false; + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return refuse ? 'Not connected' : null; + }); + + final issuedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + final first = await sendCommand( + 'queued-3', + 'manualPing', + issuedAtMs: issuedAtMs, + ); + expect(first?['accepted'], isTrue); + + refuse = true; + final redelivered = await sendCommand( + 'queued-3', + 'manualPing', + issuedAtMs: issuedAtMs, + ); + + expect(handled, hasLength(1)); + expect(redelivered?['accepted'], isTrue); + expect(redelivered?['reason'], isNull); + }); + test('a refused command may be retried once conditions change', () async { var refuse = true; bridge.attachCommandHandler((command) async { @@ -1383,8 +1450,10 @@ void main() { expect(handled, hasLength(1), reason: 'redelivery must never turn yesterday\'s tap into a ' 'transmit'); - expect(redelivered?['accepted'], isTrue, - reason: 'the dedupe reply acks delivery, not a fresh admission'); + expect(redelivered?['accepted'], isFalse, + reason: 'the ack must describe what happened, not what was hoped'); + expect(redelivered?['reason'], 'Not connected', + reason: 'the recorded outcome is echoed, not recomputed'); }); // transferUserInfo keeps a tapped command alive until the phone is @@ -1473,6 +1542,87 @@ void main() { expect(handled, hasLength(1)); }); + test('a skewed watch clock degrades, it does not refuse everything', + () async { + // The failure this replaces: two devices that disagree about the time + // by more than the tolerance had every timestamped command refused, for + // as long as the skew lasted. An unshared clock is a normal condition, + // not a reason to make the wrist inert. + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + // A watch running four minutes fast. Without the offset this stamp is + // 240 s in the future and fails the lower bound outright. + const skew = Duration(minutes: 4); + final reply = await sendCommand( + 'skewed-fast', + 'manualPing', + issuedAtMs: nowMs() + + skew.inMilliseconds - + const Duration(seconds: 2).inMilliseconds, + clockOffsetMs: -skew.inMilliseconds.toDouble(), + ); + + expect(reply?['accepted'], isTrue, + reason: 'a measured offset must make the age a real elapsed time'); + expect(handled, [WatchCommandKind.manualPing]); + }); + + test('the offset corrects the age, it does not excuse a stale command', + () async { + // The window still has to mean something: a genuinely old command is + // still old once the clocks agree, and correcting for skew must not + // become a way to smuggle yesterday's tap onto the radio. + final refusals = []; + bridge.attachCommandHandler( + (command) async { + handled.add(command.kind); + return null; + }, + onRefusal: refusals.add, + ); + + const skew = Duration(minutes: 4); + final reply = await sendCommand( + 'skewed-and-stale', + 'manualPing', + issuedAtMs: nowMs() + + skew.inMilliseconds - + const Duration(seconds: 45).inMilliseconds, + clockOffsetMs: -skew.inMilliseconds.toDouble(), + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Took too long to reach iPhone'); + expect(handled, isEmpty); + expect(refusals, ['Took too long to reach iPhone']); + }); + + test('an older watch build sending no offset behaves exactly as before', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final fresh = await sendCommand( + 'no-offset-fresh', + 'manualPing', + issuedAtMs: nowMs() - const Duration(seconds: 5).inMilliseconds, + ); + expect(fresh?['accepted'], isTrue); + + final stale = await sendCommand( + 'no-offset-stale', + 'manualPing', + issuedAtMs: nowMs() - const Duration(seconds: 31).inMilliseconds, + ); + expect(stale?['accepted'], isFalse, + reason: 'absent offset must mean zero, not unbounded tolerance'); + }); + test('an aged requestSnapshot is exempt from the transmit window', () async { bridge.attachCommandHandler((command) async { From c7e3af4eee3ae1f32bfa735a6fe9746ea484f19a Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 09:44:52 -0700 Subject: [PATCH 265/360] Name heard rows from the identity the ping carried, not the hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heard page already renders names and the wire already carries them, so the "Name unresolved — short path hash" rows were never a bandwidth problem. They were a resolution problem: the phone resolved names from the truncated path hash even when it was holding a far better identity for the same node. A discovery response carries the responder's full 64-character public key. _updateTopRepeaters mapped it to (repeaterId, snr) and dropped the pubkey on the floor — the field whose own comment says it exists for exact repeater matching — then resolved from two hex characters. A trace carries a 4-byte target, which was deliberately shortened to three bytes to fit the overlay and then resolved from the shortened form, throwing away a byte of certainty for no reason: truncating is a presentation decision, not an identity one. With one hop byte that hash is 2 characters over 256 values, so in a dense zone most rows collided and resolveUniqueHexPrefixes correctly refused to guess. It was refusing questions it did not have to be asked. Identities now travel beside the overlay rows and are used first, falling back to the existing prefix rules for the rows that never had anything better. TX echoes and passive RX are unchanged — the packet genuinely carries only the path byte, so their ambiguity is real and the refusal stays. Zero additional bytes on the wire: name already existed on WatchHeardNode, and all of this happens before the payload is built. The Live Activity and the watch's status panel resolve through the same map, so they gain the same names. Two invariants held deliberately. The map is replaced per ping and never merged, because a hash that meant one repeater in a discovery response says nothing about who a later TX echo under it was. And uniqueness is still required at every step — a longer identity makes a collision vanishingly unlikely, not impossible. --- DEVELOPMENT.md | 25 ++++- lib/providers/app_state_provider.dart | 87 ++++++++++++--- lib/services/watch/watch_geo_builder.dart | 63 +++++++++++ .../watch/watch_geo_builder_test.dart | 105 ++++++++++++++++++ 4 files changed, 261 insertions(+), 19 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d663e179..43f4f0a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -504,11 +504,26 @@ app" prompt instead of going silently stale. `WatchGeoBuilder` on the sending side, after merging every source and sorting by recency, so a busy TX history cannot erase discovery or trace markers. -**Heard-row names** are resolved per *distinct* hex-prefix length. The RX slot's -path hash can be a different width than the top rows' — they come from different -pings, and the zone's hop-byte count sets the width — so a single-length index -silently drops the odd row's name and distance. `resolveUniqueHexPrefixes` is -the only correct entry point. +**Heard-row names** resolve from the fullest identity each row arrived with, +via `WatchGeoBuilder.resolveOverlayRepeaters`. A path hash is 1–3 bytes and in a +busy zone routinely matches several repeaters, where naming one would be a coin +flip — but the phone often knows exactly who answered: a discovery response +carries the responder's full 64-character public key, and a trace carries its +4-byte target. Those identities travel beside the overlay rows in +`_overlayIdentityById`, **replaced wholesale per ping and never merged**, because +a hash that meant one repeater in a discovery response says nothing about who a +later TX echo under the same hash was. + +TX echoes and passive RX carry only the path byte, so they fall back to prefix +matching and keep refusing to guess. That fallback indexes per *distinct* +prefix length: the RX slot's hash can be a different width than the top rows', +so a single-length index silently drops the odd row's name and distance. + +Uniqueness is required at every step. A longer identity makes a collision +vanishingly unlikely, not impossible, and a confidently wrong name stays worse +than none. **Never resolve names on the watch** — it holds only the nearest 20 +repeaters, so it would name rows the phone refused as ambiguous across the full +catalogue. **Live Activity host support.** ActivityKit answers `sync` with `true`, `false`, or `"unsupported"`. `false` means *not right now* — authorization is off, or diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7c154e3a..89cca8ae 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -259,6 +259,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; DateTime? _topRepeatersOverlayUpdatedAt; + + /// The fullest identity known for each overlay row, keyed by the display hash + /// the row is shown under. + /// + /// Discovery responses carry the responder's full 64-character public key and + /// traces carry their 4-byte target, so for those rows the phone knows + /// exactly who answered. TX echoes and passive RX carry only the path byte, + /// and are absent here because nothing better exists for them. + /// + /// Replaced wholesale by [_updateTopRepeaters], never merged, and for the + /// same reason those slots are: a hash that meant one repeater in a discovery + /// response says nothing about who a later TX echo under the same hash was. + Map _overlayIdentityById = const {}; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -642,8 +655,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. /// Replaces all 3 slots entirely (no carryover from previous pings). + /// - Parameter identities: display hash to the fullest identity this ping + /// carried for it, for the ping types that carry more than a path byte. void _updateTopRepeaters( - List<({String repeaterId, double snr})> current, OverlayPingType type) { + List<({String repeaterId, double snr})> current, OverlayPingType type, + {Map? identities}) { + _overlayIdentityById = identities ?? const {}; final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -682,6 +699,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _liveActivityRepeatersUpdatedAt = DateTime.now(); } + /// Display hash to full public key, for the discovery nodes that published + /// one. Nodes whose hash is claimed by two different keys are left out rather + /// than resolved to whichever arrived last. + Map _discoveryIdentities(List nodes) { + final identities = {}; + final contested = {}; + for (final node in nodes) { + final pubkey = node.pubkeyHex; + if (pubkey == null || pubkey.isEmpty) continue; + final displayId = node.repeaterId.toUpperCase(); + final existing = identities[displayId]; + if (existing != null && existing != pubkey.toUpperCase()) { + contested.add(displayId); + continue; + } + identities[displayId] = pubkey.toUpperCase(); + } + for (final displayId in contested) { + identities.remove(displayId); + } + return identities; + } + /// Update the RX overlay slot — window matches auto-ping interval (best SNR wins). void _updateRxOverlaySlot(String repeaterId, double snr) { final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); @@ -704,6 +744,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void _clearOverlayState() { _topRepeatersOverlay = []; _topRepeatersOverlayUpdatedAt = null; + _overlayIdentityById = const {}; _rxOverlaySlot = null; _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; @@ -1383,19 +1424,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final top = _topRepeatersOverlay; final rxSlot = _rxOverlaySlot; - // Overlay IDs are hex path hashes, resolved to names by prefix. + // Overlay IDs are hex path hashes. Resolved from the fullest identity each + // row actually arrived with — a discovery response's 64-character public + // key, a trace's 4-byte target — and only falling back to prefix matching + // for the rows that never had anything better, which is TX echoes and + // passive RX. // - // Indexed per distinct length, not at one length taken from the first row. - // The RX slot's hash can be a different width than the top rows' — the two - // come from different pings, and the zone's hop-byte count is what sets the - // width — so a single-length index silently dropped the odd row's name and - // distance while every other row resolved normally. - final repeaterByHex = WatchGeoBuilder.resolveUniqueHexPrefixes( + // The prefix fallback indexes per distinct length rather than at one length + // taken from the first row. The RX slot's hash can be a different width + // than the top rows', so a single-length index silently dropped the odd + // row's name and distance while every other row resolved normally. + final repeaterByHex = WatchGeoBuilder.resolveOverlayRepeaters( repeaters: _repeaters, - prefixes: [ + displayIds: [ ...top.map((row) => row.repeaterId), if (rxSlot != null) rxSlot.repeaterId, ], + identities: _overlayIdentityById, ); final heard = WatchGeoBuilder.buildHeard( top: top, @@ -2201,7 +2246,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } String? _resolveRepeaterDisplayName(String rawId) { - final id = rawId.toUpperCase(); + // Resolve from the longest identity this row actually came with. The + // existing exact-then-prefix rules below are already right; they were just + // never given anything better than a two-character path hash to work with. + final displayId = rawId.toUpperCase(); + final id = _overlayIdentityById[displayId]?.toUpperCase() ?? displayId; final exactMatches = _repeaters.where((repeater) { return repeater.id.toUpperCase() == id || repeater.hexId.toUpperCase() == id || @@ -3918,7 +3967,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: node.localSnr, )) .toList(growable: false); - _updateTopRepeaters(heardRepeaters, OverlayPingType.disc); + _updateTopRepeaters( + heardRepeaters, + OverlayPingType.disc, + identities: _discoveryIdentities(discPing.discoveredNodes), + ); _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); _notifyMapThrottled(); @@ -5615,11 +5668,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update top repeaters overlay with successful trace result if (entry.success && entry.localSnr != null) { - // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay + // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay. + // The untruncated ID still travels as the row's identity: shortening it + // is a presentation decision, and resolving the name from the shortened + // form threw away a byte of certainty for no reason. final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], - OverlayPingType.trace); + _updateTopRepeaters( + [(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace, + identities: {displayId: id}, + ); } _notifyMapNow(); diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 5c9c9e90..83e4a68e 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -243,6 +243,69 @@ class WatchGeoBuilder { return resolved; } + /// Resolve overlay display hashes to repeaters, preferring the fuller + /// identity the ping actually carried. + /// + /// A path hash is 1–3 bytes, so in a busy zone it routinely matches several + /// repeaters and [resolveUniqueHexPrefixes] correctly refuses to name any of + /// them. But the phone often knows exactly who answered: a discovery response + /// carries the responder's full 64-character public key, and a trace carries + /// its 4-byte target. Resolving from the truncated hash threw that away, and + /// left the wrist reading "Name unresolved" for a node the phone could have + /// named precisely. + /// + /// [identities] maps a display hash to the longest identity known for the row + /// shown under it. It must describe the **current** rows only: a hash that + /// meant one repeater in a discovery response says nothing about who a later + /// TX echo under the same hash was, and carrying it over would turn a refusal + /// to guess into a confident wrong answer. + /// + /// Uniqueness is still required. A longer identity makes a collision + /// vanishingly unlikely rather than impossible, and a confidently wrong name + /// stays worse than none. + static Map resolveOverlayRepeaters({ + required List repeaters, + required Iterable displayIds, + Map identities = const {}, + }) { + final resolved = {}; + final unresolved = []; + + for (final rawId in displayIds) { + final displayId = rawId.toUpperCase(); + if (displayId.isEmpty) continue; + + final identity = identities[displayId]?.toUpperCase(); + if (identity == null || identity.isEmpty) { + unresolved.add(displayId); + continue; + } + + // Both values are the leading hex of the same public key, so whichever + // is shorter is a prefix of the other. + final matches = repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + if (hexId.isEmpty) return false; + return hexId.startsWith(identity) || identity.startsWith(hexId); + }).toList(growable: false); + + if (matches.length == 1) { + resolved[displayId] = matches.single; + } else { + // No match at all means the catalogue simply has no hex for this + // repeater; more than one means the fuller identity did not help. + // Either way the ordinary prefix rules get their turn. + unresolved.add(displayId); + } + } + + resolved.addAll(resolveUniqueHexPrefixes( + repeaters: repeaters, + prefixes: unresolved, + )); + 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), diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 473f716f..b779679d 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -595,6 +595,111 @@ void main() { }); }); + group('resolveOverlayRepeaters — name from the identity the ping carried', + () { + // Two repeaters sharing a leading byte. With one hop byte the wrist shows + // "4E" for both, and a name resolved from that alone would be a coin flip — + // which is why the prefix rules refuse. A discovery response, though, + // carries the responder's full public key. + final capitol = _repeater( + id: '01', + hexId: '4E5D82AA11', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + ); + final beacon = _repeater( + id: '02', + hexId: '4E99F1BB22', + name: 'Beacon Hill', + lat: 47.5, + lon: -122.3, + ); + + test('a full public key names a row the path hash cannot', () { + expect( + WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [capitol, beacon], + prefixes: const ['4E'], + ), + isEmpty, + reason: 'the hash alone is genuinely ambiguous, and must stay refused', + ); + + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E'], + identities: const {'4E': '4E99F1BB2233445566'}, + ); + + expect(resolved['4E']?.name, 'Beacon Hill', + reason: 'the responder published exactly who it was'); + }); + + test('a row with no identity keeps the ordinary prefix rules', () { + // TX echoes and passive RX carry only the path byte. Nothing changes for + // them, including the refusal to guess. + expect( + WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E'], + ), + isEmpty, + ); + + final unique = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E5D'], + ); + expect(unique['4E5D']?.name, 'Capitol Hill'); + }); + + test('an identity that matches nothing falls back rather than giving up', + () { + // A catalogue entry with no hex at all still resolves by its short ID + // through the prefix path, so an unmatched identity must not strand it. + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E5D'], + identities: const {'4E5D': 'FFFFFFFFFFFF'}, + ); + + expect(resolved['4E5D']?.name, 'Capitol Hill'); + }); + + test('a duplicated catalogue entry is still refused', () { + final duplicate = _repeater( + id: '03', + hexId: '4E99F1BB22', + name: 'Beacon Hill (dup)', + lat: 47.5, + lon: -122.3, + ); + + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon, duplicate], + displayIds: const ['4E'], + identities: const {'4E': '4E99F1BB2233445566'}, + ); + + expect(resolved, isEmpty, + reason: 'a longer identity makes collision unlikely, not impossible'); + }); + + test('mixed rows each use the best identity they have', () { + // One discovery row that knows exactly who answered, one TX echo that + // does not. The echo must not borrow the discovery row's certainty. + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E', '4E5D'], + identities: const {'4E': '4E99F1BB2233445566'}, + ); + + expect(resolved['4E']?.name, 'Beacon Hill'); + expect(resolved['4E5D']?.name, 'Capitol Hill'); + }); + }); + group('indexByHexPrefix', () { test('resolves a link from a path-hash prefix to the full hex', () { final repeater = From 6cdcd8720a144033877eaccd6a05c6a60d3873dd Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 09:57:04 -0700 Subject: [PATCH 266/360] Restore the names my identity change took away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substituting the discovery pubkey for the display hash in _resolveRepeaterDisplayName broke the lookup that was actually doing the work. The catalogue is matched three ways: short numeric ID, the 8-character hexId, and displayHexId — the hex truncated to the zone's hop width, which is the exact form an overlay row is labelled with. That last one is what resolved a 2-character path hash, and a 64-character public key cannot match it. So the exact stage started coming up empty and every lookup fell through to prefix matching. Where it met the second half: Repeater.fromJson defaults hex_id to the empty string because the API often omits it, and "starts with empty" is true of every id. A single such entry in the catalogue made every prefix lookup ambiguous, so the resolver refused, and repeaters that had been named for months went unnamed on the Live Activity, the watch's status panel, and the trace target label. Two fixes. The resolver now tries the fuller identity and falls back to the display hash, so a better identity can only ever add a name — which is what I should have written the first time; resolveOverlayRepeaters had exactly that fallback and I did not carry it across. And a repeater with no hexId is no longer a prefix candidate at all, which was a latent trap the old code could also hit whenever its exact stage missed. Extracted as WatchGeoBuilder.resolveRepeater so it has tests. The watch's heard page deliberately keeps its stricter hexId-only rules and does not gain short-numeric-ID matching, because a path hash and a database ID are different identity domains that can collide by coincidence. --- lib/providers/app_state_provider.dart | 52 ++++++++-------- lib/services/watch/watch_geo_builder.dart | 35 +++++++++++ .../watch/watch_geo_builder_test.dart | 62 +++++++++++++++++++ 3 files changed, 122 insertions(+), 27 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 89cca8ae..0ca02834 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2246,34 +2246,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } String? _resolveRepeaterDisplayName(String rawId) { - // Resolve from the longest identity this row actually came with. The - // existing exact-then-prefix rules below are already right; they were just - // never given anything better than a two-character path hash to work with. final displayId = rawId.toUpperCase(); - final id = _overlayIdentityById[displayId]?.toUpperCase() ?? displayId; - 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; + + // Try the fullest identity the row arrived with, then the display hash. + // + // **The fallback is the point.** A discovery response's public key is a + // better identity, but the catalogue is matched by an 8-character `hexId` + // that the API frequently omits, and the display hash additionally resolves + // through `displayHexId` — which falls back to the short numeric ID when + // there is no hex at all. Trying only the longer form threw away the lookup + // that was doing the work, and left named repeaters unnamed. + final identity = _overlayIdentityById[displayId]; + final match = (identity == null + ? null + : WatchGeoBuilder.resolveRepeater( + repeaters: _repeaters, + id: identity, + hopBytes: _hopBytes, + )) ?? + WatchGeoBuilder.resolveRepeater( + repeaters: _repeaters, + id: displayId, + hopBytes: _hopBytes, + ); + + final name = match?.name; + return name == null || name == 'Unknown' ? null : name; } // ============================================ diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 83e4a68e..01595f65 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -243,6 +243,41 @@ class WatchGeoBuilder { return resolved; } + /// Resolve one identifier to a repeater, or null when it is not unambiguous. + /// + /// Tries exact identity first — the catalogue's short ID, its hex ID, or the + /// hex truncated to the zone's hop width, which is the form an overlay row is + /// labelled with — and only then a unique prefix relation. + /// + /// **A repeater with no `hexId` is never a prefix candidate.** The API omits + /// `hex_id` often enough that `Repeater.fromJson` defaults it to the empty + /// string, and `id.startsWith('')` is true for every id, so a single such + /// entry silently made every prefix lookup ambiguous and every name vanish. + static Repeater? resolveRepeater({ + required List repeaters, + required String id, + int? hopBytes, + }) { + final needle = id.toUpperCase(); + if (needle.isEmpty) return null; + + final exact = repeaters.where((repeater) { + return repeater.id.toUpperCase() == needle || + repeater.hexId.toUpperCase() == needle || + repeater.displayHexId(overrideHopBytes: hopBytes).toUpperCase() == + needle; + }).toList(growable: false); + if (exact.length == 1) return exact.single; + if (exact.isNotEmpty || needle.length < 2) return null; + + final prefixed = repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + if (hexId.isEmpty) return false; + return hexId.startsWith(needle) || needle.startsWith(hexId); + }).toList(growable: false); + return prefixed.length == 1 ? prefixed.single : null; + } + /// Resolve overlay display hashes to repeaters, preferring the fuller /// identity the ping actually carried. /// diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index b779679d..476ca92f 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -595,6 +595,68 @@ void main() { }); }); + group('resolveRepeater', () { + // The catalogue's hex_id is optional in the API and defaults to empty, and + // an overlay row is labelled with the hex truncated to the zone's hop + // width. Both facts are load-bearing. + final capitol = _repeater( + id: '01', + hexId: '4E5D82AA', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + ); + final noHex = _repeater( + id: '02', + hexId: '', + name: 'Site Without Hex', + lat: 47.5, + lon: -122.3, + ); + + test('a repeater with no hex never makes another lookup ambiguous', () { + // id.startsWith('') is true for every id, so one such entry used to make + // every prefix lookup ambiguous and every name disappear. + final resolved = WatchGeoBuilder.resolveRepeater( + repeaters: [capitol, noHex], + id: '4E5D82AA00112233', + hopBytes: 1, + ); + + expect(resolved?.name, 'Capitol Hill'); + }); + + test('the display hash resolves through the zone hop width', () { + expect( + WatchGeoBuilder.resolveRepeater( + repeaters: [capitol, noHex], + id: '4E', + hopBytes: 1, + )?.name, + 'Capitol Hill', + ); + }); + + test('genuine ambiguity is still refused', () { + final beacon = _repeater( + id: '03', + hexId: '4E99F1BB', + name: 'Beacon Hill', + lat: 47.5, + lon: -122.3, + ); + + expect( + WatchGeoBuilder.resolveRepeater( + repeaters: [capitol, beacon], + id: '4E', + hopBytes: 1, + ), + isNull, + ); + }); + }); + group('resolveOverlayRepeaters — name from the identity the ping carried', () { // Two repeaters sharing a leading byte. With one hop byte the wrist shows From d1267e45e3c9cf63b8dcc5ce6a3a2d470da03641 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 10:15:19 -0700 Subject: [PATCH 267/360] Put the embed phases where Xcode will leave them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new embed phases sat ahead of the compile phases in Runner's buildPhases. It built, because embedding depends on target dependencies rather than phase order, but Xcode reorders it on the next unrelated edit — which lands the churn in someone else's diff. This PR introduced the phases, so this is where the ordering belongs. Also formats one line of watch_bridge_service.dart. That file was format-clean at the branch point and I made it dirty adding the clock-offset comment, and it is a file this branch adds, so it has to land clean. --- ios/Runner.xcodeproj/project.pbxproj | 4 ++-- lib/services/watch/watch_bridge_service.dart | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1a837241..68245dd1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -362,13 +362,13 @@ 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 */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + A40000000000000000000004 /* Embed Foundation Extensions */, + 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, FF739F0FAE03970EB94871F2 /* [CP] Embed Pods Frameworks */, 1706FFD683EE65B4FC7A3867 /* [CP] Copy Pods Resources */, diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 896a6970..48f2a058 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -254,8 +254,7 @@ class WatchBridgeService { rawClockOffsetMs is num ? rawClockOffsetMs.toDouble() : 0.0; final ageMs = issuedAtMs == null ? null - : DateTime.now().millisecondsSinceEpoch - - (issuedAtMs + clockOffsetMs); + : DateTime.now().millisecondsSinceEpoch - (issuedAtMs + clockOffsetMs); final requestedMapGeo = args['mapGeoNeeded']; final mapGeoNeeded = requestedMapGeo is bool ? requestedMapGeo : null; final freshMapGeoSuppression = ageMs != null && From d380620793adfd8f00d5ef87106e6fff3a86bc8e Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 13:37:04 -0700 Subject: [PATCH 268/360] Stop the wrist offering flood traffic the phone withholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone treats flood traffic as an existence policy. Send Ping and the Active/Hybrid button are built inside `if (!txNotAllowed && floodTrafficVisible)` in all three ping_controls layouts, so with flood off those controls do not exist — and `floodTrafficEnabled` folds in the regional `flood_disabled` veto a zone admin sets, which the wearer cannot override. Nothing else enforces it: sendPing and toggleAutoPing never look. The watch consulted none of that. resolveAvailableWatchStartModes advertised Hybrid on isConnected/txAllowed/offlineMode alone, and _manualPingAvailability copied only the inner half of the Send Ping gate, so _handleWatchCommand admitted both a Hybrid start and a manual ping. The preference defaults off, so this was the common configuration rather than an edge — and in the veto case the wrist would originate traffic a zone admin had forbidden. Same offer/admission drift as the Offline-Mode Hybrid bug, with an on-air consequence. Fixed in the same shape: the advertisement withdraws Hybrid, and resolveSessionStartAvailability refuses transmit starts with 'Flood Traffic Off' alongside the other transmit-only policy checks, so Passive — which is not flood traffic, and whose phone button sits outside the gate — is untouched. manualPingApplicable gains it too, and that one is not incidental. It decides Ping-versus-Stop ownership of the map toolbar's single corner, and it already carries txAllowed because it follows stable configuration facts rather than transient timing. Without flood in it, gating canManualPing alone would hand that corner to a Ping that can never fire, on the one surface that shows no reason — trading a safety bug for a dead Stop. One reason string covers both the preference and the regional veto, because the phone's own gate is the single effective value that already combines them and the wearer's next step is the same either way: look at the phone, where Settings distinguishes them. The drift test now sweeps offline and flood together, so the advertisement and the admission cannot diverge again on either. --- DEVELOPMENT.md | 12 +++- lib/providers/app_state_provider.dart | 20 +++++- lib/services/watch/watch_models.dart | 26 +++++-- .../watch/watch_wire_contract_test.dart | 69 +++++++++++++++---- 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 43f4f0a7..674589bd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -488,7 +488,17 @@ a lost ack. offered button and the admitted command. **Passive counts as transmitting** — it sends a discovery request on start and every 30 s — so the manual-ping, RX-window and cooldown guards apply to every mode. Only offline mode, - passive-only zones and TX validation are transmit-only. + passive-only zones, flood traffic being off, and TX validation are + transmit-only. +- **Flood traffic is an existence policy, not a preference.** The phone builds + Send Ping and the Active/Hybrid button inside + `if (!txNotAllowed && floodTrafficVisible)`, so with flood off those controls + do not exist — and `floodTrafficEnabled` folds in the regional + `flood_disabled` veto a zone admin sets. It gates the wrist on both sides: + `resolveAvailableWatchStartModes` withdraws Hybrid, and + `resolveSessionStartAvailability` plus `_manualPingAvailability` refuse with + 'Flood Traffic Off'. The preference **defaults off**, so a wrist that skips + this admits the common configuration rather than an edge one. **Wire versioning** (`WatchWire.version`, mirrored in `ios/Shared/MeshMapperWatchPayload.swift`) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0ca02834..d7f3aad7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1567,6 +1567,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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. + // + // That gate has two halves and this used to copy only the inner one. Send + // Ping is built inside `if (!txNotAllowed && floodTrafficVisible)`, so + // with flood traffic off the phone has no such button at all — and since + // the preference defaults off and a regional `flood_disabled` veto forces + // it off, the wrist was admitting the common case, not an edge one. + final floodAllowed = floodTrafficEnabled; final canPingManual = manualPingValidation == PingValidation.valid; final isAutoStarting = isAutoPingStarting; final isTxModeActive = isTxModeRunning; @@ -1579,7 +1586,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final pingSending = isPingSending; final discoveryWindowActive = discoveryWindowTimer.isRunning; final pendingDisable = isPendingDisable; - final allowed = canPingManual && + final allowed = floodAllowed && + canPingManual && !isAutoStarting && !isTxModeActive && !isTargetedRunning && @@ -1606,6 +1614,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { reason = 'Offline Mode'; } else if (txNotAllowed) { reason = 'Passive Only'; + } else if (!floodAllowed) { + reason = 'Flood Traffic Off'; } else if (manualPingValidation == PingValidation.manualCooldownActive || cooldownActive || manualCooldownActive || @@ -1637,6 +1647,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { isConnected: isConnected, txAllowed: txAllowed, offlineMode: offlineMode, + floodTrafficEnabled: floodTrafficEnabled, ); String get _resolvedWatchSessionModeTitle => @@ -1667,8 +1678,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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. + // + // Flood traffic belongs here rather than with the timing guards, for the + // same reason `txAllowed` does: it is a stable configuration fact, not a + // transient one. Leaving it out would hand the map toolbar's one corner + // to a Ping that can never fire — and that corner is where Stop lives. manualPingApplicable: isConnected && txAllowed && + floodTrafficEnabled && !isTxModeRunning && !isTargetedModeRunning, manualCooldownEndsAt: cooldownMs > 0 @@ -1719,6 +1736,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxWindowActive: _rxWindowTimer.isRunning, txBlockedByOffline: offlineMode && isConnected, txNotAllowed: isConnected && !txAllowed, + floodTrafficEnabled: floodTrafficEnabled, transmitValidationReason: validation == PingValidation.valid ? null : validation.message, ); diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 341f864e..2328f9eb 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -550,14 +550,24 @@ bool resolveShouldShowWatchDiagnostics({ /// Offline Mode is part of that configuration. Advertising Hybrid while it is /// on offered the wearer a button every start refuses with 'Offline Mode' — a /// permanently dead option on a screen with room for two. +/// +/// So is flood traffic, and that one is the default state rather than an edge. +/// The phone builds Send Ping and the Active/Hybrid button inside +/// `if (!txNotAllowed && floodTrafficVisible)`, so with flood off those +/// controls do not exist — and [floodTrafficEnabled] folds in the regional +/// `flood_disabled` veto, which a zone admin sets and the wearer cannot +/// override. A wrist offering Hybrid there advertises a control the phone +/// withholds, and in the veto case one a zone admin forbade. List resolveAvailableWatchStartModes({ required bool isConnected, required bool txAllowed, required bool offlineMode, + required bool floodTrafficEnabled, }) => [ WatchStartMode.passive, - if (isConnected && txAllowed && !offlineMode) WatchStartMode.hybrid, + if (isConnected && txAllowed && !offlineMode && floodTrafficEnabled) + WatchStartMode.hybrid, ]; typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); @@ -574,9 +584,9 @@ typedef SessionStartAvailability = ({bool allowed, String? reason}); /// phone's own Passive button is dead during all three. /// /// What stays transmit-only is *policy* rather than timing: offline mode, a -/// passive-only zone, and the auto-mode validation reason all describe whether -/// the wearer may originate a TX ping, and the phone's Passive button ignores -/// them too. +/// passive-only zone, flood traffic being off, and the auto-mode validation +/// reason all describe whether the wearer may originate a TX ping, and the +/// phone's Passive button ignores them all. /// /// The setup and transition guards still apply to every mode. Keeping that /// split here prevents the offered button and the radio admission from @@ -594,6 +604,7 @@ SessionStartAvailability resolveSessionStartAvailability({ required bool rxWindowActive, required bool txBlockedByOffline, required bool txNotAllowed, + required bool floodTrafficEnabled, required String? transmitValidationReason, }) { if (!isConnected) return (allowed: false, reason: 'Not connected'); @@ -620,6 +631,13 @@ SessionStartAvailability resolveSessionStartAvailability({ if (txBlockedByOffline) return (allowed: false, reason: 'Offline Mode'); if (txNotAllowed) return (allowed: false, reason: 'Passive Only'); + // True whether the wearer turned flood traffic off or a regional admin did; + // the phone's own gate is the same single effective value, and the wearer's + // next step — look at the phone, where Settings says which it was — is the + // same either way. + if (!floodTrafficEnabled) { + return (allowed: false, reason: 'Flood Traffic Off'); + } if (transmitValidationReason != null) { return (allowed: false, reason: transmitValidationReason); } diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index f863814d..c39635e2 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -439,6 +439,7 @@ void main() { bool rxWindowActive = false, bool txBlockedByOffline = false, bool txNotAllowed = false, + bool floodTrafficEnabled = true, String? transmitValidationReason, }) => resolveSessionStartAvailability( @@ -454,6 +455,7 @@ void main() { rxWindowActive: rxWindowActive, txBlockedByOffline: txBlockedByOffline, txNotAllowed: txNotAllowed, + floodTrafficEnabled: floodTrafficEnabled, transmitValidationReason: transmitValidationReason, ); @@ -574,6 +576,7 @@ void main() { isConnected: true, txAllowed: true, offlineMode: true, + floodTrafficEnabled: true, ), [WatchStartMode.passive], ); @@ -583,6 +586,7 @@ void main() { isConnected: true, txAllowed: true, offlineMode: false, + floodTrafficEnabled: true, ), [WatchStartMode.passive, WatchStartMode.hybrid], ); @@ -596,6 +600,7 @@ void main() { isConnected: connected, txAllowed: tx, offlineMode: false, + floodTrafficEnabled: true, ), contains(WatchStartMode.passive), ); @@ -603,27 +608,67 @@ void main() { } }); + test('flood traffic off withdraws Hybrid from the wrist', () { + // The phone builds Send Ping and the Active/Hybrid button inside + // `if (!txNotAllowed && floodTrafficVisible)`, so with flood off neither + // control exists. The preference defaults off and a regional + // `flood_disabled` veto forces it off, so a wrist that ignored this was + // advertising a phone-withheld control in the *default* configuration — + // and in the veto case one a zone admin forbade. + expect( + resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: false, + floodTrafficEnabled: false, + ), + [WatchStartMode.passive], + reason: 'Passive is not flood traffic and stays offered', + ); + }); + test('an advertised Hybrid is one the start gate actually admits', () { // The pairing the two resolvers must agree on: whenever Hybrid is // offered, a start in that mode is not refused for a configuration // reason. Transient timing guards are deliberately not checked here. for (final offline in [true, false]) { - final offered = resolveAvailableWatchStartModes( - isConnected: true, - txAllowed: true, - offlineMode: offline, - ).contains(WatchStartMode.hybrid); - - final admitted = startAvailability( - isTransmitMode: true, - txBlockedByOffline: offline, - ); + for (final flood in [true, false]) { + final offered = resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: offline, + floodTrafficEnabled: flood, + ).contains(WatchStartMode.hybrid); + + final admitted = startAvailability( + isTransmitMode: true, + txBlockedByOffline: offline, + floodTrafficEnabled: flood, + ); - expect(offered, admitted.allowed, - reason: 'offered and admitted must not drift on Offline Mode'); + expect(offered, admitted.allowed, + reason: 'offered and admitted must not drift on ' + 'offline=$offline flood=$flood'); + } } }); + test('flood traffic gates transmit starts but never Passive', () { + final hybrid = + startAvailability(isTransmitMode: true, floodTrafficEnabled: false); + expect(hybrid.allowed, isFalse); + expect(hybrid.reason, 'Flood Traffic Off'); + + // Passive monitoring is not flood traffic. The phone's Passive button + // sits outside the flood gate, so the wrist's must too — withdrawing it + // would strand a wearer with no way to start anything at all. + expect( + startAvailability(isTransmitMode: false, floodTrafficEnabled: false) + .allowed, + isTrue, + ); + }); + test('start admission names every shared blocked precondition', () { final blocked = { 'Not connected': startAvailability(isConnected: false), From 5ec38ef723a736b7163cfe6e8c762dbb55ccd717 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 13:37:44 -0700 Subject: [PATCH 269/360] Let a failure cue outlive the snapshot that carried it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrist Start or Ping that is admitted and then fails puts a one-shot cue on the next snapshot. The phone dropped that cue as soon as the native sync returned true — but true means updateApplicationContext accepted the blob, not that the watch ingested it, and the wearer's wrist is usually down at exactly that moment. Because the cue ID sits in the urgency key, the very next flush was urgent and overwrote the retained application context with a cue-less payload, often within a second. The watch woke to idle UI and no account of the failure: the failure path had quietly deleted its own only evidence. WatchSessionClient already has the path for this. Its `.read` presentation shows the message without a haptic for cues too old to buzz for, up to the same 90 s boundary that greys the rest of the screen, and its own comment names this race. It could never run, because resume ingests whatever context is retained — by then the second snapshot. So bound the cue by age instead of by delivery. WatchWire.cueReadableFor mirrors WatchSessionClient.staleAfter, WatchHapticCue.isPresentableAt applies it, and _presentableWatchCue feeds both the snapshot builder and the cheap urgency-key preflight — those two must agree about the cue or the preflight lies about urgency. Re-attaching costs nothing and cannot double-buzz live redelivery: the watch keys haptics on presentedCueIDs and drops the cue itself past the boundary rather than asserting a dead failure as current. Removes the onSnapshotDelivered plumbing rather than leaving it unused. A callback named "delivered" whose actual meaning is "WatchConnectivity queued the blob" is the trap that produced this, and leaving it in place invites the next use. Its test now pins that the accepted send is recorded as an accepted send. --- DEVELOPMENT.md | 11 +++ lib/providers/app_state_provider.dart | 36 ++++++---- lib/services/watch/watch_bridge_service.dart | 6 -- lib/services/watch/watch_models.dart | 19 +++++ .../watch/watch_wire_contract_test.dart | 70 ++++++++++++++++--- 5 files changed, 115 insertions(+), 27 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 674589bd..17500994 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -500,6 +500,17 @@ a lost ack. 'Flood Traffic Off'. The preference **defaults off**, so a wrist that skips this admits the common configuration rather than an edge one. +**Failure cues.** A one-shot cue rides *every* snapshot until it is older than +`WatchWire.cueReadableFor` (90 s), which mirrors `WatchSessionClient.staleAfter`. +It is deliberately **not** dropped when native accepts a payload carrying it: +that reply means `updateApplicationContext` took the blob, not that the watch +ingested it, and the wearer's wrist is usually down at that moment. Because the +cue ID is in the urgency key, dropping it there made the very next flush urgent +and overwrote the retained context with a cue-less payload — so a suspended +watch woke to idle UI and no account of the failure. Re-attaching is free and +cannot double-buzz: the watch keys haptics on `presentedCueIDs` and drops the +cue itself past the boundary rather than asserting a dead failure as current. + **Wire versioning** (`WatchWire.version`, mirrored in `ios/Shared/MeshMapperWatchPayload.swift`) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d7f3aad7..532c1476 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -154,6 +154,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({double lat, double lon})? _rankingPosition; WatchHapticCue? _watchCue; + /// The pending cue, for as long as the watch would still present it. + /// + /// **Age, not delivery.** This used to be dropped the moment a snapshot + /// carrying it came back from native — but that reply means + /// `updateApplicationContext` accepted the blob, not that the watch ingested + /// it, and the wearer's wrist is usually down at exactly that moment. Since + /// the cue ID sits in the urgency key, the very next flush was urgent and + /// overwrote the retained context with a cue-less payload, often within a + /// second. The watch woke to idle UI and no account of the failure it had + /// just been told about — the failure path having quietly deleted its own + /// only evidence. + /// + /// Re-attaching costs nothing and cannot double-buzz: the watch keys its + /// haptics on `presentedCueIDs`, and past [WatchWire.cueReadableFor] it + /// drops the cue itself rather than asserting a dead failure as current. + WatchHapticCue? get _presentableWatchCue { + final cue = _watchCue; + if (cue == null) return null; + return cue.isPresentableAt(DateTime.now()) ? cue : null; + } + /// 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. @@ -1334,7 +1355,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseEndsAt: phase.endsAt, isConnected: isConnected, controls: controls, - cue: _watchCue, + cue: _presentableWatchCue, mapGeoIncluded: _watchBridge.shouldIncludeMapGeo, ); } @@ -1386,7 +1407,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { mapGeoIncluded: includeMapGeo, availableStartModes: _availableWatchStartModes, pingColor: pingColor, - cue: _watchCue, + cue: _presentableWatchCue, phaseDurationMs: phaseDurationMs, updatedAt: now, ); @@ -1874,16 +1895,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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()); @@ -2359,7 +2370,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _watchBridge.attachCommandHandler( _handleWatchCommand, onRefusal: _emitWatchFailure, - onSnapshotDelivered: _handleWatchSnapshotDelivered, // Availability can become true long after provider startup when a // watch is paired or its app is installed. Push the current state then // rather than waiting for an unrelated phone-side notification. diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 48f2a058..fe0cc67b 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -17,7 +17,6 @@ typedef WatchUrgencyKeyBuilder = String Function(); 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. /// @@ -113,7 +112,6 @@ class WatchBridgeService { WatchCommandHandler? _commandHandler; WatchCommandRefusalHandler? _commandRefusalHandler; WatchAvailabilityHandler? _availabilityHandler; - WatchSnapshotDeliveryHandler? _snapshotDeliveryHandler; String? _lastPayload; @@ -178,12 +176,10 @@ class WatchBridgeService { WatchCommandHandler handler, { WatchCommandRefusalHandler? onRefusal, WatchAvailabilityHandler? onAvailabilityChanged, - WatchSnapshotDeliveryHandler? onSnapshotDelivered, }) { _commandHandler = handler; _commandRefusalHandler = onRefusal; _availabilityHandler = onAvailabilityChanged; - _snapshotDeliveryHandler = onSnapshotDelivered; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); unawaited(_refreshAvailability()); @@ -574,7 +570,6 @@ class WatchBridgeService { _lastSuccessfulSendAt = sentAt; _lastSendDelivered = true; _publishDiagnostics(); - _snapshotDeliveryHandler?.call(snapshot); } on MissingPluginException { // Expected on non-iOS hosts and in tests. } on PlatformException catch (error) { @@ -657,7 +652,6 @@ class WatchBridgeService { _commandHandler = null; _commandRefusalHandler = null; _availabilityHandler = null; - _snapshotDeliveryHandler = null; _mapGeoSuppressedAt = null; _lastMapGeoClaimIssuedAtMs = null; _diagnostics.dispose(); diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 2328f9eb..e954a4f9 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -39,6 +39,15 @@ class WatchWire { /// Three top-SNR rows plus the RX slot, matching `_buildTopRepeatersOverlay`. static const int maxHeard = 4; + /// How long a one-shot cue keeps riding outgoing snapshots. + /// + /// Mirrors `WatchSessionClient.staleAfter`, the boundary past which the watch + /// greys the whole surface and drops a cue rather than asserting a dead + /// failure as current. Keep the two in step: a phone that stops attaching + /// early reintroduces the silent-failure bug this bounds, and one that + /// attaches past the boundary only sends bytes the watch discards. + static const Duration cueReadableFor = Duration(seconds: 90); + /// 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. @@ -323,6 +332,16 @@ class WatchHapticCue { /// wire bump is needed while the matched phone and watch targets ship it. final String? message; + /// Whether the watch would still present this cue if it arrived now. + /// + /// The watch applies the same bound on ingest — buzzing inside its own + /// fresh window, showing the message without a haptic up to + /// [WatchWire.cueReadableFor], dropping it after. This is the phone's half + /// of that one rule, not a second policy: it decides how long the cue is + /// worth carrying, and the watch decides how it lands. + bool isPresentableAt(DateTime now) => + now.difference(issuedAt) < WatchWire.cueReadableFor; + Map toMap() => { 'id': id, 'kind': kind, diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index c39635e2..0dbd2db0 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -353,6 +353,61 @@ void main() { }); }); + test('a cue stays presentable until the watch would drop it', () { + // The bound the phone attaches by and the bound the watch presents by + // are one rule. `WatchSessionClient.staleAfter` greys the surface and + // drops the cue at 90 s; a phone that stopped attaching earlier would + // recreate the silent-failure bug, because the wearer's wrist is down + // for most of that window. + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + expect(cue.isPresentableAt(issuedAt), isTrue); + expect( + cue.isPresentableAt(issuedAt.add(const Duration(seconds: 89))), + isTrue, + reason: 'a wearer who raises their wrist late still gets the reason', + ); + expect( + cue.isPresentableAt(issuedAt.add(WatchWire.cueReadableFor)), + isFalse, + reason: 'past the stale boundary the watch drops it anyway', + ); + }); + + test('the cue survives the snapshot that carried it', () { + // The defect this pins: the phone treated native accepting one payload + // as the wearer having seen the cue, cleared it, and — because the cue + // ID is in the urgency key — immediately sent an urgent, cue-less + // payload that overwrote the retained application context. A suspended + // watch woke to idle UI and no account of the failure. + // + // Both snapshots must carry the cue, and both must agree it is urgent, + // or the cheap preflight key and the built payload drift. + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + final first = _snapshot(cue: cue); + final second = _snapshot( + cue: cue, + updatedAt: issuedAt.add(const Duration(seconds: 30)), + ); + + expect(second.toMap()['cue'], first.toMap()['cue']); + expect(second.urgencyKey, first.urgencyKey, + reason: 'a re-attached cue is not a new event to flush for'); + }); + 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. @@ -1262,13 +1317,13 @@ void main() { expect(syncCalls, 1); }); - test('successful native delivery reports the exact snapshot once', - () async { - WatchSnapshot? delivered; - bridge.attachCommandHandler( - (_) => null, - onSnapshotDelivered: (snapshot) => delivered = snapshot, - ); + test('an accepted native send is recorded, not called delivered', () async { + // There is deliberately no per-snapshot delivery callback here. Native + // returning true means `updateApplicationContext` accepted the blob; the + // watch may be suspended and ingest it much later, or never. Anything + // that treated this as wearer-visible delivery — the cue lifetime once + // did — dropped state the watch had not yet seen. + bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); final snapshot = _snapshot(); @@ -1279,7 +1334,6 @@ void main() { ); 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); From 11a7c1998524456e818b06927bc355414e65f254 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 13:48:23 -0700 Subject: [PATCH 270/360] Make map-geo recovery actually demand the geography back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the last two commits. `apply` notices a suppressed context arriving after the map came back and asks for a full replacement, but asked with `sendMapGeoPreference(true, force: true)`. `force` only defeats this client's own "already told the phone that" check. The phone still holds its payload fingerprint, and by the time the out-of-order context lands it has usually already sent full geography and cached it — so the request dedupes into silence and the wrist keeps rendering empty pings and repeaters. `refresh: true` is the flag that sets `forceRefresh` and makes the phone answer an unchanged payload; `requestFullSnapshot` was the only caller passing it. Severity is bounded but the shape is wrong: during a session, geo changes at roughly 2 Hz and the fingerprint breaks on its own within a second. Parked and idle it does not, which is when a blank map is least explicable. Also corrects a claim I introduced last commit. "Re-attaching cannot double-buzz" is true of WatchConnectivity redelivery and false of a watch process that relaunches: launch ingests the retained context against an empty `presentedCueIDs`, so a cue still inside the 30 s haptic window fires again. Holding the cue for 90 s instead of about a second widened that window, and the trade is still obviously right — one duplicate buzz beats the silence it replaced — but the document should not claim the case away. Closing it properly means persisting presented IDs across launches, which is a separate change. Not covered by CI: the watch target has no Swift tests and the workflow runs `flutter test` on Ubuntu. The Swift edit is syntax-checked only. --- DEVELOPMENT.md | 15 ++++++++++++--- ios/MeshMapperWatch/WatchSessionClient.swift | 10 +++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 17500994..8a55150d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -507,9 +507,18 @@ that reply means `updateApplicationContext` took the blob, not that the watch ingested it, and the wearer's wrist is usually down at that moment. Because the cue ID is in the urgency key, dropping it there made the very next flush urgent and overwrote the retained context with a cue-less payload — so a suspended -watch woke to idle UI and no account of the failure. Re-attaching is free and -cannot double-buzz: the watch keys haptics on `presentedCueIDs` and drops the -cue itself past the boundary rather than asserting a dead failure as current. +watch woke to idle UI and no account of the failure. Re-attaching is free: the +watch keys haptics on `presentedCueIDs` and drops the cue itself past the +boundary rather than asserting a dead failure as current. + +`presentedCueIDs` is process-local, so that de-duplication covers WatchConnectivity +redelivery but **not** a watch process that dies and relaunches. Launch ingests +the retained context, and a cue still inside `cueFreshFor` (30 s) buzzes again +against an empty set. Widening the attachment window from about a second to 90 s +widened that case with it — deliberately. One duplicate haptic after a relaunch +is a far smaller failure than the silence it replaced, and closing it properly +means persisting presented IDs across launches for a payload the watch is +already re-reading on purpose. **Wire versioning** (`WatchWire.version`, mirrored in `ios/Shared/MeshMapperWatchPayload.swift`) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 99deb8f6..7a714a8f 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -632,13 +632,21 @@ final class WatchSessionClient: NSObject { // 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. + // + // `refresh: true` is what makes that a replacement rather than a wish. + // `force` only defeats this client's own "already told the phone that" + // check; the phone still has its payload fingerprint, and by now it has + // usually already sent full geography and cached it. Without the refresh + // the request dedupes into silence and the wrist keeps the empty arrays + // until something unrelated moves the urgency key — which, parked and + // idle, is exactly when it does not. let lastRequest = lastMapGeoRecoveryRequestAt if lastRequest == nil || arrival.timeIntervalSince(lastRequest ?? .distantPast) >= Self.mapGeoRecoveryThrottle { lastMapGeoRecoveryRequestAt = arrival - sendMapGeoPreference(true, force: true) + sendMapGeoPreference(true, force: true, refresh: true) } } From 0db8805ee6535e26c0dc516614243e890aeaafb5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 13:50:18 -0700 Subject: [PATCH 271/360] Stop a slow application context overwriting a newer live one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An urgent update goes out twice — `sendMessage` and `updateApplicationContext` — and nothing orders the two. `sendMessage` does not populate `receivedApplicationContext`, so the retained context keeps whatever last arrived down the slow path, which can be a payload this client already superseded live. `apply` was last-writer-wins, and `resume` ingests that retained context on every wrist raise. That combination strands a wearer with nothing asking for better. The phone sends P2 both ways; the watch applies P2 from the message; the wrist drops and rises before context P2 lands; `resume` ingests the retained context and applies P1 over it. P1 is under 90 s old, so `resume` sees no reason to spend the radio, and the phone's caches already equal P2, so nothing further is sent. The wearer reads P1 until something unrelated moves the urgency key. So `apply` now refuses a payload older than what is rendered. Both stamps come from the one phone clock and compare raw — `inLocalTime` is for phone-instant against watch-now, which this is not. Equal stamps are accepted; a forced refresh always restamps `updatedAt`, so it can never look like a replay of itself. The refusal lifts once the held snapshot is stale. That is the part worth keeping: a bare timestamp comparison turns a phone clock stepping backwards into a watch that refuses everything for the life of the process, where this bounds it to the 90 s boundary and then recovers on its own. A stale surface is already worse than a possibly-reordered one. No wire `seq`. It would need a phone-process identity beside it — a counter restarting at zero is indistinguishable from an ancient one — to buy behaviour this already has, and that is a version conversation for no gain. Falls out of the same guard: a live message that lost the ordering check no longer teaches `clockOffset`. Its transit time is the measurement, and one that arrives out of order has already broken the assumption the measurement rests on. Not covered by CI: the watch target has no Swift tests and the workflow runs `flutter test` on Ubuntu. Syntax-checked only; this wants a wrist. --- DEVELOPMENT.md | 12 +++++ ios/MeshMapperWatch/WatchSessionClient.swift | 49 ++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8a55150d..538fffd8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -442,6 +442,18 @@ Every gate runs before the next, and each exists for a different failure: Urgent updates use `sendMessage` and *always* fall through to `updateApplicationContext`, so a missed message can't strand the watch. +**The two paths are not ordered against each other, and the watch enforces +that.** `sendMessage` does not populate `receivedApplicationContext`, so the +retained context can hold a payload the watch already superseded live — and +`resume` ingests that context on every wrist raise. `WatchSessionClient.apply` +therefore refuses anything whose `updatedAt` predates what is already rendered. +Both stamps come from the one phone clock, so they compare raw. The refusal is +lifted once the held snapshot is stale, which bounds a backwards clock step to +90 seconds of refusal instead of the life of the process. There is deliberately +no wire `seq`: a counter restarting at zero is indistinguishable from an ancient +one without a process identity beside it, which is a version conversation for +behaviour this already has. + **Cache invalidation.** Dart's dedupe caches mirror native's `lastContextData`. Native clears that only in `sessionWatchStateDidChange` and on `clear`, and says so with `nativeCacheCleared` on the `availabilityChanged` push. Reachability diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 7a714a8f..b8fef6cb 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -611,10 +611,59 @@ final class WatchSessionClient: NSObject { } } + /// Whether `decoded` supersedes what is already rendered. + /// + /// **The two delivery paths are not ordered with respect to each other.** An + /// urgent update goes out as `sendMessage` *and* as + /// `updateApplicationContext`, and only the first is fast. `sendMessage` + /// does not populate `receivedApplicationContext`, so the retained context + /// keeps whatever last arrived down the slow path — which can be a payload + /// this client already superseded live. + /// + /// That was enough to strand a wearer on stale state with nothing asking for + /// better: phone sends P2 both ways, the watch applies P2 from the message, + /// the wrist drops and rises before context P2 lands, `resume` ingests the + /// retained context and applies P1 over it. P1 is under 90 s old so `resume` + /// sees no reason to spend the radio, and the phone's own caches already + /// equal P2, so nothing further is sent. The wearer reads P1 until something + /// unrelated moves. + /// + /// Both stamps come from the same phone clock, so they are compared raw — + /// `inLocalTime` exists for phone-instant against watch-now, which this is + /// not. Equal stamps are accepted: two payloads built in the same + /// millisecond are interchangeable, and a forced refresh always restamps + /// `updatedAt`, so it can never be mistaken for a replay of itself. + /// + /// **A stale surface accepts anything.** Ordering is worth enforcing only + /// while what is held is still worth protecting. Bounding it this way means + /// the worst case of a phone clock stepping backwards is 90 seconds of + /// refusal — after which the boundary flips, the next payload lands, and the + /// watch recovers on its own. A bare timestamp comparison would refuse for + /// the life of the process. + /// + /// Deliberately no wire `seq`. It would need a phone-process identity beside + /// it — a counter that restarts at zero is indistinguishable from an ancient + /// one — to buy behaviour this already has without a version conversation. + /// + /// A rejected payload does not clear a pending command, and does not need + /// to: for one to be rejected, a newer payload must already have been + /// applied, and that one cleared it. + private func isNewerThanCurrent(_ decoded: WatchSnapshot) -> Bool { + guard let current = snapshot, !isStale else { return true } + return decoded.updatedAt >= current.updatedAt + } + private func apply(_ decoded: WatchSnapshot, live: Bool = false) { let arrival = Date() + guard isNewerThanCurrent(decoded) else { return } + // Learned before anything below reads a phone-stamped time. + // + // Reached only by a payload that won the ordering check above, which also + // keeps a *delayed* live message from teaching a wrong offset: its transit + // is the measurement, so one that arrives out of order has already failed + // the assumption the measurement rests on. if live { clockOffset = decoded.updatedAt.timeIntervalSince(arrival) } From 70b11cf8dc1e4aa7dd87f9e8337d6bf86c2183bb Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 14:09:57 -0700 Subject: [PATCH 272/360] Name the session a queued Stop meant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop is exempt from the transmit-age window, and should be: a late stop takes the radio off air, so refusing one leaves a session transmitting after the wearer asked it to end. But the exemption quietly assumed sessions were interchangeable, and the command carried nothing to say which one it meant. So a Stop queued while the phone was out of range — or while the watch was suspended mid transfer — could arrive after that session had ended and another begun, and stop the wrong one. Silently: the wrist gets its acceptance, the phone stops a session nobody asked it to, and the wearer finds out by noticing that recording has halted. Of everything the wrist can get wrong, this is the one that costs data rather than a confusing screen. The command now carries the session id from the snapshot the wearer was looking at when they tapped, and the phone refuses a stop whose target it has moved on from. Attached inside `send` rather than by each call site, so no Stop button can forget it. Checked after the active-session gate, not before. With nothing running, a stop stays the harmless no-op it has always been rather than becoming a refusal for a session that is already over — the wearer asked for stopped, and stopped is what they have. Additive and optional on the wire, so no version bump: absent means an older watch build, which is admitted exactly as it was before the field existed. Refusing those instead would strand a wearer whose Stop button the phone had silently stopped honouring, which is the failure this is meant to prevent. Makes DEVELOPMENT.md's "lateness can only make refusing it worse" true. It was not, and this is why. The admission rule lives in the shared resolver and the wire key is asserted at the bridge, both mutation-checked. The watch half is syntax-checked only. --- DEVELOPMENT.md | 8 +- ios/MeshMapperWatch/WatchSessionClient.swift | 6 +- ios/Shared/MeshMapperWatchPayload.swift | 13 +++ lib/providers/app_state_provider.dart | 5 ++ lib/services/watch/watch_bridge_service.dart | 1 + lib/services/watch/watch_models.dart | 34 ++++++++ .../watch/watch_wire_contract_test.dart | 86 +++++++++++++++++++ 7 files changed, 151 insertions(+), 2 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 538fffd8..340ea52c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -495,7 +495,13 @@ a lost ack. the key jump backwards the first time an offset is learned. - `requestSnapshot` and `stopSession` are exempt from the age window: one transmits nothing, and the other takes the radio *off* air, so lateness can - only make refusing it worse. + only make refusing it worse. **A stop therefore names its session**, from the + snapshot the wearer was looking at when they tapped, and is refused if the + phone has since moved on. Without that the exemption assumed one session was + as good as another, and a stop queued against A could silently end B. The + field is optional: absent means an older watch build and is admitted as + before, because refusing those would strand a wearer whose Stop button the + phone had quietly stopped honouring. - `resolveSessionStartAvailability` is the single start gate for both the offered button and the admitted command. **Passive counts as transmitting** — it sends a discovery request on start and every 30 s — so the manual-ping, diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index b8fef6cb..a584bb50 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -246,7 +246,11 @@ final class WatchSessionClient: NSObject { // phone needs this to measure age, but uses the raw stamp to order // map-geo claims, and rewriting that key would make it jump backwards // the first time an offset is learned. - clockOffsetMs: clockOffset.map { $0 * 1000 } + clockOffsetMs: clockOffset.map { $0 * 1000 }, + // Named here rather than by each caller, so no Stop button can forget + // it. This is the session the wearer was actually looking at when they + // tapped; the phone refuses the stop if it has since moved on. + sessionId: kind == .stopSession ? snapshot?.sessionId : nil ) guard let data = try? MeshMapperWatchWire.encoder.encode(command), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 3c92b5c5..54c88747 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -416,6 +416,19 @@ struct WatchCommand: Codable, Hashable { /// application context can sit retained for hours, so its timestamp says /// nothing about the current offset. let clockOffsetMs: Double? + /// Which session a `stopSession` means, from the snapshot the wearer was + /// looking at when they tapped. + /// + /// Stopping is exempt from the transmit-age window — a late stop takes the + /// radio off air, so refusing it is the worse failure — but that exemption + /// assumed sessions were interchangeable. They are not: a Stop queued while + /// the phone was out of range, delivered after that session ended and + /// another began, silently stopped the wrong one and the wearer learned + /// about it by noticing that recording had halted. + /// + /// Optional and additive. Absent — an older watch build — the phone admits + /// the stop exactly as it always did. + let sessionId: String? } // There is deliberately no acknowledgement model: queued commands have no diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 532c1476..00f50b2c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1823,6 +1823,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { kind: kind, isSessionActive: _autoPingEnabled, isSessionStarting: _autoPingStarting, + requestedSessionId: command.sessionId, + // The same value the wrist rendered, so a stop is matched against + // what the wearer was looking at rather than against a field only + // this side knows. + currentSessionId: _liveActivitySessionId ?? 'idle', ); if (admission.refusal != null) return admission.refusal; if (!admission.shouldRun) return null; diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index fe0cc67b..bcb9a275 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -312,6 +312,7 @@ class WatchBridgeService { mode: args['mode'] as String?, mapGeoNeeded: effectiveMapGeoNeeded, forceRefresh: args['forceRefresh'] == true, + sessionId: args['sessionId'] as String?, )); final refusal = admission is Future ? await admission : admission; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index e954a4f9..b713ad18 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -483,11 +483,25 @@ class WatchCommand { this.mode, this.mapGeoNeeded, this.forceRefresh = false, + this.sessionId, }); final WatchCommandKind kind; final String? mode; + /// Which session a [WatchCommandKind.stopSession] means, taken from the + /// snapshot the wearer was looking at when they tapped. + /// + /// Stopping is exempt from the transmit-age window, because a late stop + /// takes the radio off air and refusing it is the worse failure. That + /// exemption quietly assumed sessions were interchangeable. They are not: a + /// Stop queued while the phone was out of range, delivered after that + /// session ended and another began, stopped the wrong one — silently, and + /// the wearer found out by noticing recording had halted. + /// + /// Null from an older watch build, which is admitted exactly as before. + final String? sessionId; + /// 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. @@ -667,10 +681,19 @@ SessionStartAvailability resolveSessionStartAvailability({ /// 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. +/// +/// [requestedSessionId] is the session the wrist meant, and is compared against +/// [currentSessionId] before a stop is admitted. Both are needed because a stop +/// is deliberately exempt from the transmit-age window: it can arrive +/// arbitrarily late, and "stop the session" is only unambiguous while there is +/// one session it could mean. A null request is an older watch build and keeps +/// the previous behaviour. WatchCommandAdmission resolveWatchSessionCommandAdmission({ required WatchCommandKind kind, required bool isSessionActive, required bool isSessionStarting, + String? requestedSessionId, + String? currentSessionId, }) { switch (kind) { case WatchCommandKind.startSession: @@ -685,6 +708,17 @@ WatchCommandAdmission resolveWatchSessionCommandAdmission({ refusal: 'Still starting — try Stop again', ); } + // Checked only once there is a session to stop. With none running the + // stop stays the harmless no-op it has always been, rather than becoming + // a refusal for a session that is already over. + if (isSessionActive && + requestedSessionId != null && + requestedSessionId != currentSessionId) { + return ( + shouldRun: false, + refusal: 'That session already ended', + ); + } return (shouldRun: isSessionActive, refusal: null); case WatchCommandKind.manualPing: case WatchCommandKind.requestSnapshot: diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 0dbd2db0..2a021a73 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -524,6 +524,60 @@ void main() { expect(WatchCommandKind.fromWire('selfDestruct'), isNull); }); + test('a queued Stop cannot end a session it never saw', () { + // Stop is exempt from the transmit-age window, so it can arrive + // arbitrarily late — the phone out of range, the watch suspended mid + // transfer. That exemption assumed one session was as good as another. + // A Stop queued against A, delivered after A ended and B began, used to + // stop B silently, and the wearer found out by noticing that recording + // had halted. + final wrongSession = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.stopSession, + isSessionActive: true, + isSessionStarting: false, + requestedSessionId: 'session-a', + currentSessionId: 'session-b', + ); + expect(wrongSession.shouldRun, isFalse); + expect(wrongSession.refusal, 'That session already ended'); + + final sameSession = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.stopSession, + isSessionActive: true, + isSessionStarting: false, + requestedSessionId: 'session-a', + currentSessionId: 'session-a', + ); + expect(sameSession, (shouldRun: true, refusal: null)); + + // An older watch build names no session. It is admitted exactly as it + // was before the field existed, because refusing it would strand a + // wearer whose only Stop button the phone had stopped honouring. + final olderBuild = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.stopSession, + isSessionActive: true, + isSessionStarting: false, + currentSessionId: 'session-b', + ); + expect(olderBuild, (shouldRun: true, refusal: null)); + }); + + test('a Stop with nothing running stays a harmless no-op', () { + // Checked after the active-session gate on purpose. With no session, a + // mismatched id must not turn a silent no-op into a refusal for + // something that is already over — the wearer asked for stopped, and + // stopped is what they have. + final stop = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.stopSession, + isSessionActive: false, + isSessionStarting: false, + requestedSessionId: 'session-a', + currentSessionId: 'idle', + ); + + expect(stop, (shouldRun: false, refusal: null)); + }); + test('Start is idempotent while starting but Stop tells the truth', () { final start = resolveWatchSessionCommandAdmission( kind: WatchCommandKind.startSession, @@ -851,6 +905,7 @@ void main() { double? issuedAtMs, double? clockOffsetMs, bool? forceRefresh, + String? sessionId, }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger @@ -865,6 +920,7 @@ void main() { if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, if (clockOffsetMs != null) 'clockOffsetMs': clockOffsetMs, if (forceRefresh != null) 'forceRefresh': forceRefresh, + if (sessionId != null) 'sessionId': sessionId, }), ), null, @@ -1848,6 +1904,36 @@ void main() { expect(reply?['reason'], 'Command failed'); }); + test('a stop command carries its session id to admission', () async { + // The bridge is the only place that names the wire key, and the watch + // target has no Swift tests to pin the other end. If `sessionId` were + // renamed here, every production stop would arrive unattributed and be + // admitted against whatever session happened to be running — the exact + // failure the field exists to prevent, reintroduced silently. + WatchCommand? seen; + bridge.attachCommandHandler((command) { + seen = command; + return null; + }); + + await sendCommand('stop-1', 'stopSession', sessionId: 'session-a'); + + expect(seen?.kind, WatchCommandKind.stopSession); + expect(seen?.sessionId, 'session-a'); + }); + + test('a stop from an older watch build names no session', () async { + WatchCommand? seen; + bridge.attachCommandHandler((command) { + seen = command; + return null; + }); + + await sendCommand('stop-2', 'stopSession'); + + expect(seen?.sessionId, isNull); + }); + test('a start command carries its requested mode to admission', () async { WatchCommand? received; bridge.attachCommandHandler((command) { From 4dd2426ee41343e9e198ae93bcdb3f3b50e9b68e Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 14:12:53 -0700 Subject: [PATCH 273/360] Keep a wrist command that arrives before Dart is listening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued command can wake a phone app that was not running, and the native relay invokes Dart the moment WatchConnectivity delivers it — which can be well before attachCommandHandler runs, since that happens partway through the provider's asynchronous initialization. transferUserInfo hands each command over exactly once, so anything dropped in that window is simply gone: the wrist shows its spinner, times out after ten seconds, and never says why. Flutter already buffers platform messages sent before a handler exists, so this was never total loss — but the default depth is one, so a wearer who tapped twice lost the first tap. Measured rather than taken from the documentation: the new test pushes three commands at the channel buffer before attaching a handler and, without the reservation, exactly one arrives. So reserve eight, at the top of main() before anything slow. That is well past any plausible burst — there is one Start button and one Ping button — while staying bounded, because these are intents that still face the age window and the ID cache on arrival, not state to be replayed. Uses the platform's own mechanism rather than a native queue and a ready handshake. It narrows the window rather than closing it: a burst landing before Dart's entrypoint executes at all is still governed by the default depth, since resizing upward keeps what is already queued but cannot retroactively make room. Closing that remainder means buffering natively, which is a lot of moving parts for a window smaller than the one this covers. --- lib/main.dart | 6 ++ lib/services/watch/watch_bridge_service.dart | 33 +++++++++++ .../watch/watch_wire_contract_test.dart | 56 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/lib/main.dart b/lib/main.dart index 37354b48..aa9f7edd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,11 +16,17 @@ import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; import 'services/offline_map_service.dart'; +import 'services/watch/watch_bridge_service.dart'; import 'utils/debug_logger_io.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Before anything slow. A watch command can launch this process, and the + // native relay fires as soon as WatchConnectivity delivers — long before the + // provider finishes initializing and starts listening. + WatchBridgeService.reserveCommandQueue(); + // Enable debug file logging FIRST on mobile to capture early logs // This must happen before DebugLogger.initialize() to capture early logs if (!kIsWeb) { diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index bcb9a275..729b5dee 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -77,6 +77,39 @@ class WatchBridgeService { static const String _channelName = 'meshmapper/watch'; + /// How many wrist commands may wait for Dart to start listening. + /// + /// A queued command can wake a phone app that was not running, and + /// `didReceiveUserInfo` fires as soon as WatchConnectivity has it — which can + /// be well before [attachCommandHandler] runs, since that happens partway + /// through the provider's asynchronous initialization. `transferUserInfo` + /// hands each command over exactly once, so anything dropped in that window + /// is gone: the wrist shows a spinner, times out after ten seconds, and says + /// nothing about why. + /// + /// Flutter already buffers platform messages sent before a handler exists, + /// but only one deep by default, so a wearer who tapped twice lost the first + /// tap. Eight is well past any plausible burst — the wearer has one Start and + /// one Ping button — while still bounded, because these are intents that go + /// through the age window and the ID cache on arrival, not state to replay. + static const int _commandQueueDepth = 8; + + /// Reserve that room. Must run before the first command can arrive, which in + /// practice means the top of `main()`. + /// + /// This narrows the window rather than closing it. Messages that arrive + /// before Dart's entrypoint executes at all are still governed by the default + /// depth of one — resizing upward keeps whatever is already queued, so that + /// one survives, but a burst landing in the gap between engine start and this + /// call cannot. Closing it completely means buffering natively and handshaking + /// a ready signal, which is a lot of moving parts for a smaller window than + /// the one this covers. + static void reserveCommandQueue() { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return; + ServicesBinding.instance.channelBuffers + .resize(_channelName, _commandQueueDepth); + } + /// The three durations a test may shorten. /// /// Without a seam here every throttle assertion had to wait out the real diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 2a021a73..4378e11c 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -2173,4 +2174,59 @@ void main() { expect(sentLat(1), 47.6002); }); }); + + group('command queue depth', () { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + ServicesBinding.instance.channelBuffers.resize('meshmapper/watch', 1); + }); + + /// Push straight at the channel buffer, the way the engine does for a + /// platform message with no Dart handler yet. + Future push(String id) { + final data = const StandardMethodCodec().encodeMethodCall( + MethodCall('command', {'id': id, 'kind': 'manualPing'}), + ); + final completer = Completer(); + ServicesBinding.instance.channelBuffers.push( + 'meshmapper/watch', + data, + (_) => completer.complete(), + ); + return completer.future; + } + + test('commands arriving before Dart listens are not lost', () async { + // A queued command can launch this process, and the native relay fires + // as soon as WatchConnectivity delivers — before attachCommandHandler + // runs partway through the provider's async init. transferUserInfo hands + // each command over exactly once, so a dropped one is gone: the wrist + // spins for ten seconds and then says nothing. + WatchBridgeService.reserveCommandQueue(); + + unawaited(push('early-1')); + unawaited(push('early-2')); + unawaited(push('early-3')); + + final seen = []; + final bridge = WatchBridgeService( + channel: const MethodChannel('meshmapper/watch'), + ); + addTearDown(bridge.dispose); + bridge.attachCommandHandler((command) { + seen.add(command.kind.name); + return null; + }); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(seen.length, 3, + reason: 'the default depth of one keeps only the newest'); + }); + }); } From 5064e240d84304717caf86406d52fdd4b9e756dd Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 19 Aug 2026 14:28:11 -0700 Subject: [PATCH 274/360] Put the watch's decisions somewhere a test can reach them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion app shipped with no tests at all, and four commits on this branch went in syntax-checked only. The cause was structural rather than neglect: `WatchSessionClient` is `@Observable`, `@MainActor`, and reaches `WCSession.default` through a computed property with no injection point, so nothing inside it could be exercised without a paired watch. The rest of the branch pushed testable logic into Dart, which worked — the wire contract, the geo builder and the start gates all have real coverage — but that strategy covers nothing only expressible in Swift, which is exactly where the last several bugs were. So the decisions move to `WatchWireRules` in the shared, Foundation-only payload file: snapshot ordering, cue presentation, and the staleness origin. The client keeps its observable state and its timers and now delegates. That file was already compiled into both app targets, so this needed no project change at all — worth having, given this branch has already had to correct build-phase ordering once. Tested by a SwiftPM package rather than an Xcode unit-test bundle. A watchOS test target wants a host app, a simulator and a signing identity to test code that depends on none of them; `swift test` runs these in milliseconds on any toolchain. The package compiles the shipping source through a symlink, so there is no copy to drift. Sixteen tests, each mutation-checked: removing the ordering guard, removing its stale escape hatch, collapsing the cue read window into the haptic window, ignoring the clock offset, and dropping the arrival clamp are all caught. One of them caught me rather than the code. I expected `reception` to age a payload from `producedAt`; it ages from `producedAt` plus `clockTolerance`, clamped to arrival. The slack is spent making a payload look *younger* so ordinary skew cannot age a live one early — it is not a symmetric error bar, and a snapshot reads up to five seconds fresher than it is. The test now says so, because I had to learn it the slow way. CI gains a macOS job running those tests and type-checking every watch source against the watchOS SDK. The second half closes a gap worth naming on its own: a Swift break in the companion app now fails a PR instead of Adam's next device build. It type-checks rather than builds, so it still knows nothing about target membership, embed phases, entitlements or signing — a file added to the folder and never added to the target passes here and fails in Xcode. Still uncovered, and documented as such: WatchConnectivity delivery, SwiftUI, MapKit. The two-path delivery race in particular is reasoning plus a unit test of the rule, not evidence from a wrist. --- .github/workflows/ci.yml | 31 ++ .gitignore | 3 + DEVELOPMENT.md | 25 ++ ios/MeshMapperWatch/WatchSessionClient.swift | 79 +---- ios/Shared/MeshMapperWatchPayload.swift | 134 +++++++++ ios/WatchLogicTests/Package.swift | 24 ++ .../WatchWire/MeshMapperWatchPayload.swift | 1 + .../WatchLogicTests/WatchWireRulesTests.swift | 275 ++++++++++++++++++ 8 files changed, 508 insertions(+), 64 deletions(-) create mode 100644 ios/WatchLogicTests/Package.swift create mode 120000 ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift create mode 100644 ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3f3bd4..b6711d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,34 @@ jobs: - run: dart format --output=none --set-exit-if-changed . - run: flutter analyze - run: flutter test + + watch-logic: + name: Watch Wire Rules + # The watch app's decision logic — snapshot ordering, cue presentation, + # staleness — decides what the wearer sees and was previously unreachable + # from CI: the analyze job runs on Linux and `flutter test` compiles no + # Swift. These are plain SwiftPM tests over Foundation-only sources, so + # they need no Xcode project, simulator or signing identity, and run in + # seconds. This does not cover WatchConnectivity, SwiftUI or MapKit; those + # still want a wrist. + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Unit-test the wire rules + run: swift test + working-directory: ios/WatchLogicTests + + # Compiles every watch source against the real watchOS SDK without an + # Xcode project, a simulator or a signing identity, so a Swift break in + # the companion app fails a PR instead of Adam's next device build. + # + # It type-checks sources; it does not link, and it knows nothing about + # target membership. A file added to the folder but never added to the + # target still passes here and still fails to build in Xcode, as do + # embed-phase, entitlement and signing mistakes. + - name: Type-check the watch target + run: > + xcrun --sdk watchos swiftc -typecheck + -target arm64_32-apple-watchos11.0 + ios/MeshMapperWatch/*.swift ios/Shared/MeshMapperWatchPayload.swift diff --git a/.gitignore b/.gitignore index d7c37fee..48592770 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ drive_route_map*.html # Claude Code .claude/ CLAUDE.md + +# SwiftPM build products for the watch wire-rule tests +ios/WatchLogicTests/.build/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 340ea52c..eefd537a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,8 +33,33 @@ flutter test # Run a single test file flutter test test/services/gps_service_test.dart + +# Watch wire rules — snapshot ordering, cue presentation, staleness. +# Plain SwiftPM over Foundation-only sources: no Xcode project, no simulator, +# no signing. macOS only. +(cd ios/WatchLogicTests && swift test) + +# Type-check every watch source against the watchOS SDK, without building +xcrun --sdk watchos swiftc -typecheck -target arm64_32-apple-watchos11.0 \ + ios/MeshMapperWatch/*.swift ios/Shared/MeshMapperWatchPayload.swift ``` +**What the watch is and is not covered by.** `WatchWireRules` in +`ios/Shared/MeshMapperWatchPayload.swift` holds the decisions that pick what the +wearer sees, deliberately kept Foundation-only and free of `WCSession` so they +can be tested at all — `WatchSessionClient` is `@Observable`, `@MainActor`, and +reaches `WCSession.default` through a computed property with no injection point. +Logic that belongs to the wire goes there; the client keeps observable state and +timers. `ios/WatchLogicTests` compiles the shipping file through a symlink, so +there is no copy to drift. + +That covers the rules and, via the type-check, a Swift compile break. It does +**not** cover WatchConnectivity delivery, SwiftUI, MapKit, or anything about +target membership: a file added to `ios/MeshMapperWatch/` but never added to the +target type-checks here and still fails to build in Xcode, as do embed-phase, +entitlement and signing mistakes. Those still need a real build, and the +delivery races still need a wrist. + ### Building for Release ```bash # Use Build.sh — prompts for API key and signing passwords diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index a584bb50..77b4a71b 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -111,15 +111,10 @@ final class WatchSessionClient: NSObject { /// 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 + /// The timing rules this client applies live in `WatchWireRules`, which is + /// Foundation-only and therefore reachable from `swift test`. Kept as an + /// alias because the surrounding view code reads better for it. + static let staleAfter = WatchWireRules.staleAfter private static let mapGeoSuppressionDelay: TimeInterval = 15 private static let mapGeoRenewalInterval: TimeInterval = 5 * 60 private static let mapGeoRecoveryThrottle: TimeInterval = 3 @@ -396,21 +391,13 @@ final class WatchSessionClient: NSObject { 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 { inLocalTime($0) } - .map { min(arrival, $0.addingTimeInterval(Self.clockTolerance)) } ?? arrival + let (origin, remaining) = WatchWireRules.reception( + arrival: arrival, + producedAt: producedAt, + clockOffset: clockOffset + ) receivedAt = origin - let remaining = Self.staleAfter - arrival.timeIntervalSince(origin) guard remaining > 0 else { isStale = true return @@ -427,45 +414,6 @@ final class WatchSessionClient: NSObject { } } - /// A phone-stamped instant expressed in this watch's clock. - private func inLocalTime(_ phoneInstant: Date) -> Date { - guard let clockOffset else { return phoneInstant } - return phoneInstant.addingTimeInterval(-clockOffset) - } - - /// How much of a cue is still worth presenting, given its age. - private enum CuePresentation { - /// Recent enough that the wearer can connect the buzz to what they did. - case feel - /// Too old to buzz for, but still the only account of the failure they - /// will ever get: the phone drops a cue once WatchConnectivity accepts the - /// snapshot carrying it, which happens while the watch is still suspended. - case read - case drop - } - - /// A wrist-down interval longer than [cueFreshFor] used to discard the cue - /// outright — no haptic *and* no message — so a wearer who tapped Start, - /// dropped their wrist and looked back a minute later was never told why the - /// session had not begun. Buzzing for something that old is wrong, but - /// staying silent about it is worse. - /// - /// The upper bound is [staleAfter], the same boundary that greys the rest of - /// the screen: past it the wearer is already being told the whole surface is - /// old, and a banner asserting a stale failure as current would be its own - /// lie. - private func presentation( - for cue: WatchHapticCue, - at arrival: Date - ) -> CuePresentation { - guard let issuedAt = cue.issuedAt else { return .drop } - let age = arrival.timeIntervalSince(inLocalTime(issuedAt)) - // A few seconds of skew must not suppress a real failure that has just - // crossed the radio. - guard age >= -Self.clockTolerance else { return .drop } - if age <= Self.cueFreshFor { return .feel } - return age <= Self.staleAfter ? .read : .drop - } /// The main-actor half of the activation callback. private func completeActivation() { @@ -653,8 +601,7 @@ final class WatchSessionClient: NSObject { /// to: for one to be rejected, a newer payload must already have been /// applied, and that one cleared it. private func isNewerThanCurrent(_ decoded: WatchSnapshot) -> Bool { - guard let current = snapshot, !isStale else { return true } - return decoded.updatedAt >= current.updatedAt + WatchWireRules.supersedes(decoded, current: snapshot, isStale: isStale) } private func apply(_ decoded: WatchSnapshot, live: Bool = false) { @@ -704,7 +651,11 @@ final class WatchSessionClient: NSObject { } if let cue = decoded.cue, - case let presentation = presentation(for: cue, at: arrival), + case let presentation = WatchWireRules.presentation( + for: cue, + at: arrival, + clockOffset: clockOffset + ), presentation != .drop, presentedCueIDs.insert(cue.id).inserted { diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 54c88747..daff69bf 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -451,3 +451,137 @@ extension MeshMapperWatchWire { static let payloadKey = "snapshot" static let commandKey = "command" } + +/// The wire's timing rules, as pure functions. +/// +/// These decide what the wearer sees: whether an arriving payload supersedes +/// the one on screen, how old a snapshot is allowed to look before the surface +/// greys, and whether a failure cue is buzzed, shown silently, or dropped. +/// +/// **They live here to be testable.** `WatchSessionClient` is `@Observable`, +/// `@MainActor`, and reaches `WCSession.default` through a computed property +/// with no injection point, so nothing inside it can be exercised without a +/// paired watch. These rules need none of that — a date, an optional offset, +/// and a decoded payload — and this file is already Foundation-only and +/// already compiled into every target that needs it, so moving them costs no +/// project surgery. `WatchLogicTests` runs them under `swift test` on any +/// toolchain; the client keeps the observable state and the timers. +/// +/// Phone-side mirrors live in `lib/services/watch/watch_models.dart`: +/// `WatchWire.cueReadableFor` tracks `staleAfter`, and the phone stops +/// attaching a cue at the same boundary the watch stops presenting one. +enum WatchWireRules { + /// 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 + + /// Recent enough that the wearer can still connect a buzz to what they did. + 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. + static let clockTolerance: TimeInterval = 5 + + /// How much of a cue is still worth presenting, given its age. + enum CuePresentation { + /// Recent enough that the wearer can connect the buzz to what they did. + case feel + /// Too old to buzz for, but still the only account of the failure they + /// will ever get: the phone attaches a cue until it passes `staleAfter`, + /// and most of that window is spent with the wrist down. + case read + case drop + } + + /// A phone-stamped instant expressed in this watch's clock. + static func inLocalTime( + _ phoneInstant: Date, + clockOffset: TimeInterval? + ) -> Date { + guard let clockOffset else { return phoneInstant } + return phoneInstant.addingTimeInterval(-clockOffset) + } + + /// A wrist-down interval longer than [cueFreshFor] used to discard the cue + /// outright — no haptic *and* no message — so a wearer who tapped Start, + /// dropped their wrist and looked back a minute later was never told why the + /// session had not begun. Buzzing for something that old is wrong, but + /// staying silent about it is worse. + /// + /// The upper bound is [staleAfter], the same boundary that greys the rest of + /// the screen: past it the wearer is already being told the whole surface is + /// old, and a banner asserting a stale failure as current would be its own + /// lie. + static func presentation( + for cue: WatchHapticCue, + at arrival: Date, + clockOffset: TimeInterval? + ) -> CuePresentation { + guard let issuedAt = cue.issuedAt else { return .drop } + let age = arrival.timeIntervalSince( + inLocalTime(issuedAt, clockOffset: clockOffset) + ) + // A few seconds of skew must not suppress a real failure that has just + // crossed the radio. + guard age >= -clockTolerance else { return .drop } + if age <= cueFreshFor { return .feel } + return age <= staleAfter ? .read : .drop + } + + /// Whether `incoming` supersedes what is already rendered. + /// + /// **The two delivery paths are not ordered with respect to each other.** An + /// urgent update goes out as `sendMessage` *and* as + /// `updateApplicationContext`, and only the first is fast. `sendMessage` + /// does not populate `receivedApplicationContext`, so the retained context + /// keeps whatever last arrived down the slow path — which can be a payload + /// already superseded live. `resume` ingests that context on every wrist + /// raise, and last-writer-wins then walks the wearer backwards onto state + /// that is under 90 s old, so nothing asks for better. + /// + /// Both stamps come from the one phone clock, so they compare raw — + /// `inLocalTime` is for phone-instant against watch-now, which this is not. + /// Equal stamps are accepted: two payloads built in the same millisecond are + /// interchangeable, and a forced refresh always restamps `updatedAt`, so it + /// can never be mistaken for a replay of itself. + /// + /// **A stale surface accepts anything.** Ordering is worth enforcing only + /// while what is held is still worth protecting. Bounding it this way means + /// the worst case of a phone clock stepping backwards is 90 seconds of + /// refusal, after which the boundary flips and the watch recovers on its + /// own. A bare comparison would refuse for the life of the process. + static func supersedes( + _ incoming: WatchSnapshot, + current: WatchSnapshot?, + isStale: Bool + ) -> Bool { + guard let current, !isStale else { return true } + return incoming.updatedAt >= current.updatedAt + } + + /// When a snapshot should be aged from, and how long it has left. + /// + /// 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 [clockTolerance] of slack so + /// ordinary skew does not age a genuinely live payload early. + /// + /// A non-positive `remaining` means the payload is already stale on arrival. + static func reception( + arrival: Date, + producedAt: Date?, + clockOffset: TimeInterval? + ) -> (origin: Date, remaining: TimeInterval) { + let origin = + producedAt + .map { inLocalTime($0, clockOffset: clockOffset) } + .map { min(arrival, $0.addingTimeInterval(clockTolerance)) } ?? arrival + return (origin, staleAfter - arrival.timeIntervalSince(origin)) + } +} diff --git a/ios/WatchLogicTests/Package.swift b/ios/WatchLogicTests/Package.swift new file mode 100644 index 00000000..be2d834f --- /dev/null +++ b/ios/WatchLogicTests/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Unit tests for the watch wire's decision logic. +// +// A package rather than an Xcode unit-test bundle, deliberately. A watchOS test +// target needs a host app, a simulator and a signing identity, and adding one +// means hand-editing a project file this branch has already had to correct once +// for build-phase ordering — all to test code that depends on none of it. +// `WatchWireRules` and the payload models are Foundation-only, so `swift test` +// exercises them on any toolchain, with no Xcode project involved at all. +// +// `Sources/WatchWire/MeshMapperWatchPayload.swift` is a symlink to the shipping +// file in `ios/Shared`, which SwiftPM requires to live under the package root. +// A symlink rather than a copy so there is nothing to drift: the tests compile +// the same bytes the app targets do. +let package = Package( + name: "WatchLogicTests", + platforms: [.macOS(.v13)], + targets: [ + .target(name: "WatchWire"), + .testTarget(name: "WatchLogicTests", dependencies: ["WatchWire"]), + ] +) diff --git a/ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift b/ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift new file mode 120000 index 00000000..2485d55e --- /dev/null +++ b/ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift @@ -0,0 +1 @@ +../../../Shared/MeshMapperWatchPayload.swift \ No newline at end of file diff --git a/ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift b/ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift new file mode 100644 index 00000000..33f636d9 --- /dev/null +++ b/ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift @@ -0,0 +1,275 @@ +import Foundation +import XCTest + +@testable import WatchWire + +/// Tests for the wire's timing rules — the decisions that pick what the wearer +/// sees. Everything here was previously reachable only through +/// `WatchSessionClient`, which needs a paired watch to exercise at all. +final class WatchWireRulesTests: XCTestCase { + private let base = Date(timeIntervalSince1970: 1_770_000_000) + + private func snapshot(updatedAt: Date, sessionId: String = "s1") -> WatchSnapshot { + WatchSnapshot( + wireVersion: 2, + sessionId: sessionId, + mode: "Passive", + phase: "active", + phaseTitle: "Passive active", + phaseDetail: nil, + phaseEndsAtMs: nil, + phaseDurationMs: nil, + isConnected: true, + zoneCode: "SEA", + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + pingColor: nil, + geo: WatchGeo( + you: nil, + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [] + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ), + cue: nil, + updatedAtMs: updatedAt.timeIntervalSince1970 * 1000 + ) + } + + private func cue(issuedAt: Date?) -> WatchHapticCue { + WatchHapticCue( + id: "cue-1", + kind: "failure", + issuedAtMs: issuedAt.map { $0.timeIntervalSince1970 * 1000 }, + message: "Could not start" + ) + } + + // MARK: - Ordering + + func testFirstSnapshotIsAlwaysAccepted() { + XCTAssertTrue( + WatchWireRules.supersedes( + snapshot(updatedAt: base), current: nil, isStale: false + ) + ) + } + + func testNewerSnapshotSupersedes() { + XCTAssertTrue( + WatchWireRules.supersedes( + snapshot(updatedAt: base.addingTimeInterval(1)), + current: snapshot(updatedAt: base), + isStale: false + ) + ) + } + + /// The defect this rule exists for. The phone sends P2 down both paths, the + /// watch applies P2 from the live message, the wrist drops and rises before + /// context P2 arrives, and `resume` ingests a retained context still holding + /// P1. Last-writer-wins walked the wearer backwards onto state too young to + /// look stale, so nothing asked for anything better. + func testASlowRetainedContextCannotOverwriteANewerLiveOne() { + let p1 = snapshot(updatedAt: base) + let p2 = snapshot(updatedAt: base.addingTimeInterval(5)) + + XCTAssertFalse( + WatchWireRules.supersedes(p1, current: p2, isStale: false), + "an out-of-order application context must not replace a newer live payload" + ) + } + + /// A forced refresh restamps `updatedAt`, so it is strictly newer and can + /// never be mistaken for a replay of itself. Equal stamps are still accepted: + /// two payloads built in the same millisecond are interchangeable. + func testIdenticalTimestampsAreAccepted() { + XCTAssertTrue( + WatchWireRules.supersedes( + snapshot(updatedAt: base), + current: snapshot(updatedAt: base), + isStale: false + ) + ) + } + + /// The bound that keeps a backwards phone clock from being permanent. Once + /// the held snapshot is stale, anything is better than what is on screen. + func testAStaleSurfaceAcceptsAnythingSoAClockStepCannotStall() { + let older = snapshot(updatedAt: base) + let newer = snapshot(updatedAt: base.addingTimeInterval(60)) + + XCTAssertFalse( + WatchWireRules.supersedes(older, current: newer, isStale: false) + ) + XCTAssertTrue( + WatchWireRules.supersedes(older, current: newer, isStale: true), + "refusal must lift at the stale boundary, or the watch never recovers" + ) + } + + // MARK: - Cue presentation + + func testAFreshCueIsFelt() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), at: base.addingTimeInterval(5), + clockOffset: nil + ), + .feel + ) + } + + /// The wrist-down case. Too old to buzz for, but still the only account of + /// the failure the wearer will ever get. + func testACueTooOldToFeelIsStillRead() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), at: base.addingTimeInterval(45), + clockOffset: nil + ), + .read + ) + } + + func testACueIsDroppedAtTheStaleBoundary() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), + at: base.addingTimeInterval(WatchWireRules.staleAfter + 1), + clockOffset: nil + ), + .drop, + "past the boundary the whole surface reads as old; a cue must not claim to be current" + ) + } + + /// The phone stops attaching a cue at exactly this boundary + /// (`WatchWire.cueReadableFor`). If the two ever drift apart, one side is + /// sending bytes the other discards, or dropping a failure the other expects + /// to show. + func testTheReadWindowEndsWhereTheSurfaceGoesStale() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), + at: base.addingTimeInterval(WatchWireRules.staleAfter - 1), + clockOffset: nil + ), + .read + ) + } + + func testAnUndatedCueIsDroppedRatherThanReplayed() { + XCTAssertEqual( + WatchWireRules.presentation(for: cue(issuedAt: nil), at: base, clockOffset: nil), + .drop + ) + } + + /// A few seconds of skew must not suppress a real failure that has just + /// crossed the radio, but a wildly future-dated one is not credible. + func testSkewIsToleratedButAFutureCueIsNot() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base.addingTimeInterval(2)), at: base, clockOffset: nil + ), + .feel + ) + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base.addingTimeInterval(120)), at: base, clockOffset: nil + ), + .drop + ) + } + + /// The offset converts a phone stamp into watch time. Without it applied, a + /// phone running a minute ahead would look like a cue from the future and be + /// dropped — the failure the offset exists to prevent. + func testTheClockOffsetIsAppliedBeforeAging() { + let phoneAheadBy: TimeInterval = 60 + let issued = base.addingTimeInterval(phoneAheadBy) + + XCTAssertEqual( + WatchWireRules.presentation(for: cue(issuedAt: issued), at: base, clockOffset: nil), + .drop + ) + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: issued), at: base, clockOffset: phoneAheadBy + ), + .feel + ) + } + + // MARK: - Reception age + + func testAgeRunsFromWhenThePhoneBuiltThePayload() { + // Launch ingests a retained context that can be hours old. Stamping + // arrival would present long-dead state as fresh for a full 90 seconds. + // + // Note the direction of the tolerance: origin is `producedAt` plus + // `clockTolerance`, not `producedAt`. The slack is spent making a payload + // look *younger*, so ordinary skew cannot age a genuinely live one early — + // it is not a symmetric error bar. A snapshot therefore reads up to five + // seconds fresher than it is, which is the intended trade and the reason + // the clamp to `arrival` below has to exist. + let produced = base + let arrival = base.addingTimeInterval(60) + let result = WatchWireRules.reception( + arrival: arrival, producedAt: produced, clockOffset: nil + ) + + XCTAssertEqual( + result.origin, + produced.addingTimeInterval(WatchWireRules.clockTolerance) + ) + XCTAssertEqual( + result.remaining, + WatchWireRules.staleAfter - 60 + WatchWireRules.clockTolerance, + accuracy: 0.001 + ) + } + + func testARetainedContextArrivesAlreadyStale() { + let result = WatchWireRules.reception( + arrival: base.addingTimeInterval(3600), producedAt: base, clockOffset: nil + ) + + XCTAssertLessThanOrEqual(result.remaining, 0) + } + + /// A phone clock running fast must not be able to date a payload into the + /// future and extend its life beyond the boundary. + func testAFastPhoneClockCannotExtendASnapshotsLife() { + let arrival = base + let result = WatchWireRules.reception( + arrival: arrival, + producedAt: base.addingTimeInterval(600), + clockOffset: nil + ) + + XCTAssertEqual(result.origin, arrival, "origin is clamped to arrival") + XCTAssertEqual(result.remaining, WatchWireRules.staleAfter, accuracy: 0.001) + } + + func testAnUndatedPayloadAgesFromArrival() { + let result = WatchWireRules.reception( + arrival: base, producedAt: nil, clockOffset: nil + ) + + XCTAssertEqual(result.origin, base) + XCTAssertEqual(result.remaining, WatchWireRules.staleAfter, accuracy: 0.001) + } +} From 423a78e186f6d14b0d095ed4b57acc86ad5809ee Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 19 Aug 2026 22:32:37 -0400 Subject: [PATCH 275/360] Run dart format across the codebase CI's format gate only runs on pull requests, so unformatted code accumulated on dev through direct pushes and failed PR #29's check. Mechanical rewraps only, plus braces on four ifs the formatter split onto two lines (keeps curly_braces_in_flow_control_structures quiet). --- bin/test_offline_upload.dart | 24 +- lib/models/log_entry.dart | 1 + lib/models/user_preferences.dart | 1 - lib/providers/app_state_provider.dart | 103 +- lib/screens/connection_screen.dart | 45 +- lib/screens/log_screen.dart | 22 +- lib/screens/offline_maps_screen.dart | 30 +- lib/screens/settings_screen.dart | 8 +- lib/services/api_service.dart | 12 +- lib/services/bluetooth/mobile_bluetooth.dart | 3 +- lib/services/custom_api_service.dart | 3 +- lib/services/meshcore/connection.dart | 3 +- lib/services/meshcore/rx_logger.dart | 2 + lib/services/meshcore/tx_tracker.dart | 11 +- lib/services/meshcore/unified_rx_handler.dart | 3 +- lib/services/offline_map_service.dart | 16 +- lib/services/offline_session_service.dart | 9 +- lib/services/ping_service.dart | 19 +- .../transport/android_serial_service.dart | 16 +- .../transport/stream_frame_codec.dart | 4 +- .../transport/stream_transport_base.dart | 3 +- lib/services/transport/tcp_service.dart | 9 +- lib/utils/coverage_summary.dart | 55 +- lib/utils/geo_validation.dart | 3 +- lib/utils/repeater_format.dart | 4 +- lib/widgets/cell_summary_sheet.dart | 13 +- lib/widgets/map_widget.dart | 1315 +++++---- lib/widgets/noise_floor_chart.dart | 3 +- lib/widgets/ping_controls.dart | 2454 +++++++++-------- .../transport/stream_frame_codec_test.dart | 9 +- test/services/wire_tag_codec_test.dart | 11 +- test/utils/coverage_summary_test.dart | 21 +- 32 files changed, 2121 insertions(+), 2114 deletions(-) diff --git a/bin/test_offline_upload.dart b/bin/test_offline_upload.dart index 89ba5d48..8a47c9e5 100644 --- a/bin/test_offline_upload.dart +++ b/bin/test_offline_upload.dart @@ -62,8 +62,10 @@ List> generateTestItems( double baseLat = 45.3215, double baseLon = -75.6693, }) { - final baseTimestamp = - (DateTime.now().subtract(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000); + final baseTimestamp = (DateTime.now() + .subtract(const Duration(hours: 24)) + .millisecondsSinceEpoch ~/ + 1000); final items = >[]; for (var i = 0; i < count; i++) { @@ -633,7 +635,8 @@ void printSummary(List results) { print(' Auth failed: ${result.failureReason}'); } else if (result.passed) { print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); - print(' PASSED ${result.totalUploaded} items across ${result.batches.length} batches'); + print( + ' PASSED ${result.totalUploaded} items across ${result.batches.length} batches'); } else { print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); print( @@ -656,15 +659,13 @@ void printSummary(List results) { print(' FINDINGS:'); final rapidFire = validResults - .where( - (r) => r.interBatchDelay.inSeconds == 0 && r.batchSize == 50) + .where((r) => r.interBatchDelay.inSeconds == 0 && r.batchSize == 50) .toList(); final throttled = validResults .where((r) => r.interBatchDelay.inSeconds > 0 && r.batchSize == 50) .toList(); final batchSweep = validResults - .where( - (r) => r.interBatchDelay.inSeconds == 0 && r.batchSize != 50) + .where((r) => r.interBatchDelay.inSeconds == 0 && r.batchSize != 50) .toList(); // Check if rapid fire failed but throttled passed @@ -686,8 +687,7 @@ void printSummary(List results) { final rapidItems = rapidFire.first.totalUploaded; final throttledItems = throttled.first.totalUploaded; if ((rapidItems - throttledItems).abs() < 60) { - print( - ' - Both rapid and throttled fail at ~$rapidItems items.'); + print(' - Both rapid and throttled fail at ~$rapidItems items.'); print( ' - CONCLUSION: Server-side ITEM COUNT LIMIT (~$rapidItems per session).'); print( @@ -700,8 +700,7 @@ void printSummary(List results) { } } else if (validResults.every((r) => r.passed)) { print(' - All scenarios passed!'); - print( - ' - Could not reproduce the session invalidation in this run.'); + print(' - Could not reproduce the session invalidation in this run.'); print( ' - The issue may depend on server load, time of day, or specific session state.'); } @@ -798,7 +797,8 @@ Future main(List arguments) async { exit(1); } if (pubkey == null && contactUri == null) { - print('Error: --pubkey= or --contact-uri= is required'); + print( + 'Error: --pubkey= or --contact-uri= is required'); printUsage(); exit(1); } diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 0f5a6320..d8022b16 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -108,6 +108,7 @@ class RxLogEntry { final int header; // Packet header byte final double latitude; final double longitude; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. final List pathHops; diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 2090262b..064c11f6 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -111,7 +111,6 @@ class UserPreferences { /// layer so users can see the base map underneath the coverage squares. final double coverageOverlayOpacity; - /// Coverage grid preset, matching the web UI's Grid Mode: 300 = Simplified /// (300 m cells, the default), 100 = Detailed (100 m cells + 3×3 blob, /// applied server-side). Only these two values are valid. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 70ba024d..f152fc31 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -496,8 +496,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isPendingDisable && !_rxWindowTimer.isRunning && !_discoveryWindowTimer.isRunning) { - debugWarn( - '[TIMER] pendingDisable stuck true with no RX/discovery window ' + debugWarn('[TIMER] pendingDisable stuck true with no RX/discovery window ' 'running — locks ping controls until restart [$reason]'); } } @@ -730,6 +729,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final x = (((lon + 180.0) / 360.0) * n).floor(); return x < 0 ? 0 : (x >= n ? n - 1 : x); } + int latToY(double lat) { final latRad = lat * math.pi / 180.0; final sinhArg = math.tan(latRad); @@ -1035,6 +1035,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _floodDisabledAlertPending = false; notifyListeners(); } + int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; @@ -1619,12 +1620,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[CONN] Firmware name is "Anonymous" but no persisted real name found'); } } - deviceName = selfInfoName ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); + deviceName = + selfInfoName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); } if (deviceName == null || deviceName.isEmpty) { - debugError( - '[APP] Cannot request auth: could not retrieve device name'); + debugError('[APP] Cannot request auth: could not retrieve device name'); return { 'success': false, 'reason': 'no_device_name', @@ -1687,8 +1687,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } - debugLog( - '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { @@ -1761,8 +1760,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _meshCoreConnection != null) { try { final deviceTime = await _meshCoreConnection!.getDeviceTime(); - final appTime = - DateTime.now().millisecondsSinceEpoch ~/ 1000; + final appTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; final drift = deviceTime - appTime; debugError( '[APP] Device clock: $deviceTime, app clock: $appTime, drift: ${drift}s'); @@ -1857,8 +1855,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (status == ConnectionStatus.disconnected) { final wasConnected = _connectionStep == ConnectionStep.connected; final hasRemembered = _rememberedDevice != null; - final isUnexpected = - !_userRequestedDisconnect && !_isAutoReconnecting; + final isUnexpected = !_userRequestedDisconnect && !_isAutoReconnecting; final canAutoReconnect = hasRemembered && !kIsWeb && _rememberedDevice!.transportType != TransportType.usbSerial; @@ -2032,8 +2029,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _meshCoreConnection!.stepStream.listen((step) { _connectionStep = step; if (step == ConnectionStep.connected) { - _manufacturerString = - _meshCoreConnection!.deviceInfo?.manufacturer; + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; @@ -2138,8 +2134,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final usbProductName = usbDevice['productName'] as String? ?? 'USB Serial'; - final usbDeviceName = - usbDevice['deviceName'] as String? ?? 'USB Serial'; + final usbDeviceName = usbDevice['deviceName'] as String? ?? 'USB Serial'; final serialService = AndroidSerialService( deviceName: usbDeviceName, productName: usbProductName, @@ -2162,8 +2157,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _meshCoreConnection!.stepStream.listen((step) { _connectionStep = step; if (step == ConnectionStep.connected) { - _manufacturerString = - _meshCoreConnection!.deviceInfo?.manufacturer; + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; @@ -2284,8 +2278,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _meshCoreConnection!.stepStream.listen((step) { _connectionStep = step; if (step == ConnectionStep.connected) { - _manufacturerString = - _meshCoreConnection!.deviceInfo?.manufacturer; + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; @@ -2453,8 +2446,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith( - autoPingInterval: _apiService.minModeInterval); + _preferences = + _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); debugLog( '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); } @@ -2656,11 +2649,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isNew) { multiHopEvents.add(newEvent); _audioService.playReceiveSound(); - _pingStats = - _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); + _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); } else { - final idx = multiHopEvents - .indexWhere((e) => e.repeaterId == repeaterId); + final idx = + multiHopEvents.indexWhere((e) => e.repeaterId == repeaterId); if (idx >= 0) { multiHopEvents[idx] = newEvent; } @@ -2686,10 +2678,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingTimer.startWithSkipReason(intervalMs, skipReason); if (skipReason != null) { - if (_preferences.autoStopAfterIdle && - _idleAutoStopReference != null) { - final elapsed = - DateTime.now().difference(_idleAutoStopReference!); + if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + final elapsed = DateTime.now().difference(_idleAutoStopReference!); if (elapsed >= _autoStopIdleTimeout) { _triggerIdleAutoStop(); } @@ -2856,8 +2846,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { debugLog( '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); - logError( - 'Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', + logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }; @@ -5039,7 +5028,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { offlineMode: true, skipSessionStore: true, ); - if (authResult != null) break; // got a response (success OR a real rejection) + if (authResult != null) { + break; // got a response (success OR a real rejection) + } if (attempt >= authRetryBackoff.length) break; // retries exhausted final delay = authRetryBackoff[attempt]; debugWarn( @@ -5130,7 +5121,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (pc is Map) { pc.forEach((k, v) { final n = (v is int) ? v : (int.tryParse('$v') ?? 0); - placementTotals[k.toString()] = (placementTotals[k.toString()] ?? 0) + n; + placementTotals[k.toString()] = + (placementTotals[k.toString()] ?? 0) + n; }); } final tf = resp['too_far_region']; @@ -5154,9 +5146,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final batch = pings.skip(i).take(batchSize).toList(); final backoff = i == 0 ? firstBatchBackoff : laterBatchBackoff; - var result = - await _apiService.uploadBatchWithSessionId(batch, offlineSessionId, - onResponse: accumulatePlacement); + var result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId, + onResponse: accumulatePlacement); // Retry only session-propagation / transient errors. nonRetryable // (data/zone/key) errors are NOT retried — we stop and preserve instead. @@ -5172,9 +5164,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[OFFLINE] Batch $batchNum $kind error, retry ${retry + 1}/${backoff.length} after ${delay}s'); onProgress?.call('Batch $batchNum/$totalBatches (retry ${retry + 1})'); await Future.delayed(Duration(seconds: delay)); - result = - await _apiService.uploadBatchWithSessionId(batch, offlineSessionId, - onResponse: accumulatePlacement); + result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId, + onResponse: accumulatePlacement); } if (result == UploadResult.success) { @@ -5252,16 +5244,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { required String publicKey, required Map auth, }) { - final contact = publicKey.length >= 8 - ? publicKey.substring(0, 8).toUpperCase() - : null; + final contact = + publicKey.length >= 8 ? publicKey.substring(0, 8).toUpperCase() : null; var iata = zoneCode ?? _preferences.iataCode; final zone = auth['zone']; if (zone is Map && zone['code'] != null) { iata = zone['code'].toString(); } - debugLog( - '[OFFLINE] Custom API forward for batch $batchNum ' + debugLog('[OFFLINE] Custom API forward for batch $batchNum ' '(${batch.length} pings, contact=$contact, iata=$iata)'); _customApiService.forwardPings( batch, @@ -5557,7 +5547,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future setBroadcastCoords(bool enabled) async { _preferences = _preferences.copyWith(broadcastCoords: enabled); await _savePreferences(); - debugLog('[PING] Broadcast coordinates ${enabled ? 'enabled' : 'disabled'}'); + debugLog( + '[PING] Broadcast coordinates ${enabled ? 'enabled' : 'disabled'}'); notifyListeners(); } @@ -5591,7 +5582,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } - // ============================================ // API Error Handling // ============================================ @@ -5639,7 +5629,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case 'network_error': return 'Unable to connect to the MeshMapper server. Please check your internet connection and try again.'; case 'clock_error': - return serverMessage ?? 'Device clock error. Power-cycle your device to reset it.'; + return serverMessage ?? + 'Device clock error. Power-cycle your device to reset it.'; default: return serverMessage ?? 'Unknown error occurred.'; } @@ -5660,7 +5651,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await _apiQueueService.flushQueue(); } catch (e) { - debugError('[SESSION] Queue flush before session-limit disconnect failed: $e'); + debugError( + '[SESSION] Queue flush before session-limit disconnect failed: $e'); } await disconnect(); return; @@ -6356,8 +6348,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cooldownTimer.stop(); _pingService!.clearCooldown(); final resolvedMode = _resolveAutoModeForZone(previousMode); - debugLog( - '[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); + debugLog('[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); toggleAutoPing(resolvedMode); } }); @@ -6423,8 +6414,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle zone-to-zone transfer during active wardriving session. /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. - Future _handleZoneTransfer( - String newZoneCode, String newZoneName, + Future _handleZoneTransfer(String newZoneCode, String newZoneName, {bool? wasAutoPingOverride, AutoMode? previousModeOverride}) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); @@ -6678,8 +6668,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences.copyWith(discDropEnabled: _userOriginalDiscDrop!); } if (_userOriginalFloodTraffic != null) { - _preferences = - _preferences.copyWith(floodTrafficEnabled: _userOriginalFloodTraffic!); + _preferences = _preferences.copyWith( + floodTrafficEnabled: _userOriginalFloodTraffic!); } debugLog( '[ZONE] Preferences restored to user baseline before applying new zone policies'); @@ -7208,7 +7198,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); _selectedTransport = _rememberedDevice!.transportType; - debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name} (${_rememberedDevice!.transportType.name})'); + debugLog( + '[APP] Loaded remembered device: ${_rememberedDevice!.name} (${_rememberedDevice!.transportType.name})'); notifyListeners(); } } catch (e) { diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index afe79ad8..65f6978f 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -253,11 +253,10 @@ class _ConnectionScreenState extends State ); } - final canConnect = - appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - (appState.offlineMode || appState.inZone == true); + final canConnect = appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + (appState.offlineMode || appState.inZone == true); switch (appState.selectedTransport) { case TransportType.ble: @@ -285,9 +284,7 @@ class _ConnectionScreenState extends State icon: Icons.usb, label: 'Connect', color: Theme.of(context).colorScheme.primary, - onPressed: canConnect - ? () => _connectWebSerial(appState) - : null, + onPressed: canConnect ? () => _connectWebSerial(appState) : null, ); } return _buildBottomButton( @@ -296,7 +293,8 @@ class _ConnectionScreenState extends State color: Theme.of(context).colorScheme.primary, onPressed: canConnect ? () => setState(() { - _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); + _usbDevicesFuture = + AndroidSerialService.getAvailablePorts(); }) : null, ); @@ -1921,7 +1919,9 @@ class _ConnectionScreenState extends State for (final transport in available) Expanded( child: _buildTransportSegment( - context, appState, transport, + context, + appState, + transport, isSelected: appState.selectedTransport == transport, ), ), @@ -2109,7 +2109,9 @@ class _ConnectionScreenState extends State future: _savedTcpFuture ??= TcpService.getSavedConnections(), builder: (context, snapshot) { final saved = snapshot.data; - if (saved == null || saved.isEmpty) return const SizedBox.shrink(); + if (saved == null || saved.isEmpty) { + return const SizedBox.shrink(); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2120,8 +2122,8 @@ class _ConnectionScreenState extends State ), ), const SizedBox(height: 8), - ...saved.map((conn) => _buildSavedTcpTile( - context, appState, conn, canConnect)), + ...saved.map((conn) => + _buildSavedTcpTile(context, appState, conn, canConnect)), ], ); }, @@ -2189,7 +2191,9 @@ class _ConnectionScreenState extends State await TcpService.deleteConnection(conn.id); if (mounted) { final future = TcpService.getSavedConnections(); - setState(() { _savedTcpFuture = future; }); + setState(() { + _savedTcpFuture = future; + }); } }, ), @@ -2235,8 +2239,7 @@ class _ConnectionScreenState extends State icon: Icons.usb_off, iconColor: Colors.grey.withValues(alpha: 0.5), title: 'No USB Devices', - message: - 'Connect a MeshCore device via USB OTG and tap Refresh.', + message: 'Connect a MeshCore device via USB OTG and tap Refresh.', action: FilledButton.icon( onPressed: () => setState(() { _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); @@ -2255,14 +2258,11 @@ class _ConnectionScreenState extends State final pid = device['pid'] as int? ?? 0; return ListTile( leading: const Icon(Icons.usb), - title: Text( - device['productName'] as String? ?? 'USB Device'), + title: Text(device['productName'] as String? ?? 'USB Device'), subtitle: Text( 'VID: ${vid.toRadixString(16)} PID: ${pid.toRadixString(16)}'), enabled: canConnect, - onTap: canConnect - ? () => appState.connectViaUsb(device) - : null, + onTap: canConnect ? () => appState.connectViaUsb(device) : null, ); }, ); @@ -2281,7 +2281,8 @@ class _ConnectionScreenState extends State Icon( Icons.usb, size: 64, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + color: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index a922dd5d..84714d68 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -770,7 +770,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: nodeWidth, child: _tableHeader(context, 'Node')), + SizedBox( + width: nodeWidth, child: _tableHeader(context, 'Node')), Expanded(child: _tableHeader(context, 'SNR', center: true)), Expanded(child: _tableHeader(context, 'RSSI', center: true)), ], @@ -861,7 +862,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: nodeWidth, child: _tableHeader(context, 'Node')), + SizedBox( + width: nodeWidth, child: _tableHeader(context, 'Node')), Expanded(child: _tableHeader(context, 'SNR', center: true)), Expanded(child: _tableHeader(context, 'RSSI', center: true)), ], @@ -896,9 +898,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Expanded( child: Center( child: _buildChip( - event.rssi != null - ? '${event.rssi}' - : '-', + event.rssi != null ? '${event.rssi}' : '-', rssiColor))), ], ), @@ -916,9 +916,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Icon( Icons.route, size: 12, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(width: 4), @@ -1046,8 +1045,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Expanded( child: RxPathChain( hops: entry.pathHops, - fromLatLng: - (lat: entry.latitude, lon: entry.longitude), + fromLatLng: (lat: entry.latitude, lon: entry.longitude), fontSize: 12, ), ), @@ -1143,8 +1141,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildDiscNodeRow( - BuildContext context, DiscoveredNodeEntry node, List repeaters) { + Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node, + List repeaters) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 5549f918..f1bf8c8a 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -179,9 +179,8 @@ class _OfflineMapsScreenState extends State { const Spacer(), IconButton( icon: const Icon(Icons.refresh, size: 18), - onPressed: _tileCacheBusy - ? null - : () => service.refreshRegions(), + onPressed: + _tileCacheBusy ? null : () => service.refreshRegions(), tooltip: 'Refresh sizes', visualDensity: VisualDensity.compact, ), @@ -616,8 +615,8 @@ class _OfflineMapsScreenState extends State { child: TextButton.icon( onPressed: () => _confirmCancelDownload(context, service), icon: const Icon(Icons.close, size: 16, color: Colors.red), - label: const Text('Cancel', - style: TextStyle(color: Colors.red)), + label: + const Text('Cancel', style: TextStyle(color: Colors.red)), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), @@ -649,9 +648,7 @@ class _OfflineMapsScreenState extends State { return s == 0 ? '${d.inMinutes}m' : '${d.inMinutes}m ${s}s'; } final minutes = d.inMinutes % 60; - return minutes == 0 - ? '${d.inHours}h' - : '${d.inHours}h ${minutes}m'; + return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m'; } Future _confirmCancelDownload( @@ -1265,8 +1262,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), ) : const Icon(Icons.download), - label: - Text(_submitting ? 'Starting...' : 'Download Area'), + label: Text(_submitting ? 'Starting...' : 'Download Area'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(44), ), @@ -1296,12 +1292,10 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { return; } - final halfLat = (visible.northeast.latitude - visible.southwest.latitude) - .abs() * - 0.2; - final halfLng = (visible.northeast.longitude - visible.southwest.longitude) - .abs() * - 0.2; + final halfLat = + (visible.northeast.latitude - visible.southwest.latitude).abs() * 0.2; + final halfLng = + (visible.northeast.longitude - visible.southwest.longitude).abs() * 0.2; final sw = LatLng( coordinates.latitude - halfLat, @@ -1545,9 +1539,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { DragEventType eventType, ) { final corner = _handleIdToCorner[id]; - if (corner == null || - _boundsSW == null || - _boundsNE == null) { + if (corner == null || _boundsSW == null || _boundsNE == null) { return; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d4581280..f6f78d59 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -221,16 +221,16 @@ class _SettingsScreenState extends State { }, ), SwitchListTile( - secondary: Icon( - prefs.mapTilesEnabled ? Icons.cloud : Icons.cloud_off), + secondary: + Icon(prefs.mapTilesEnabled ? Icons.cloud : Icons.cloud_off), title: const Text('Use Downloaded Tiles Only'), subtitle: Text(prefs.mapTilesEnabled ? 'Online tiles load normally' : 'Only downloaded areas are shown · no network tile requests'), value: !prefs.mapTilesEnabled, onChanged: (value) { - appState.updatePreferences( - prefs.copyWith(mapTilesEnabled: !value)); + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), if (prefs.mapTilesEnabled) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index ec98dc11..ded6d3fa 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -41,7 +41,8 @@ class ApiService { bool _rxAllowed = false; int? _sessionExpiresAt; String? _wireKey; // TX wire-tag secret from /auth (null = un-keyed fallback) - int _pingCounter = 0; // per-session TX counter; resets on fresh /auth, not on heartbeat + int _pingCounter = + 0; // per-session TX counter; resets on fresh /auth, not on heartbeat Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; @@ -442,7 +443,8 @@ class ApiService { final resumeCounter = (data['resume_counter'] as num?)?.toInt() ?? 0; _pingCounter = resumeCounter; if (resumeCounter > 0) { - debugLog('[AUTH] resumed ping counter at $resumeCounter (reused session)'); + debugLog( + '[AUTH] resumed ping counter at $resumeCounter (reused session)'); } if (_wireKey != null) { debugLog('[AUTH] wire-tag key received (len=${_wireKey!.length})'); @@ -916,9 +918,9 @@ class ApiService { required int y, int gsize = 300, }) async { - final url = Uri.parse( - 'https://${zone.toLowerCase()}.meshmapper.net/vector_tile.php' - '?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1'); + final url = + Uri.parse('https://${zone.toLowerCase()}.meshmapper.net/vector_tile.php' + '?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1'); final sw = Stopwatch()..start(); debugLog( '[API] GET /vector_tile.php?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1 (zone ${zone.toLowerCase()})'); diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 70513891..bb32bc37 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -438,8 +438,7 @@ class MobileBluetoothService implements BluetoothService { if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { - debugLog( - '[BLE] Bond error (apple-code 14/15) on attempt $attempt'); + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt'); await removeBond(deviceId); // On iOS, removeBond() is a no-op (not supported by Core Bluetooth). // Don't burn internal retries against stale bond keys — they will all diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart index a657c86b..243b5d64 100644 --- a/lib/services/custom_api_service.dart +++ b/lib/services/custom_api_service.dart @@ -62,7 +62,8 @@ class CustomApiService { return; } if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) { - debugLog('[CUSTOM API] Forward skipped ($source): API key not configured'); + debugLog( + '[CUSTOM API] Forward skipped ($source): API key not configured'); return; } diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 9a593310..9da3926a 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -643,7 +643,8 @@ class MeshCoreConnection { reader.readInt32LE(); // advLon reader.readBytes(3); // reserved reader.readByte(); // manualAddContacts - radioFreqKHz = reader.readUInt32LE(); // radioFreq (kHz on real hardware) + radioFreqKHz = + reader.readUInt32LE(); // radioFreq (kHz on real hardware) radioBwHz = reader.readUInt32LE(); // radioBw (Hz) radioSf = reader.readByte(); // radioSf radioCr = reader.readByte(); // radioCr diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 5ad029ea..b4187e9c 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -507,6 +507,7 @@ class RxObservation { final double lon; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped (the /// user's own carpeater, when present, has been removed from the tail). final List displayHops; @@ -536,6 +537,7 @@ class RxApiEntry { final int header; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. final List displayHops; diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 92226f2f..7c6216f2 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -36,9 +36,8 @@ class TxTracker { onEchoReceived; /// Callback fired when a multi-hop echo is received (for real-time UI updates) - void Function( - String repeaterId, double? snr, int? rssi, List pathHops, bool isNew)? - onMultiHopEchoReceived; + void Function(String repeaterId, double? snr, int? rssi, + List pathHops, bool isNew)? onMultiHopEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -173,7 +172,8 @@ class TxTracker { if (isMultiHop) { pathHex = metadata.lastHopHex!; displayHops = [ - for (var i = 0; i < metadata.pathHashCount; i++) metadata.getHopHex(i)!, + for (var i = 0; i < metadata.pathHashCount; i++) + metadata.getHopHex(i)!, ]; debugLog( '[TX LOG] Multi-hop echo (pathHashCount=${metadata.pathHashCount}): ' @@ -344,8 +344,7 @@ class TxTracker { // Notify appropriate callback final best = targetMap[pathHex]!; if (isMultiHop) { - debugLog( - '[TX LOG] Invoking onMultiHopEchoReceived callback'); + debugLog('[TX LOG] Invoking onMultiHopEchoReceived callback'); onMultiHopEchoReceived?.call( pathHex, best.snr, best.rssi, displayHops, isNewRepeater); } else { diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 84949b05..b5946d21 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -101,7 +101,8 @@ class UnifiedRxHandler { return; } if (result.type == TxEchoResultType.multiHopEcho) { - debugLog('[UNIFIED RX] Packet was multi-hop TX echo, grouped with TX'); + debugLog( + '[UNIFIED RX] Packet was multi-hop TX echo, grouped with TX'); return; } } diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index c5f0e753..de685d10 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -450,8 +450,8 @@ class OfflineMapService extends ChangeNotifier { for (final r in rawRegions) { try { final actual = sizes[r.id]; - parsed.add( - OfflineMapRegion.fromOfflineRegion(r, actualBytes: actual)); + parsed + .add(OfflineMapRegion.fromOfflineRegion(r, actualBytes: actual)); downloadsSum += actual ?? 0; } catch (e) { debugWarn('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); @@ -543,10 +543,10 @@ class OfflineMapService extends ChangeNotifier { /// Web Mercator tile-Y index for a given latitude at zoom level `2^z = n`. static int _mercatorTileY(double latDeg, int n) { final latRad = latDeg * math.pi / 180.0; - final y = (1 - - math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) / - 2 * - n; + final y = + (1 - math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) / + 2 * + n; return y.floor().clamp(0, n - 1); } @@ -568,9 +568,7 @@ class OfflineMapService extends ChangeNotifier { /// subtracting progress. int get _activePendingBytes { final progress = _downloadProgress ?? 0; - return ((1 - progress) * _activeEstBytes) - .clamp(0, _activeEstBytes) - .toInt(); + return ((1 - progress) * _activeEstBytes).clamp(0, _activeEstBytes).toInt(); } // ── Download ── diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 78fead9a..db4c0789 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -13,8 +13,10 @@ class OfflineSession { final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display final String? contactUri; // Signed contact URI for registration during upload - final String? radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") - final String? deviceModel; // Device model/manufacturer captured at record time + final String? + radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") + final String? + deviceModel; // Device model/manufacturer captured at record time final double? powerLevel; // TX power level (watts) captured at record time final String? appVersion; // App version captured at record time final bool uploaded; // Track upload status @@ -364,8 +366,7 @@ class OfflineSessionService { /// Remove the first [processedCount] pings from a session. /// Called after partial upload so retries don't re-send already-processed data. /// Returns the number of remaining pings, or null if session not found. - Future removeProcessedPings( - String filename, int processedCount) async { + Future removeProcessedPings(String filename, int processedCount) async { final index = _sessions.indexWhere((s) => s.filename == filename); if (index == -1) { debugWarn('[OFFLINE] Session not found for ping removal: $filename'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 34c29d33..e4909f97 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -614,7 +614,8 @@ class PingService { // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes // a counter exactly like a privacy ping, so the session ends identically. if ((getPingCounter?.call() ?? 0) >= 2047) { - debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); + debugError( + '[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; onSessionLimitReached?.call(); return false; @@ -722,9 +723,8 @@ class PingService { if (isNew) { txPing.heardRepeaters.add(repeater); } else { - final idx = txPing.heardRepeaters - .indexWhere((r) => r.repeaterId == repeaterId && - r.pathHops != null); + final idx = txPing.heardRepeaters.indexWhere( + (r) => r.repeaterId == repeaterId && r.pathHops != null); if (idx >= 0) { txPing.heardRepeaters[idx] = repeater; } @@ -846,8 +846,12 @@ class PingService { } // Collect multi-hop echo data for the onTxWindowComplete callback - final multiHopEchoes = - <({String repeaterId, double? snr, int? rssi, List pathHops})>[]; + final multiHopEchoes = <({ + String repeaterId, + double? snr, + int? rssi, + List pathHops + })>[]; if (txTracker != null && txTracker.multiHopRepeaters.isNotEmpty) { for (final entry in txTracker.multiHopRepeaters.entries) { final echo = entry.value; @@ -874,7 +878,8 @@ class PingService { externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, power: getPowerLevel?.call(), - pingCounter: _pendingTxPingCounter, // null in coords mode → server coords path + pingCounter: + _pendingTxPingCounter, // null in coords mode → server coords path wireTag: _pendingTxWireTag, // null in coords mode → server coords path ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); diff --git a/lib/services/transport/android_serial_service.dart b/lib/services/transport/android_serial_service.dart index f3a37a05..ec9af89d 100644 --- a/lib/services/transport/android_serial_service.dart +++ b/lib/services/transport/android_serial_service.dart @@ -14,8 +14,7 @@ class AndroidSerialService extends StreamTransportBase { final String deviceName; final String productName; - static const _methodChannel = - MethodChannel('net.meshmapper.app/usb_serial'); + static const _methodChannel = MethodChannel('net.meshmapper.app/usb_serial'); static const _eventChannel = EventChannel('net.meshmapper.app/usb_serial/data'); @@ -55,7 +54,8 @@ class AndroidSerialService extends StreamTransportBase { 'baudRate': 115200, }); if (connectResult != null) { - debugLog('[CONN] USB device info: controlFound=${connectResult['controlFound']}, ' + debugLog( + '[CONN] USB device info: controlFound=${connectResult['controlFound']}, ' 'interfaces=${connectResult['interfaceCount']}, ' 'dataClass=0x${(connectResult['dataClass'] as int?)?.toRadixString(16) ?? '?'}, ' 'inPacket=${connectResult['inMaxPacket']}, outPacket=${connectResult['outMaxPacket']}'); @@ -84,8 +84,7 @@ class AndroidSerialService extends StreamTransportBase { @override Future closeConnection() async { try { - final diag = - await _methodChannel.invokeMethod('readDiagnostics'); + final diag = await _methodChannel.invokeMethod('readDiagnostics'); if (diag != null) { debugLog('[CONN] USB read loop: ' 'reads=${diag['readAttempts']}, ' @@ -121,11 +120,8 @@ class AndroidSerialService extends StreamTransportBase { /// List available USB serial devices via native Android USB API. static Future>> getAvailablePorts() async { - final result = - await _methodChannel.invokeMethod('listDevices'); + final result = await _methodChannel.invokeMethod('listDevices'); if (result == null) return []; - return result - .map((e) => Map.from(e as Map)) - .toList(); + return result.map((e) => Map.from(e as Map)).toList(); } } diff --git a/lib/services/transport/stream_frame_codec.dart b/lib/services/transport/stream_frame_codec.dart index 8532d513..def7f7d5 100644 --- a/lib/services/transport/stream_frame_codec.dart +++ b/lib/services/transport/stream_frame_codec.dart @@ -95,8 +95,8 @@ class StreamFrameCodec { if (bytes.length < totalFrameSize) return; // Extract the payload (strip header) - final payload = Uint8List.fromList( - bytes.sublist(headerSize, totalFrameSize)); + final payload = + Uint8List.fromList(bytes.sublist(headerSize, totalFrameSize)); // Remove consumed bytes, keep remainder if (bytes.length > totalFrameSize) { diff --git a/lib/services/transport/stream_transport_base.dart b/lib/services/transport/stream_transport_base.dart index 88cbaacb..22ba548e 100644 --- a/lib/services/transport/stream_transport_base.dart +++ b/lib/services/transport/stream_transport_base.dart @@ -32,8 +32,7 @@ abstract class StreamTransportBase implements CompanionTransport { Stream get dataStream => _dataController.stream; @override - Stream get connectionStream => - _connectionController.stream; + Stream get connectionStream => _connectionController.stream; @override ConnectionStatus get connectionStatus => _status; diff --git a/lib/services/transport/tcp_service.dart b/lib/services/transport/tcp_service.dart index 16f71059..2c443d3f 100644 --- a/lib/services/transport/tcp_service.dart +++ b/lib/services/transport/tcp_service.dart @@ -134,8 +134,7 @@ class TcpService extends StreamTransportBase { } } - static Future saveConnection( - String host, int port, String name) async { + static Future saveConnection(String host, int port, String name) async { final prefs = await SharedPreferences.getInstance(); final connections = await getSavedConnections(); @@ -151,8 +150,7 @@ class TcpService extends StreamTransportBase { lastConnected: DateTime.now(), )); - final jsonString = - jsonEncode(connections.map((c) => c.toJson()).toList()); + final jsonString = jsonEncode(connections.map((c) => c.toJson()).toList()); await prefs.setString(_prefsKey, jsonString); } @@ -161,8 +159,7 @@ class TcpService extends StreamTransportBase { final connections = await getSavedConnections(); connections.removeWhere((c) => c.id == id); - final jsonString = - jsonEncode(connections.map((c) => c.toJson()).toList()); + final jsonString = jsonEncode(connections.map((c) => c.toJson()).toList()); await prefs.setString(_prefsKey, jsonString); } } diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index a3160420..c186f9e2 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -15,14 +15,15 @@ import '../models/repeater.dart'; /// Matches `hex(?)(snr)[lat,lon]` repeater path tokens. Groups: 1=hex, 2=`?`, /// 3=snr, 4=`lat,lon`. Mirrors the web regex used in both functions. -final RegExp _tokenRe = - RegExp(r'([a-f0-9]+)(\?)?(?:\((.*?)\))?(?:\[(.*?)\])?', caseSensitive: false); +final RegExp _tokenRe = RegExp(r'([a-f0-9]+)(\?)?(?:\((.*?)\))?(?:\[(.*?)\])?', + caseSensitive: false); final RegExp _hexOnlyRe = RegExp(r'[^a-fA-F0-9]'); final RegExp _snrParenRe = RegExp(r'\(([\d.-]+)\)'); final RegExp _coordBracketRe = RegExp(r'\[([\d.-]+),([\d.-]+)\]'); double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { - const r = 6371000.0; // same earth radius as Leaflet map.distance + coordsWithin100m + const r = + 6371000.0; // same earth radius as Leaflet map.distance + coordsWithin100m final dLat = (lat2 - lat1) * math.pi / 180; final dLon = (lon2 - lon1) * math.pi / 180; final a = math.sin(dLat / 2) * math.sin(dLat / 2) + @@ -202,7 +203,8 @@ class _RepInfo { final double lon; final int status; // enabled: 1 active, 2 ambiguous/excluded final bool hidden; - const _RepInfo(this.id, this.hexId, this.lat, this.lon, this.status, this.hidden); + const _RepInfo( + this.id, this.hexId, this.lat, this.lon, this.status, this.hidden); } /// Indexes the loaded repeaters the same three ways the web does: by id @@ -236,8 +238,9 @@ class RepeaterLookup { final hidden = r.name.startsWith('🚫') || r.name.endsWith('🚫'); final info = _RepInfo(id, cleanHex, r.lat, r.lon, r.enabled, hidden); lk._byId[id] = info; - final shortHex = - cleanHex.length >= prefixLen ? cleanHex.substring(0, prefixLen) : cleanHex; + final shortHex = cleanHex.length >= prefixLen + ? cleanHex.substring(0, prefixLen) + : cleanHex; if (shortHex.length >= 2) { (lk._byShortHex[shortHex] ??= <_RepInfo>[]).add(info); final firstByte = cleanHex.length >= 2 ? cleanHex.substring(0, 2) : ''; @@ -283,7 +286,8 @@ class RepeaterLookup { final full = _byFullHex[rid]; if (full != null) return <_RepInfo>[full]; final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; - final cands = _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + final cands = + _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); if (cands.isEmpty) { final loc = _byId[rid]; if (loc != null) return <_RepInfo>[loc]; @@ -299,7 +303,8 @@ class RepeaterLookup { final full = _byFullHex[rid]; if (full != null) return full; final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; - final cands = _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + final cands = + _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); if (cands.length == 1) return cands.first; return _byId[rid]; } @@ -441,8 +446,9 @@ class GridSummary { // DISC full-id check via public_key. if (s == 6 && p['public_key'] is String) { - final pkClean = - (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final pkClean = (p['public_key'] as String) + .replaceAll(_hexOnlyRe, '') + .toLowerCase(); final targetRep = lookup._byFullHex[pkClean]; if (targetRep != null) { final hrStr = hr is String ? hr : ''; @@ -508,10 +514,8 @@ class RepeaterStats { int get totalMatched => bidir + tx + rx + disc + dead; - factory RepeaterStats.fromCoverage( - List> points, - Repeater target, - RepeaterLookup lookup, + factory RepeaterStats.fromCoverage(List> points, + Repeater target, RepeaterLookup lookup, {bool disableDupLogic = false}) => fromCoverageWithPoints(points, target, lookup, disableDupLogic: disableDupLogic) @@ -522,12 +526,12 @@ class RepeaterStats { /// `_matchedPoints`), so callers can draw the repeater's coverage cells + /// connection lines. [fromCoverage] delegates here, so there is ONE source of /// matching truth. - static ({RepeaterStats stats, List> matched}) - fromCoverageWithPoints( - List> points, - Repeater target, - RepeaterLookup lookup, - {bool disableDupLogic = false}) { + static ({ + RepeaterStats stats, + List> matched + }) fromCoverageWithPoints( + List> points, Repeater target, RepeaterLookup lookup, + {bool disableDupLogic = false}) { final matchedPoints = >[]; final repId = target.id.toLowerCase(); final repLat = target.lat; @@ -598,8 +602,9 @@ class RepeaterStats { } if (status == 6 && p['public_key'] is String) { - final pk = - (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final pk = (p['public_key'] as String) + .replaceAll(_hexOnlyRe, '') + .toLowerCase(); final tr = lookup._byFullHex[pk]; if (tr != null && tr.id == repId) { final hrStr = hr is String ? hr : ''; @@ -759,7 +764,8 @@ List heardEndpointsForCell( void addEndpoint(_RepInfo rep, double? snr) { if (rep.hidden) return; // Self-line guard (web drawLine skips start==end within 1e-6). - if ((startLat - rep.lat).abs() < 1e-6 && (startLon - rep.lon).abs() < 1e-6) { + if ((startLat - rep.lat).abs() < 1e-6 && + (startLon - rep.lon).abs() < 1e-6) { return; } final key = '${rep.lat.toStringAsFixed(6)},${rep.lon.toStringAsFixed(6)}'; @@ -878,7 +884,8 @@ List heardEndpointsForCell( .toList(); } } else { - candidates = const <_RepInfo>[]; // no coords -> drop (default dup logic) + candidates = + const <_RepInfo>[]; // no coords -> drop (default dup logic) } if ((dbm == null || dbm.isEmpty) && (status == 6 || status == 7)) { dbm = (_toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])) diff --git a/lib/utils/geo_validation.dart b/lib/utils/geo_validation.dart index 61514816..d22a59ba 100644 --- a/lib/utils/geo_validation.dart +++ b/lib/utils/geo_validation.dart @@ -28,7 +28,8 @@ bool isDegenerateBounds( double maxLon, { double epsilonDeg = 1e-6, }) => - (maxLat - minLat).abs() < epsilonDeg && (maxLon - minLon).abs() < epsilonDeg; + (maxLat - minLat).abs() < epsilonDeg && + (maxLon - minLon).abs() < epsilonDeg; /// Clamp fit-bounds edge padding so it can never meet or exceed the map's /// rendered size. diff --git a/lib/utils/repeater_format.dart b/lib/utils/repeater_format.dart index 692fe651..2d6c9d5c 100644 --- a/lib/utils/repeater_format.dart +++ b/lib/utils/repeater_format.dart @@ -9,7 +9,9 @@ DateTime? _toDateTime(num? ts) { if (ts == null) return null; var v = ts.toDouble(); if (v <= 0) return null; - if (v < 20000000000) v = v * 1000; // seconds -> ms (web: `if (ts < 20000000000) ts *= 1000`) + if (v < 20000000000) { + v = v * 1000; // seconds -> ms (web: `if (ts < 20000000000) ts *= 1000`) + } return DateTime.fromMillisecondsSinceEpoch(v.toInt()); } diff --git a/lib/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart index 4710fcf2..a2148e49 100644 --- a/lib/widgets/cell_summary_sheet.dart +++ b/lib/widgets/cell_summary_sheet.dart @@ -87,7 +87,9 @@ class CellSummarySheet extends StatelessWidget { ); } final s = snap.data; - if (s == null) return _note(context, 'Could not load coverage data.'); + if (s == null) { + return _note(context, 'Could not load coverage data.'); + } if (s.total == 0) return _note(context, 'No coverage data here.'); return _buildSummary(context, s); }, @@ -146,7 +148,8 @@ class CellSummarySheet extends StatelessWidget { _cell( context, value: s.maxDistMeters != null - ? formatCoverageDistance(s.maxDistMeters!, isImperial: isImperial) + ? formatCoverageDistance(s.maxDistMeters!, + isImperial: isImperial) : 'N/A', label: 'MAX DIST', color: _maxDistBlue, @@ -249,7 +252,8 @@ class CellSummarySheet extends StatelessWidget { child: Icon(icon, size: 22, color: color), ); } - return _cell(context, value: '', label: 'AVG SNR', valueWidget: valueWidget); + return _cell(context, + value: '', label: 'AVG SNR', valueWidget: valueWidget); } Widget _barGraph(BuildContext context, GridSummary s) { @@ -308,7 +312,8 @@ class CellSummarySheet extends StatelessWidget { child: Center( child: Text( msg, - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d0328f10..40e14933 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1068,9 +1068,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { !_canAnimateCamera) { return; } - final valid = points - .where((p) => isValidLatLng(p.latitude, p.longitude)) - .toList(); + final valid = + points.where((p) => isValidLatLng(p.latitude, p.longitude)).toList(); if (valid.length < 2) return; double minLat = valid[0].latitude, maxLat = valid[0].latitude; @@ -1344,7 +1343,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _mapController! .getMetersPerPixelAtLatitude(position.latitude) .then((mapLibreMpx) { - debugLog('[MAP CENTER] m/px sanity: formula=${metersPerPixel.toStringAsFixed(4)} ' + debugLog( + '[MAP CENTER] m/px sanity: formula=${metersPerPixel.toStringAsFixed(4)} ' 'maplibre=${mapLibreMpx.toStringAsFixed(4)} ' 'ratio=${(metersPerPixel / mapLibreMpx).toStringAsFixed(3)} ' '(zoom=${zoom.toStringAsFixed(2)} lat=${position.latitude.toStringAsFixed(4)})'); @@ -1444,8 +1444,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0 - _zoomEpsilon); - _animateToPositionWithZoom( - adjustedPosition, 16.0 - _zoomEpsilon); + _animateToPositionWithZoom(adjustedPosition, 16.0 - _zoomEpsilon); debugLog( '[MAP] Initial zoom to GPS position (with panel offset)'); } else { @@ -1914,8 +1913,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// maplibre_gl 0.25.0 has no `setOffline` implementation, so we ship our /// own: a URLProtocol that fails tile requests fast while offline mode is /// engaged, letting MapLibre-iOS render only its cached tiles. - static const _iosOfflineChannel = - MethodChannel('meshmapper/ios_map_offline'); + static const _iosOfflineChannel = MethodChannel('meshmapper/ios_map_offline'); /// Toggle MapLibre between online (network tiles) and offline (cache-only). /// Android uses the plugin's native `setOffline`; iOS uses our bridge. @@ -2340,9 +2338,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // hide the repeaters that didn't, and label each line with its distance. // Lines are theme-aware blue (web keys off the basemap; we key off the // Flutter theme). Read brightness before the async gap. - final fanColor = Theme.of(context).brightness == Brightness.dark - ? '#4da6ff' - : '#00008b'; + final fanColor = + Theme.of(context).brightness == Brightness.dark ? '#4da6ff' : '#00008b'; blobPointsFuture.then((pts) { if (!mounted || !_cellPopupActive) return; final eps = _capByFarthest( @@ -3027,7 +3024,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (_consecutiveTileLoadFailures > 0 && mounted) { if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { - debugLog('[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); + debugLog( + '[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); } _consecutiveTileLoadFailures = 0; setState(() {}); @@ -3357,8 +3355,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Remove a specific coverage source+layer pair without touching the /// active-ID tracking. - Future _removeCoverageLayerById( - String layerId, String sourceId) async { + Future _removeCoverageLayerById(String layerId, String sourceId) async { if (_mapController == null) return; try { await _mapController!.removeLayer(layerId); @@ -3383,9 +3380,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// GeoJSON FeatureCollection of the session patch: one rectangle per cell, /// corners computed from grid indices exactly like the server's tiles. Map _buildPatchGeoJson(AppStateProvider appState) { - final steps = - kCoverageGridSteps[appState.preferences.coverageGridSize] ?? - kCoverageGridSteps[300]!; + final steps = kCoverageGridSteps[appState.preferences.coverageGridSize] ?? + kCoverageGridSteps[300]!; final features = >[]; for (final cell in appState.coveragePatchCells.values) { final lat0 = cell.i * steps[0]; @@ -3435,9 +3431,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { 'in', [ 'concat', - ['to-string', ['get', 'i']], + [ + 'to-string', + ['get', 'i'] + ], '_', - ['to-string', ['get', 'j']], + [ + 'to-string', + ['get', 'j'] + ], ], ['literal', keys], ], @@ -4080,8 +4082,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugError('[MAP] Failed to update repeater source: $e'); } // Also push the spider source. Empty FeatureCollection if no spider open. - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _syncSpiderSymbols(appState, currentZoom); @@ -4095,9 +4096,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Web-Mercator metres-per-pixel at the given latitude and zoom. double _metersPerPxAtZoom(double latDeg, num zoom) { - return 156543.03392 * - math.cos(latDeg * math.pi / 180) / - math.pow(2, zoom); + return 156543.03392 * math.cos(latDeg * math.pi / 180) / math.pow(2, zoom); } /// Great-circle distance between two LatLngs in metres (haversine). @@ -4114,9 +4113,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { math.cos(lat2) * math.sin(dLon / 2) * math.sin(dLon / 2); - return 2 * - earthRadiusM * - math.asin(math.min(1.0, math.sqrt(h))); + return 2 * earthRadiusM * math.asin(math.min(1.0, math.sqrt(h))); } /// MapLibre Native (Android SDK 12.3.1 / iOS 6.19.1, both bound by @@ -4163,8 +4160,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// any repeater is within the broad search disc; caller should treat a /// result of length < 2 as "no spiderfy needed". List _findSpiderGroup(LatLng anchor, AppStateProvider appState) { - final mPerPxMaxZoom = - _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final mPerPxMaxZoom = _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; // Broad initial radius: 10× the stick threshold so we don't miss an @@ -4200,8 +4196,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final curPos = LatLng(cur.lat, cur.lon); for (final r in candidates) { if (visited.contains(r.id)) continue; - if (_haversineMeters(curPos, LatLng(r.lat, r.lon)) <= - stickThresholdM) { + if (_haversineMeters(curPos, LatLng(r.lat, r.lon)) <= stickThresholdM) { visited.add(r.id); result.add(r); queue.add(r); @@ -4227,8 +4222,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // cluster diameter at maxZoom is ~2× the cluster radius (centroid // drift); pointCount × stickThreshold is a comfortable upper bound for // any plausible cluster size. - final mPerPxMaxZoom = - _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final mPerPxMaxZoom = _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; final broadRadiusM = stickThresholdM * math.max(10, pointCount); final candidates = >[]; @@ -4245,8 +4239,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Layout the [n] spread positions around [center]. Uses a single ring up to /// 8 markers, two concentric rings up to 20, and a Fermat / golden-angle /// spiral past 20. - List _computeSpiderRing( - LatLng center, int n, double currentZoom) { + List _computeSpiderRing(LatLng center, int n, double currentZoom) { final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); final lat0 = center.latitude; final lon0 = center.longitude; @@ -4286,9 +4279,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { )); } for (var i = 0; i < outerCount; i++) { - final angle = -math.pi / 2 + - math.pi / outerCount + - 2 * math.pi * i / outerCount; + final angle = + -math.pi / 2 + math.pi / outerCount + 2 * math.pi * i / outerCount; positions.add(offset( _spiderOuterRadiusPx * math.cos(angle), _spiderOuterRadiusPx * math.sin(angle), @@ -4374,8 +4366,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Leader line — shortened by `_leaderLineEndShortenPx` at the marker // end so it doesn't punch through the icon / label halo. Compute the // shortening in screen-pixel space, then convert back to lat/lon. - final dxM = - (pos.longitude - center.longitude) * 111320 * cosLat; + final dxM = (pos.longitude - center.longitude) * 111320 * cosLat; final dyM = (pos.latitude - center.latitude) * 111320; final dxPx = dxM / mPerPx; final dyPx = dyM / mPerPx; @@ -4384,8 +4375,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final scale = (lenPx - _leaderLineEndShortenPx) / lenPx; final endLon = center.longitude + (pos.longitude - center.longitude) * scale; - final endLat = - center.latitude + (pos.latitude - center.latitude) * scale; + final endLat = center.latitude + (pos.latitude - center.latitude) * scale; features.add({ 'type': 'Feature', 'properties': {'repeaterId': repeater.id}, @@ -4408,8 +4398,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { AppStateProvider appState, double currentZoom) async { if (_mapController == null || !_clusterLayersReady) return; try { - final geojson = - _buildSpiderFeatureCollection(appState, currentZoom); + final geojson = _buildSpiderFeatureCollection(appState, currentZoom); // Detailed mode references baked per-chip images. The main collection // usually registers them first, but a spidered repeater that the main // builder filtered out (focus / isolation / heard-repeater fade) would @@ -5147,8 +5136,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Draw the selected repeater's coverage [cells] as status-coloured fills /// (Feature B). [latStep]/[lonStep] size each cell's ring. Empty list clears. - Future _updateCoverageCells(List cells, String cvd, - double latStep, double lonStep) async { + Future _updateCoverageCells(List cells, + String cvd, double latStep, double lonStep) async { if (_mapController == null || !_styleLoaded) return; if (cells.isEmpty) { await _clearCoverageCells(); @@ -5344,7 +5333,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ]; // Skinnier than the tile fan-out — a repeater draws many lines at once. - await _updateCoverageLines(segments, repeater.lat, repeater.lon, width: 1.5); + await _updateCoverageLines(segments, repeater.lat, repeater.lon, + width: 1.5); // Dim the base coverage tiles so the repeater's coloured cells + lines pop // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in // _clearRepeaterIsolation. Skip while ping focus already hid the overlay. @@ -5555,8 +5545,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugError('[MAP] render/addImage(distance label) failed: $e'); } } - imageSize ??= _registeredDistanceLabelImageSizes[imageName] ?? - const Size(60, 18); + imageSize ??= + _registeredDistanceLabelImageSizes[imageName] ?? const Size(60, 18); _distanceLabelImageSize[key] = imageSize; _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); @@ -6051,8 +6041,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _buildControlButton( icon: _autoFollow ? Icons.my_location : Icons.location_searching, tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: - appState.currentPosition != null ? _centerOnPosition : null, + onPressed: appState.currentPosition != null ? _centerOnPosition : null, isActive: _autoFollow, ), // Always North toggle @@ -7137,157 +7126,159 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final iconReserve = lacksLocation ? 18.0 : 0.0; final nodeColWidth = chipWidth + iconReserve; return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: nodeColWidth, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - ], + ], + ), ), - ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data row - Builder(builder: (context) { - final localSnr = entry.localSnr ?? 0; - final localRssi = entry.localRssi ?? 0; - final remoteSnr = entry.remoteSnr ?? 0; - - final rxSnrColor = PingColors.snrColor(localSnr); - final rssiColor = PingColors.rssiColor(localRssi); - final txSnrColor = - PingColors.snrColor(remoteSnr.toDouble()); - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup( - context, entry.targetRepeaterId, fromLatLng: ( - lat: entry.latitude, - lon: entry.longitude - )), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - child: Row( - children: [ - // Repeater ID + optional no-location icon, - // pinned to the node column width so the - // SNR/RSSI/TX columns stay aligned. - SizedBox( - width: nodeColWidth, - child: Row( - children: [ - RepeaterIdChip( - repeaterId: entry.targetRepeaterId, - fontSize: 13, - width: chipWidth), - if (lacksLocation) - Padding( - padding: const EdgeInsets.only( - left: 4), - child: _noLocationIndicator(), - ), - ], + Divider( + height: 1, color: Theme.of(context).dividerColor), + // Data row + Builder(builder: (context) { + final localSnr = entry.localSnr ?? 0; + final localRssi = entry.localRssi ?? 0; + final remoteSnr = entry.remoteSnr ?? 0; + + final rxSnrColor = PingColors.snrColor(localSnr); + final rssiColor = PingColors.rssiColor(localRssi); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + optional no-location icon, + // pinned to the node column width so the + // SNR/RSSI/TX columns stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: + entry.targetRepeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), ), - ), - // RX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: localSnr.toStringAsFixed(1), - color: rxSnrColor, + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), ), ), - ), - // RX RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: '$localRssi', - color: rssiColor, + // RX RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '$localRssi', + color: rssiColor, + ), ), ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: remoteSnr.toStringAsFixed(1), - color: txSnrColor, + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), ), ), - ), - ], + ], + ), ), - ), - ); - }), - ], - ), - ); + ); + }), + ], + ), + ); }), ], ], @@ -7648,8 +7639,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Row-2 stats for the cell pill: Max Dist · Avg SNR · Avg Noise · Total Pings. - Widget _cellPillStats( - BuildContext context, Future summaryFuture, bool isImperial) { + Widget _cellPillStats(BuildContext context, + Future summaryFuture, bool isImperial) { return FutureBuilder( future: summaryFuture, builder: (context, snap) { @@ -7658,10 +7649,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final dist = loading ? '…' : (s?.maxDistMeters != null - ? formatCoverageDistance(s!.maxDistMeters!, isImperial: isImperial) + ? formatCoverageDistance(s!.maxDistMeters!, + isImperial: isImperial) : 'N/A'); - final noise = - loading ? '…' : (s?.avgNoise != null ? '${s!.avgNoise} dBm' : 'N/A'); + final noise = loading + ? '…' + : (s?.avgNoise != null ? '${s!.avgNoise} dBm' : 'N/A'); final total = loading ? '…' : '${s?.total ?? 0}'; return Wrap( spacing: 14, @@ -7731,14 +7724,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Row-2 stats for the repeater pill: Max Range · Hop Bytes · Clock Sync · /// Last Heard. (Online/offline status is the coloured tower in the title.) /// Max Range comes from the lazy [statsFuture]. - Widget _repeaterPillStats( - BuildContext context, - Repeater repeater, - Future statsFuture, - String? clockSkew, - bool isImperial) { + Widget _repeaterPillStats(BuildContext context, Repeater repeater, + Future statsFuture, String? clockSkew, bool isImperial) { final clockOk = clockSkew == null; - final lastHeard = repeater.lastHeard > 0 ? daysAgo(repeater.lastHeard) : 'N/A'; + final lastHeard = + repeater.lastHeard > 0 ? daysAgo(repeater.lastHeard) : 'N/A'; return FutureBuilder( future: statsFuture, builder: (context, snap) { @@ -7820,8 +7810,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } final theme = Theme.of(context); - final timeStr = - _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; + final timeStr = _focusedPingTimestamp != null + ? _formatTime(_focusedPingTimestamp!) + : ''; return GestureDetector( // Swallow body taps (no accidental expand / no fall-through to the map). @@ -7947,8 +7938,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { iconColor: PingColors.txSuccess)); if (source.localSnr != null) chips.add(snrChip(source.localSnr!)); if (source.remoteSnr != null) { - chips.add(_pillStat( - Icons.arrow_upward, 'TX ${source.remoteSnr!.toStringAsFixed(1)}')); + chips.add(_pillStat(Icons.arrow_upward, + 'TX ${source.remoteSnr!.toStringAsFixed(1)}')); } if (source.localRssi != null) chips.add(rssiChip(source.localRssi!)); } @@ -8092,8 +8083,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: - Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), ), ), child: ConstrainedBox( @@ -8292,8 +8282,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { const SizedBox(height: 16), // Split repeaters into direct and multi-hop - ..._buildTxRepeaterSections(context, ping, heardRepeaters, - resolved, hasAmbiguous), + ..._buildTxRepeaterSections( + context, ping, heardRepeaters, resolved, hasAmbiguous), ], ), ), @@ -8402,8 +8392,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], )); widgets.add(const SizedBox(height: 12)); - widgets.add(_buildMultiHopRepeaterTable( - context, ping, multiHopRepeaters)); + widgets + .add(_buildMultiHopRepeaterTable(context, ping, multiHopRepeaters)); } return widgets; @@ -8422,16 +8412,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -8440,9 +8427,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant)), + color: + Theme.of(context).colorScheme.onSurfaceVariant)), ), Expanded( child: Text('SNR', @@ -8480,8 +8466,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ SizedBox( @@ -8511,9 +8497,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Expanded( child: Center( child: _buildStatChip( - value: repeater.rssi != null - ? '${repeater.rssi}' - : '-', + value: + repeater.rssi != null ? '${repeater.rssi}' : '-', color: rssiColor, ), ), @@ -8541,17 +8526,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -8560,9 +8542,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant)), + color: + Theme.of(context).colorScheme.onSurfaceVariant)), ), Expanded( child: Text('SNR', @@ -8603,8 +8584,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ SizedBox( @@ -8626,8 +8607,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Expanded( child: Center( child: _buildStatChip( - value: - repeater.snr?.toStringAsFixed(1) ?? '-', + value: repeater.snr?.toStringAsFixed(1) ?? '-', color: snrColor, ), ), @@ -8648,8 +8628,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), if (repeater.pathHops != null && repeater.pathHops!.isNotEmpty) Padding( - padding: const EdgeInsets.only( - left: 12, right: 12, bottom: 8), + padding: + const EdgeInsets.only(left: 12, right: 12, bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -8726,8 +8706,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { decoration: BoxDecoration( color: PingColors.rx.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all( - color: PingColors.rx.withValues(alpha: 0.4)), + border: + Border.all(color: PingColors.rx.withValues(alpha: 0.4)), ), child: Icon(Icons.arrow_downward, color: PingColors.rx, size: 24), @@ -8825,119 +8805,121 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final iconReserve = lacksLocation ? 18.0 : 0.0; final nodeColWidth = chipWidth + iconReserve; return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: nodeColWidth, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup( - context, ping.repeaterId, - fromLatLng: (lat: ping.latitude, lon: ping.longitude)), - child: Padding( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), + horizontal: 12, vertical: 10), child: Row( children: [ - // Repeater ID + optional no-location icon, pinned - // to the node column width so SNR/RSSI stay aligned. SizedBox( width: nodeColWidth, - child: Row( - children: [ - RepeaterIdChip( - repeaterId: ping.repeaterId, - fontSize: 13, - width: chipWidth), - if (lacksLocation) - Padding( - padding: const EdgeInsets.only(left: 4), - child: _noLocationIndicator(), - ), - ], + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - // SNR Expanded( - child: Center( - child: _buildStatChip( - value: ping.snr.toStringAsFixed(1), - color: snrColor, + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${ping.rssi}', - color: rssiColor, + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - ), - ], - ), - ); + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + optional no-location icon, pinned + // to the node column width so SNR/RSSI stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), + // SNR + Expanded( + child: Center( + child: _buildStatChip( + value: ping.snr.toStringAsFixed(1), + color: snrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${ping.rssi}', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); }), // Path section (origin → ... → us). Skipped when the path is @@ -8955,8 +8937,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), const SizedBox(height: 12), Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -8996,9 +8978,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final resolved = entry.discoveredNodes.isNotEmpty ? _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), - fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: - entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + fullHexIds: + entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: entry.discoveredNodes + .map((n) => n.localSnr as double?) + .toList(), ) : const <_ResolvedRepeater>[]; _activatePingFocus( @@ -9149,166 +9133,169 @@ class _MapWidgetState extends State with WidgetsBindingObserver { .any((n) => _hexIdLacksLocation(n.repeaterId)); final nodeExtra = 20.0 + (anyLacksLocation ? 18.0 : 0.0); return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: - _nodeColumnWidth(extraPadding: nodeExtra), - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: + _nodeColumnWidth(extraPadding: nodeExtra), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - ], + ], + ), ), - ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) { - final rxSnrColor = PingColors.snrColor(node.localSnr); - final rssiColor = - PingColors.rssiColor(node.localRssi); - final txSnrColor = - PingColors.snrColor(node.remoteSnr.toDouble()); - final lacksLocation = - _hexIdLacksLocation(node.repeaterId); - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup( - context, node.repeaterId, - fullHexId: node.pubkeyHex, - fromLatLng: ( - lat: entry.latitude, - lon: entry.longitude - )), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - child: Row( - children: [ - // Node ID with type (+ optional no-loc icon) - SizedBox( - width: - _nodeColumnWidth(extraPadding: nodeExtra), - child: Row( - children: [ - RepeaterIdChip( - repeaterId: node.repeaterId, - fontSize: 13), - Text( - node.nodeTypeLabel, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _discMarkerColor, - ), - ), - if (lacksLocation) - Padding( - padding: const EdgeInsets.only( - left: 4), - child: _noLocationIndicator(), + Divider( + height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.discoveredNodes.map((node) { + final rxSnrColor = + PingColors.snrColor(node.localSnr); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); + final lacksLocation = + _hexIdLacksLocation(node.repeaterId); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Node ID with type (+ optional no-loc icon) + SizedBox( + width: _nodeColumnWidth( + extraPadding: nodeExtra), + child: Row( + children: [ + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), + Text( + node.nodeTypeLabel, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _discMarkerColor, + ), ), - ], + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), ), - ), - // RX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: node.localSnr.toStringAsFixed(1), - color: rxSnrColor, + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: + node.localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), ), ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: '${node.localRssi}', - color: rssiColor, + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${node.localRssi}', + color: rssiColor, + ), ), ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: - node.remoteSnr.toStringAsFixed(1), - color: txSnrColor, + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: + node.remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), ), ), - ), - ], + ], + ), ), - ), - ); - }), - ], - ), - ); + ); + }), + ], + ), + ); }), ], ], @@ -9504,210 +9491,210 @@ class _MapWidgetState extends State with WidgetsBindingObserver { children: [ // Header with icon badge (containing ID) and name Row( - children: [ - // Icon badge with hex ID (mirrors map marker) - Builder(builder: (context) { - final displayId = repeater.displayHexId( - overrideHopBytes: regionHopBytesOverride); - final isLongId = displayId.length > 2; - return Container( - constraints: const BoxConstraints(minWidth: 44), - height: 44, - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 8) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: iconColor, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: isLongId ? 13 : 16, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), - ); - }), - const SizedBox(width: 12), - Expanded( - child: Text( - repeater.name, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 16), - - // Status chips row - Row( - children: [ - if (isDuplicate) ...[ - _buildRepeaterStatusChip( - 'Duplicate', _repeaterDuplicateColor), - const SizedBox(width: 8), - ], - _buildRepeaterStatusChip(statusLabel, statusColor), - ], - ), - const SizedBox(height: 16), - - // Details card - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( children: [ - // Location - _repRow( - context, - Icons.location_on, - Text( - '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', - style: const TextStyle( - fontSize: 13, fontFamily: 'monospace'), - ), - ), - // Fingerprint (id / hex) - _repRow( - context, - Icons.fingerprint, - Text.rich(TextSpan(children: [ - TextSpan( - text: fingerprintShort, - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - fontWeight: FontWeight.w600), + // Icon badge with hex ID (mirrors map marker) + Builder(builder: (context) { + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); + final isLongId = displayId.length > 2; + return Container( + constraints: const BoxConstraints(minWidth: 44), + height: 44, + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: iconColor, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white, width: 2), ), - TextSpan( - text: ' ($fingerprintFull)', + alignment: Alignment.center, + child: Text( + displayId, style: TextStyle( - fontSize: 12, - color: - Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: isLongId ? 13 : 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', ), ), - ])), - ), - // Hop bytes - _repRow( - context, - Icons.swap_horiz, - Text( - 'Hop Bytes: ${repeater.hopBytes} byte${repeater.hopBytes == 1 ? '' : 's'}', - style: const TextStyle(fontSize: 13), + ); + }), + const SizedBox(width: 12), + Expanded( + child: Text( + repeater.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), - // Last heard (schedule) - if (repeater.lastHeard > 0) + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 16), + + // Status chips row + Row( + children: [ + if (isDuplicate) ...[ + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), + const SizedBox(width: 8), + ], + _buildRepeaterStatusChip(statusLabel, statusColor), + ], + ), + const SizedBox(height: 16), + + // Details card + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Location _repRow( context, - Icons.schedule, - Text(formatDateWithAgo(repeater.lastHeard), - style: const TextStyle(fontSize: 13)), + Icons.location_on, + Text( + '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', + style: const TextStyle( + fontSize: 13, fontFamily: 'monospace'), + ), ), - // Clock-skew warning — single compact row, e.g. "Clock is - // 1.2 days ahead" (was two rows / a long sentence). - if (clockSkew != null) + // Fingerprint (id / hex) _repRow( context, - Icons.warning_amber_rounded, - Text('Clock is $clockSkew', - style: const TextStyle(fontSize: 13)), - color: Colors.red.shade400, + Icons.fingerprint, + Text.rich(TextSpan(children: [ + TextSpan( + text: fingerprintShort, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w600), + ), + TextSpan( + text: ' ($fingerprintFull)', + style: TextStyle( + fontSize: 12, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ])), ), - // First heard - if (repeater.createdAt != null) + // Hop bytes _repRow( context, - Icons.event, + Icons.swap_horiz, Text( - 'First Heard: ${formatDateWithAgo(repeater.createdAt)}', - style: const TextStyle(fontSize: 13)), + 'Hop Bytes: ${repeater.hopBytes} byte${repeater.hopBytes == 1 ? '' : 's'}', + style: const TextStyle(fontSize: 13), + ), ), - // Max range (lazy) - FutureBuilder( - future: statsFuture, - builder: (context, snap) { - final loading = - snap.connectionState != ConnectionState.done; - final stats = snap.data; - final range = loading - ? '…' - : (stats?.maxRangeMeters != null - ? formatCoverageDistance(stats!.maxRangeMeters!, - isImperial: isImperial) - : 'N/A'); - return _repRow( + // Last heard (schedule) + if (repeater.lastHeard > 0) + _repRow( context, - Icons.open_in_full, - Text('Max Range: $range', + Icons.schedule, + Text(formatDateWithAgo(repeater.lastHeard), style: const TextStyle(fontSize: 13)), - ); - }, - ), - ], - ), - ), - const SizedBox(height: 14), - // BIDIR/TX/RX/DISC/DEAD totals (lazy — filled after the fetch) - FutureBuilder( - future: statsFuture, - builder: (context, snap) { - final loading = snap.connectionState != ConnectionState.done; - final stats = snap.data; - String v(int? n) => loading ? '…' : '${n ?? 0}'; - return Row( - children: [ - _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), - const Color(0xFF1E7E34)), - _repeaterStatCell( - context, 'TX', v(stats?.tx), const Color(0xFFFD7E14)), - _repeaterStatCell( - context, 'RX', v(stats?.rx), const Color(0xFF6F42C1)), - _repeaterStatCell(context, 'DISC', v(stats?.disc), - const Color(0xFF17A2B8)), - _repeaterStatCell(context, 'DEAD', v(stats?.dead), - const Color(0xFF6C757D)), + ), + // Clock-skew warning — single compact row, e.g. "Clock is + // 1.2 days ahead" (was two rows / a long sentence). + if (clockSkew != null) + _repRow( + context, + Icons.warning_amber_rounded, + Text('Clock is $clockSkew', + style: const TextStyle(fontSize: 13)), + color: Colors.red.shade400, + ), + // First heard + if (repeater.createdAt != null) + _repRow( + context, + Icons.event, + Text( + 'First Heard: ${formatDateWithAgo(repeater.createdAt)}', + style: const TextStyle(fontSize: 13)), + ), + // Max range (lazy) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = + snap.connectionState != ConnectionState.done; + final stats = snap.data; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : 'N/A'); + return _repRow( + context, + Icons.open_in_full, + Text('Max Range: $range', + style: const TextStyle(fontSize: 13)), + ); + }, + ), ], - ); - }, - ), - ], - ), + ), + ), + const SizedBox(height: 14), + // BIDIR/TX/RX/DISC/DEAD totals (lazy — filled after the fetch) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + String v(int? n) => loading ? '…' : '${n ?? 0}'; + return Row( + children: [ + _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), + const Color(0xFF1E7E34)), + _repeaterStatCell( + context, 'TX', v(stats?.tx), const Color(0xFFFD7E14)), + _repeaterStatCell( + context, 'RX', v(stats?.rx), const Color(0xFF6F42C1)), + _repeaterStatCell(context, 'DISC', v(stats?.disc), + const Color(0xFF17A2B8)), + _repeaterStatCell(context, 'DEAD', v(stats?.dead), + const Color(0xFF6C757D)), + ], + ); + }, + ), + ], + ), ), ), ).then((result) { diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 312d6a13..0cc073e0 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -463,8 +463,7 @@ class InteractiveNoiseFloorChartState /// Build direct and multi-hop repeater sections for the marker detail sheet List _buildMarkerRepeaterSections( BuildContext context, List repeaters) { - final directRepeaters = - repeaters.where((r) => r.pathHops == null).toList(); + final directRepeaters = repeaters.where((r) => r.pathHops == null).toList(); final multiHopRepeaters = repeaters.where((r) => r.pathHops != null).toList(); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index de1a87c0..f1cf060d 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -94,315 +94,317 @@ class PingControls extends StatelessWidget { return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { - final validation = appState.pingValidation; - final manualValidation = appState - .manualPingValidation; // Manual ping validation (no distance check) - final autoValidation = appState.autoModeValidation; - final canPingManual = - manualValidation == PingValidation.valid; // For Send Ping button - final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; - final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final isTargetedRunning = appState.isTargetedModeRunning; - final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState - .isPendingDisable; // Disable pending, waiting for RX window to complete - final isAutoStarting = appState - .isAutoPingStarting; // True while an auto mode is starting (pre-first-notify) - final cooldownActive = appState - .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode - final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState - .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = - appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = - appState.rxWindowTimer.isRunning; // RX listening window after ping - final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState - .isPingSending; // True immediately when manual ping button clicked - final isPingInProgress = appState - .isPingInProgress; // True during entire ping + RX window (includes auto pings) - final autoPingWaiting = - appState.autoPingTimer.isRunning; // Waiting for next auto ping - final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != - null; // Last ping was skipped (e.g. distance) - final discoveryWindowActive = appState.discoveryWindowTimer - .isRunning; // Discovery listening window countdown (Passive Mode) - final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; - - // TX is blocked when offline mode is active and connected - final txBlockedByOffline = appState.offlineMode && appState.isConnected; - - // TX not allowed when API says zone is at TX capacity - final txNotAllowed = appState.isConnected && !appState.txAllowed; - - // Determine blocking reason for status hint (in priority order) - // Skip antenna hint - the antenna selector already shows this - String? blockingHint; - IconData? blockingIcon; - Color? blockingColor; - - final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || - prefs.powerLevelSet || - appState.deviceModel != null; - - if (!appState.isConnected) { - // Don't show hint when disconnected - buttons are obviously disabled - } else if (!prefs.externalAntennaSet) { - blockingHint = 'Select antenna option'; - blockingIcon = Icons.settings_input_antenna; - blockingColor = Colors.orange; - } else if (!isPowerSet) { - blockingHint = 'Select power level in Connect tab'; - blockingIcon = Icons.bolt; - blockingColor = Colors.orange; - } else if (validation == PingValidation.noGpsLock) { - blockingHint = 'Waiting for GPS lock...'; - blockingIcon = Icons.gps_off; - blockingColor = Colors.blue; - } else if (validation == PingValidation.gpsInaccurate) { - blockingHint = 'GPS accuracy too low'; - blockingIcon = Icons.gps_not_fixed; - blockingColor = Colors.orange; - } else if (validation == PingValidation.outsideGeofence) { - blockingHint = 'Outside service area'; - blockingIcon = Icons.wrong_location; - blockingColor = Colors.red; - } - // Note: cooldown and tooClose are shown on button itself - - final floodTrafficVisible = appState.floodTrafficEnabled; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Action buttons row - Row( + final validation = appState.pingValidation; + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) + final autoValidation = appState.autoModeValidation; + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button + final canStartAuto = autoValidation == PingValidation.valid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; + final hybridEnabled = appState.preferences.hybridModeEnabled; + final isPendingDisable = appState + .isPendingDisable; // Disable pending, waiting for RX window to complete + final isAutoStarting = appState + .isAutoPingStarting; // True while an auto mode is starting (pre-first-notify) + final cooldownActive = appState.cooldownTimer + .isRunning; // Shared cooldown after disabling Active Mode + final cooldownRemaining = appState.cooldownTimer.remainingSec; + final manualCooldownActive = appState.manualPingCooldownTimer + .isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = + appState.rxWindowTimer.isRunning; // RX listening window after ping + final rxWindowRemaining = appState.rxWindowTimer.remainingSec; + final isPingSending = appState + .isPingSending; // True immediately when manual ping button clicked + final isPingInProgress = appState + .isPingInProgress; // True during entire ping + RX window (includes auto pings) + final autoPingWaiting = + appState.autoPingTimer.isRunning; // Waiting for next auto ping + final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != + null; // Last ping was skipped (e.g. distance) + final discoveryWindowActive = appState.discoveryWindowTimer + .isRunning; // Discovery listening window countdown (Passive Mode) + final discoveryWindowRemaining = + appState.discoveryWindowTimer.remainingSec; + + // TX is blocked when offline mode is active and connected + final txBlockedByOffline = appState.offlineMode && appState.isConnected; + + // TX not allowed when API says zone is at TX capacity + final txNotAllowed = appState.isConnected && !appState.txAllowed; + + // Determine blocking reason for status hint (in priority order) + // Skip antenna hint - the antenna selector already shows this + String? blockingHint; + IconData? blockingIcon; + Color? blockingColor; + + final prefs = appState.preferences; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; + + if (!appState.isConnected) { + // Don't show hint when disconnected - buttons are obviously disabled + } else if (!prefs.externalAntennaSet) { + blockingHint = 'Select antenna option'; + blockingIcon = Icons.settings_input_antenna; + blockingColor = Colors.orange; + } else if (!isPowerSet) { + blockingHint = 'Select power level in Connect tab'; + blockingIcon = Icons.bolt; + blockingColor = Colors.orange; + } else if (validation == PingValidation.noGpsLock) { + blockingHint = 'Waiting for GPS lock...'; + blockingIcon = Icons.gps_off; + blockingColor = Colors.blue; + } else if (validation == PingValidation.gpsInaccurate) { + blockingHint = 'GPS accuracy too low'; + blockingIcon = Icons.gps_not_fixed; + blockingColor = Colors.orange; + } else if (validation == PingValidation.outsideGeofence) { + blockingHint = 'Outside service area'; + blockingIcon = Icons.wrong_location; + blockingColor = Colors.red; + } + // Note: cooldown and tooClose are shown on button itself + + final floodTrafficVisible = appState.floodTrafficEnabled; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!txNotAllowed && floodTrafficVisible) ...[ - // Send Ping button - // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" - // Manual pings use 15-second cooldown, no distance requirement - // When Active/Passive Mode is running, just shows "Send Ping" (disabled) - Expanded( - child: _ActionButton( - icon: Icons.cell_tower, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isTxModeRunning - ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running - : isPingSending - ? 'Sending...' - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) - : manualCooldownActive - ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown - : discoveryWindowActive - ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && - // Grey Send Ping while an auto mode is starting so the - // single-ping icon doesn't show until the first auto ping - // fires (matches active/passive gating) (#389) - !isAutoStarting && - !isTxModeRunning && - !isTargetedRunning && - !cooldownActive && - !manualCooldownActive && - !txBlockedByOffline && - !txNotAllowed && - !rxWindowActive && - !isPingSending && - !discoveryWindowActive && - !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && - !isTxModeRunning, // Only active during manual ping flow - onPressed: () => _sendPing(context, appState), - showCooldown: - false, // No longer needed - countdown shown in label - subtitle: txBlockedByOffline - ? 'Offline Mode' - : txNotAllowed - ? 'Passive Only' - : null, // No "Move Xm" - manual pings have no distance requirement - subtitleColor: txBlockedByOffline - ? Colors.orange - : txNotAllowed - ? Colors.red - : null, - ), - ), - const SizedBox(width: 10), - - // Active/Hybrid Mode button (toggle) - // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon - // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle - // When OFF after being ON: shows "Cooldown Xs" like other buttons - // During manual ping: shows "Cooldown Xs" (disabled) - Expanded( - child: _ActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isPendingDisable - ? (rxWindowActive - ? 'Stopping ${rxWindowRemaining}s' - : discoveryWindowActive - ? 'Stopping ${discoveryWindowRemaining}s' - : 'Stopping...') + // Action buttons row + Row( + children: [ + if (!txNotAllowed && floodTrafficVisible) ...[ + // Send Ping button + // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" + // Manual pings use 15-second cooldown, no distance requirement + // When Active/Passive Mode is running, just shows "Send Ping" (disabled) + Expanded( + child: _ActionButton( + icon: Icons.cell_tower, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' : isTxModeRunning - ? (isPingInProgress && - !rxWindowActive && - !discoveryWindowActive + ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running + : isPingSending ? 'Sending...' - : discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // TX RX window - : autoPingWaiting - ? (autoPingSkipped - ? 'Skipped ${autoPingRemaining}s' - : 'Next ping ${autoPingRemaining}s') - : hybridEnabled - ? 'Hybrid Mode' - : 'Active Mode') - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' - : hybridEnabled - ? 'Hybrid Mode' - : 'Active Mode', - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && - !isTargetedRunning && - !isAutoStarting && - ((isTxModeRunning || - (canStartAuto && - !isPassiveModeRunning && - !cooldownActive && - !isPingSending && - !rxWindowActive)) && + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) + : manualCooldownActive + ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown + : discoveryWindowActive + ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + // Grey Send Ping while an auto mode is starting so the + // single-ping icon doesn't show until the first auto ping + // fires (matches active/passive gating) (#389) + !isAutoStarting && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && !txBlockedByOffline && - !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - onPressed: () => hybridEnabled - ? _toggleHybridAuto(context, appState) - : _toggleTxRxAuto(context, appState), - showCooldown: false, - subtitle: txBlockedByOffline - ? 'Offline Mode' - : txNotAllowed - ? 'Passive Only' - : (isPendingDisable ? 'Stopping' : null), - subtitleColor: txBlockedByOffline - ? Colors.orange - : txNotAllowed - ? Colors.red - : Colors.orange, - ), - ), - const SizedBox(width: 10), - ], - - // Passive Mode button (toggle) - // When ON: shows "Listening..." → "Next Disc Xs" cycle - // When OFF: returns to normal, Active/Hybrid Mode re-enables immediately - // Disabled during manual ping countdown phases, shows "Cooldown Xs" - // When Active/Hybrid Mode is running, just shows "Passive Mode" (disabled, no countdown) - Expanded( - child: _ActionButton( - icon: Icons.hearing, - label: isPassiveModeRunning - ? (discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window - : autoPingWaiting - ? (autoPingSkipped - ? 'Skipped ${autoPingRemaining}s' - : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery - : 'Passive Mode') // Initial state before first discovery - : isTxModeRunning || isPendingDisable - ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Passive Mode', - color: isPassiveModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || - (appState.isConnected && - !isTxModeRunning && - !isTargetedRunning && - !isPendingDisable && - !isAutoStarting && - !isPingSending && - !rxWindowActive && - !cooldownActive && - prefs.externalAntennaSet && - isPowerSet), - isActive: isPassiveModeRunning && - (discoveryWindowActive || - autoPingWaiting), // Active during listening/waiting phases - onPressed: () => _toggleRxAuto(context, appState), - ), - ), - ], - ), + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: (isPingSending || rxWindowActive) && + !isTxModeRunning, // Only active during manual ping flow + onPressed: () => _sendPing(context, appState), + showCooldown: + false, // No longer needed - countdown shown in label + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : null, // No "Move Xm" - manual pings have no distance requirement + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : null, + ), + ), + const SizedBox(width: 10), + + // Active/Hybrid Mode button (toggle) + // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon + // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle + // When OFF after being ON: shows "Cooldown Xs" like other buttons + // During manual ping: shows "Cooldown Xs" (disabled) + Expanded( + child: _ActionButton( + icon: + hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isPendingDisable + ? (rxWindowActive + ? 'Stopping ${rxWindowRemaining}s' + : discoveryWindowActive + ? 'Stopping ${discoveryWindowRemaining}s' + : 'Stopping...') + : isTxModeRunning + ? (isPingInProgress && + !rxWindowActive && + !discoveryWindowActive + ? 'Sending...' + : discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // TX RX window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next ping ${autoPingRemaining}s') + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode') + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode', + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + !isAutoStarting && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + showCooldown: false, + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : (isPendingDisable ? 'Stopping' : null), + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : Colors.orange, + ), + ), + const SizedBox(width: 10), + ], - // Status hint area - only show when there's a hint - if (blockingHint != null) - Padding( - padding: const EdgeInsets.only(top: 6, bottom: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(blockingIcon, size: 14, color: blockingColor), - const SizedBox(width: 6), - Text( - blockingHint, - style: TextStyle( - fontSize: 12, - color: blockingColor, - fontWeight: FontWeight.w500, + // Passive Mode button (toggle) + // When ON: shows "Listening..." → "Next Disc Xs" cycle + // When OFF: returns to normal, Active/Hybrid Mode re-enables immediately + // Disabled during manual ping countdown phases, shows "Cooldown Xs" + // When Active/Hybrid Mode is running, just shows "Passive Mode" (disabled, no countdown) + Expanded( + child: _ActionButton( + icon: Icons.hearing, + label: isPassiveModeRunning + ? (discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + : 'Passive Mode') // Initial state before first discovery + : isTxModeRunning || isPendingDisable + ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Passive Mode', + color: isPassiveModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isAutoStarting && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || + autoPingWaiting), // Active during listening/waiting phases + onPressed: () => _toggleRxAuto(context, appState), ), ), ], ), - ) - else - const SizedBox(height: 8), - - // Targeted Ping controls - _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || - isPassiveModeRunning || - isHybridModeRunning, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - ), - ], - ); + + // Status hint area - only show when there's a hint + if (blockingHint != null) + Padding( + padding: const EdgeInsets.only(top: 6, bottom: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(blockingIcon, size: 14, color: blockingColor), + const SizedBox(width: 6), + Text( + blockingHint, + style: TextStyle( + fontSize: 12, + color: blockingColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + else + const SizedBox(height: 8), + + // Targeted Ping controls + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + ), + ], + ); }, ); } @@ -482,104 +484,103 @@ class _ActionButtonState extends State<_ActionButton> { final bgOpacity = widget.isActive ? 0.25 : 0.12; return Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.enabled ? widget.onPressed : null, + color: Colors.transparent, + child: InkWell( + onTap: widget.enabled ? widget.onPressed : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: bgOpacity), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: effectiveColor.withValues(alpha: borderOpacity), - width: widget.isActive ? 2 : 1, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Icon with indicator dot - SizedBox( - height: 30, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - // Main icon - Icon( - widget.icon, - size: 26, - color: showColor - ? effectiveColor - : colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), - ), - // Active indicator dot - if (widget.isActive) - Positioned( - top: 0, - right: -6, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: const Color(0xFF22C55E), - shape: BoxShape.circle, - border: Border.all( - color: colorScheme.surface, - width: 2, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 4), - // Label - always same height - Text( - widget.label, - style: TextStyle( - fontSize: 11, - fontWeight: - widget.isActive ? FontWeight.w600 : FontWeight.w500, + border: Border.all( + color: effectiveColor.withValues(alpha: borderOpacity), + width: widget.isActive ? 2 : 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icon with indicator dot + SizedBox( + height: 30, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Main icon + Icon( + widget.icon, + size: 26, color: showColor - ? (widget.isActive - ? effectiveColor - : colorScheme.onSurface) + ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - ), - // Active status text OR subtitle - always reserve space - SizedBox( - height: 12, - child: widget.isActive - ? const Text( - 'Active', + // Active indicator dot + if (widget.isActive) + Positioned( + top: 0, + right: -6, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.surface, + width: 2, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + // Label - always same height + Text( + widget.label, + style: TextStyle( + fontSize: 11, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, + color: showColor + ? (widget.isActive + ? effectiveColor + : colorScheme.onSurface) + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + ), + // Active status text OR subtitle - always reserve space + SizedBox( + height: 12, + child: widget.isActive + ? const Text( + 'Active', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: Color(0xFF22C55E), + ), + ) + : widget.subtitle != null + ? Text( + widget.subtitle!, style: TextStyle( fontSize: 9, - fontWeight: FontWeight.w600, - color: Color(0xFF22C55E), + fontWeight: FontWeight.w500, + color: widget.subtitleColor ?? + Colors.orange.shade600, ), ) - : widget.subtitle != null - ? Text( - widget.subtitle!, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: widget.subtitleColor ?? - Colors.orange.shade600, - ), - ) - : null, - ), - ], + : null, ), - ), + ], ), - ); + ), + ), + ); } } @@ -649,180 +650,182 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { - final isTargetedRunning = appState.isTargetedModeRunning; - final maxLen = appState.traceHopBytes * 2; - - // Sync controller when provider clears target (e.g. trace bytes changed) - if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { - _controller.clear(); - } - - // Determine if the start button should be enabled - final hexText = _controller.text.trim(); - final isValidHex = hexText.isNotEmpty && - hexText.length == maxLen && - RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); - final canStart = isValidHex && - !widget.isAnyModeRunning && - !isTargetedRunning && - !widget.cooldownActive && - appState.isConnected; - - // Status text for when targeted mode is running - String? statusText; - if (isTargetedRunning) { - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; - final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; - final autoPingWaiting = appState.autoPingTimer.isRunning; - final autoPingRemaining = appState.autoPingTimer.remainingSec; - - if (discoveryWindowActive) { - statusText = 'Listening ${discoveryRemaining}s'; - } else if (autoPingWaiting) { - statusText = appState.autoPingTimer.skipReason != null - ? 'Skipped ${autoPingRemaining}s' - : 'Next in ${autoPingRemaining}s'; - } - } - - final isEnabled = (canStart || isTargetedRunning) && !_isStarting; - final buttonColor = (isTargetedRunning || _isStarting) - ? const Color(0xFF22C55E) // green-500 when running/starting - : Colors.cyan; - final effectiveColor = - isEnabled ? buttonColor : colorScheme.onSurfaceVariant; - - return Container( - decoration: BoxDecoration( - color: - effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: - effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), - width: isTargetedRunning ? 1.5 : 1, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Targeted button - Expanded( - child: GestureDetector( - onTap: isEnabled - ? () async { - HapticFeedback.lightImpact(); - if (!isTargetedRunning) { - setState(() => _isStarting = true); - appState.setTargetRepeaterId( - _controller.text.trim().toUpperCase()); - } - await appState.toggleAutoPing(AutoMode.targeted); - if (mounted) setState(() => _isStarting = false); - } - : null, - behavior: HitTestBehavior.opaque, - child: Row( - children: [ - Icon( - Icons.route, - size: 18, - color: effectiveColor, - ), - if (!widget.compact) ...[ - const SizedBox(width: 8), - Expanded( - child: Text( - _isStarting - ? 'Starting...' - : isTargetedRunning - ? (statusText ?? 'Stop') - : widget.cooldownActive - ? 'Cooldown ${widget.cooldownRemaining}s' - : 'Trace Mode', - style: TextStyle( - fontSize: 13, - fontWeight: isTargetedRunning - ? FontWeight.w600 - : FontWeight.w500, - color: isEnabled - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ], - ), + final isTargetedRunning = appState.isTargetedModeRunning; + final maxLen = appState.traceHopBytes * 2; + + // Sync controller when provider clears target (e.g. trace bytes changed) + if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { + _controller.clear(); + } + + // Determine if the start button should be enabled + final hexText = _controller.text.trim(); + final isValidHex = hexText.isNotEmpty && + hexText.length == maxLen && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); + final canStart = isValidHex && + !widget.isAnyModeRunning && + !isTargetedRunning && + !widget.cooldownActive && + appState.isConnected; + + // Status text for when targeted mode is running + String? statusText; + if (isTargetedRunning) { + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + + if (discoveryWindowActive) { + statusText = 'Listening ${discoveryRemaining}s'; + } else if (autoPingWaiting) { + statusText = appState.autoPingTimer.skipReason != null + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s'; + } + } + + final isEnabled = (canStart || isTargetedRunning) && !_isStarting; + final buttonColor = (isTargetedRunning || _isStarting) + ? const Color(0xFF22C55E) // green-500 when running/starting + : Colors.cyan; + final effectiveColor = + isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + + return Container( + decoration: BoxDecoration( + color: effectiveColor.withValues( + alpha: isTargetedRunning ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: effectiveColor.withValues( + alpha: isTargetedRunning ? 0.5 : 0.25), + width: isTargetedRunning ? 1.5 : 1, ), ), - const SizedBox(width: 8), - // Hex text field - SizedBox( - width: 80, - child: TextField( - controller: _controller, - enabled: !isTargetedRunning, - maxLength: maxLen, - textCapitalization: TextCapitalization.characters, - style: TextStyle( - fontSize: 14, - fontFamily: 'monospace', - color: isTargetedRunning - ? colorScheme.onSurfaceVariant - : colorScheme.onSurface, - ), - decoration: InputDecoration( - hintText: - 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', - hintStyle: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Targeted button + Expanded( + child: GestureDetector( + onTap: isEnabled + ? () async { + HapticFeedback.lightImpact(); + if (!isTargetedRunning) { + setState(() => _isStarting = true); + appState.setTargetRepeaterId( + _controller.text.trim().toUpperCase()); + } + await appState.toggleAutoPing(AutoMode.targeted); + if (mounted) setState(() => _isStarting = false); + } + : null, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Icon( + Icons.route, + size: 18, + color: effectiveColor, + ), + if (!widget.compact) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', + style: TextStyle( + fontSize: 13, + fontWeight: isTargetedRunning + ? FontWeight.w600 + : FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), ), - counterText: '', - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + ), + const SizedBox(width: 8), + // Hex text field + SizedBox( + width: 80, + child: TextField( + controller: _controller, + enabled: !isTargetedRunning, + maxLength: maxLen, + textCapitalization: TextCapitalization.characters, + style: TextStyle( + fontSize: 14, + fontFamily: 'monospace', + color: isTargetedRunning + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: + 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintStyle: TextStyle( + fontSize: 12, + color: + colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + counterText: '', + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), + _UpperCaseTextFormatter(), + ], + onChanged: (value) { + appState.setTargetRepeaterId(value.trim().toUpperCase()); + setState(() {}); + }, ), ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), - _UpperCaseTextFormatter(), - ], - onChanged: (value) { - appState.setTargetRepeaterId(value.trim().toUpperCase()); - setState(() {}); - }, - ), - ), - const SizedBox(width: 6), - // Choose repeater button - SizedBox( - width: 32, - height: 32, - child: IconButton( - icon: Icon( - Icons.list, - size: 18, - color: (!isTargetedRunning && appState.repeaters.isNotEmpty) - ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + const SizedBox(width: 6), + // Choose repeater button + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: Icon( + Icons.list, + size: 18, + color: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + onPressed: + (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? _showRepeaterPicker + : null, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + tooltip: 'Choose repeater', + ), ), - onPressed: (!isTargetedRunning && appState.repeaters.isNotEmpty) - ? _showRepeaterPicker - : null, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - tooltip: 'Choose repeater', - ), + ], ), - ], - ), - ); + ); }, ); } @@ -867,357 +870,360 @@ class _CompactPingControlsState extends State { return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { - final manualValidation = appState - .manualPingValidation; // Manual ping validation (no distance check) - final autoValidation = appState.autoModeValidation; - final canPingManual = - manualValidation == PingValidation.valid; // For Send Ping button - final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; - final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final isTargetedRunning = appState.isTargetedModeRunning; - final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; - final isAutoStarting = appState.isAutoPingStarting; - final cooldownActive = appState.cooldownTimer.isRunning; - final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState - .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = - appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; - final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; - final isPingInProgress = appState.isPingInProgress; - final autoPingWaiting = appState.autoPingTimer.isRunning; - final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; - final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; - - // TX is blocked when offline mode is active and connected - final txBlockedByOffline = appState.offlineMode && appState.isConnected; - - // TX not allowed when API says zone is at TX capacity - final txNotAllowed = appState.isConnected && !appState.txAllowed; - - final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || - prefs.powerLevelSet || - appState.deviceModel != null; - - // Determine which button is currently active (not during cooldown) - final sendPingCurrentlyActive = - (isPingSending || rxWindowActive || manualCooldownActive) && - !isTxModeRunning; - final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; - final passiveModeCurrentlyActive = - isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); - - // Track the last active button for cooldown - if (sendPingCurrentlyActive) { - _lastActiveButton = _LastActiveButton.sendPing; - } else if (activeModeCurrentlyActive) { - _lastActiveButton = _LastActiveButton.activeMode; - } else if (passiveModeCurrentlyActive) { - _lastActiveButton = _LastActiveButton.passiveMode; - } else if (isTargetedRunning) { - _lastActiveButton = _LastActiveButton.targeted; - } - // Reset when no cooldown and no activity - if (!cooldownActive && - !manualCooldownActive && - !sendPingCurrentlyActive && - !activeModeCurrentlyActive && - !passiveModeCurrentlyActive && - !isTargetedRunning) { - _lastActiveButton = _LastActiveButton.none; - } - - // Determine which button should be expanded - // During cooldown, the last active button stays expanded - final sendPingExpanded = sendPingCurrentlyActive || - (manualCooldownActive && - _lastActiveButton == _LastActiveButton.sendPing) || - (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); - final activeModeExpanded = activeModeCurrentlyActive || - (cooldownActive && _lastActiveButton == _LastActiveButton.activeMode); - final passiveModeExpanded = passiveModeCurrentlyActive || - (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); - - // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && - // Grey Send Ping while an auto mode is starting so the single-ping icon - // doesn't show until the first auto ping fires (matches active/passive - // gating) (#389) - !isAutoStarting && - !isTxModeRunning && - !isTargetedRunning && - !cooldownActive && - !manualCooldownActive && - !txBlockedByOffline && - !txNotAllowed && - !rxWindowActive && - !isPingSending && - !discoveryWindowActive && - !isPendingDisable; - final sendPingActive = (isPingSending || rxWindowActive) && - !isTxModeRunning && - !cooldownActive && - !manualCooldownActive; - final sendPingShowColor = sendPingEnabled || sendPingActive; - - final activeModeEnabled = !isPendingDisable && - !isTargetedRunning && - !isAutoStarting && - ((isTxModeRunning || - (canStartAuto && - !isPassiveModeRunning && - !cooldownActive && - !isPingSending && - !rxWindowActive)) && + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) + final autoValidation = appState.autoModeValidation; + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button + final canStartAuto = autoValidation == PingValidation.valid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; + final hybridEnabled = appState.preferences.hybridModeEnabled; + final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; + final cooldownActive = appState.cooldownTimer.isRunning; + final cooldownRemaining = appState.cooldownTimer.remainingSec; + final manualCooldownActive = appState.manualPingCooldownTimer + .isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = appState.rxWindowTimer.isRunning; + final rxWindowRemaining = appState.rxWindowTimer.remainingSec; + final isPingSending = appState.isPingSending; + final isPingInProgress = appState.isPingInProgress; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryWindowRemaining = + appState.discoveryWindowTimer.remainingSec; + + // TX is blocked when offline mode is active and connected + final txBlockedByOffline = appState.offlineMode && appState.isConnected; + + // TX not allowed when API says zone is at TX capacity + final txNotAllowed = appState.isConnected && !appState.txAllowed; + + final prefs = appState.preferences; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; + + // Determine which button is currently active (not during cooldown) + final sendPingCurrentlyActive = + (isPingSending || rxWindowActive || manualCooldownActive) && + !isTxModeRunning; + final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; + final passiveModeCurrentlyActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + + // Track the last active button for cooldown + if (sendPingCurrentlyActive) { + _lastActiveButton = _LastActiveButton.sendPing; + } else if (activeModeCurrentlyActive) { + _lastActiveButton = _LastActiveButton.activeMode; + } else if (passiveModeCurrentlyActive) { + _lastActiveButton = _LastActiveButton.passiveMode; + } else if (isTargetedRunning) { + _lastActiveButton = _LastActiveButton.targeted; + } + // Reset when no cooldown and no activity + if (!cooldownActive && + !manualCooldownActive && + !sendPingCurrentlyActive && + !activeModeCurrentlyActive && + !passiveModeCurrentlyActive && + !isTargetedRunning) { + _lastActiveButton = _LastActiveButton.none; + } + + // Determine which button should be expanded + // During cooldown, the last active button stays expanded + final sendPingExpanded = sendPingCurrentlyActive || + (manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing) || + (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); + final activeModeExpanded = activeModeCurrentlyActive || + (cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode); + final passiveModeExpanded = passiveModeCurrentlyActive || + (cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode); + + // Determine which buttons are colored (enabled or active) + final sendPingEnabled = canPingManual && + // Grey Send Ping while an auto mode is starting so the single-ping icon + // doesn't show until the first auto ping fires (matches active/passive + // gating) (#389) + !isAutoStarting && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && !txBlockedByOffline && - !txNotAllowed); - final activeModeActive = isPendingDisable || isTxModeRunning; - final activeModeShowColor = activeModeEnabled || activeModeActive; - - final passiveModeEnabled = isPassiveModeRunning || - (appState.isConnected && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable; + final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && + !cooldownActive && + !manualCooldownActive; + final sendPingShowColor = sendPingEnabled || sendPingActive; + + final activeModeEnabled = !isPendingDisable && !isTargetedRunning && - !isPendingDisable && !isAutoStarting && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed); + final activeModeActive = isPendingDisable || isTxModeRunning; + final activeModeShowColor = activeModeEnabled || activeModeActive; + + final passiveModeEnabled = isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isAutoStarting && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet); + final passiveModeActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeShowColor = passiveModeEnabled || passiveModeActive; + + // Trace Mode (only relevant when a repeater ID has been entered) + final hasTargetRepeaterId = appState.targetRepeaterId != null && + appState.targetRepeaterId!.isNotEmpty; + final targetedCurrentlyActive = isTargetedRunning; + final traceModeExpanded = targetedCurrentlyActive || + (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); + final traceModeEnabled = hasTargetRepeaterId && + !isTxModeRunning && + !isPassiveModeRunning && + !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && + !manualCooldownActive && + appState.isConnected && prefs.externalAntennaSet && - isPowerSet); - final passiveModeActive = - isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); - final passiveModeShowColor = passiveModeEnabled || passiveModeActive; - - // Trace Mode (only relevant when a repeater ID has been entered) - final hasTargetRepeaterId = appState.targetRepeaterId != null && - appState.targetRepeaterId!.isNotEmpty; - final targetedCurrentlyActive = isTargetedRunning; - final traceModeExpanded = targetedCurrentlyActive || - (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); - final traceModeEnabled = hasTargetRepeaterId && - !isTxModeRunning && - !isPassiveModeRunning && - !isPendingDisable && - !isPingSending && - !rxWindowActive && - !cooldownActive && - !manualCooldownActive && - appState.isConnected && - prefs.externalAntennaSet && - isPowerSet; - final traceModeActive = isTargetedRunning; - final traceModeShowColor = traceModeEnabled || traceModeActive; - - // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || - activeModeExpanded || - passiveModeExpanded || - traceModeExpanded; - // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && - !activeModeShowColor && - !passiveModeShowColor && - (!hasTargetRepeaterId || !traceModeShowColor); - - // Build the buttons - final sendPingButton = _CompactActionButton( - icon: Icons.cell_tower, - label: _getSendPingLabel( - isPingSending: isPingSending, - rxWindowActive: rxWindowActive, - rxWindowRemaining: rxWindowRemaining, - manualCooldownActive: manualCooldownActive, - manualCooldownRemaining: manualCooldownRemaining, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - showFullText: sendPingExpanded, - ), - color: const Color(0xFF0EA5E9), // sky-500 - enabled: sendPingEnabled, - isActive: sendPingActive, - isExpanded: sendPingExpanded, - progress: rxWindowActive && !isTxModeRunning - ? appState.rxWindowTimer.progress - : manualCooldownActive && - _lastActiveButton == _LastActiveButton.sendPing - ? appState.manualPingCooldownTimer.progress - : cooldownActive && + isPowerSet; + final traceModeActive = isTargetedRunning; + final traceModeShowColor = traceModeEnabled || traceModeActive; + + // Check if any button is actively expanded (showing label) + final anyExpanded = sendPingExpanded || + activeModeExpanded || + passiveModeExpanded || + traceModeExpanded; + // Check if all buttons are disabled (no color) - used to split space equally in initial state + final allDisabled = !sendPingShowColor && + !activeModeShowColor && + !passiveModeShowColor && + (!hasTargetRepeaterId || !traceModeShowColor); + + // Build the buttons + final sendPingButton = _CompactActionButton( + icon: Icons.cell_tower, + label: _getSendPingLabel( + isPingSending: isPingSending, + rxWindowActive: rxWindowActive, + rxWindowRemaining: rxWindowRemaining, + manualCooldownActive: manualCooldownActive, + manualCooldownRemaining: manualCooldownRemaining, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + showFullText: sendPingExpanded, + ), + color: const Color(0xFF0EA5E9), // sky-500 + enabled: sendPingEnabled, + isActive: sendPingActive, + isExpanded: sendPingExpanded, + progress: rxWindowActive && !isTxModeRunning + ? appState.rxWindowTimer.progress + : manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing - ? appState.cooldownTimer.progress - : null, - onPressed: () => _sendPing(context, appState), - ); + ? appState.manualPingCooldownTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.sendPing + ? appState.cooldownTimer.progress + : null, + onPressed: () => _sendPing(context, appState), + ); - final activeModeButton = _CompactActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: _getActiveModeLabel( - isActiveModeRunning: isTxModeRunning, - isPingInProgress: isPingInProgress, - rxWindowActive: rxWindowActive, - rxWindowRemaining: rxWindowRemaining, - autoPingWaiting: autoPingWaiting, - autoPingRemaining: autoPingRemaining, - isPendingDisable: isPendingDisable, - showFullText: activeModeExpanded, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - isExpandedDuringCooldown: activeModeExpanded && cooldownActive, - isSkipped: autoPingSkipped, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - ), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning + final activeModeButton = _CompactActionButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: _getActiveModeLabel( + isActiveModeRunning: isTxModeRunning, + isPingInProgress: isPingInProgress, + rxWindowActive: rxWindowActive, + rxWindowRemaining: rxWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + isPendingDisable: isPendingDisable, + showFullText: activeModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: activeModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + ), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: activeModeEnabled, + isActive: activeModeActive, + isExpanded: activeModeExpanded, + progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning + ? (discoveryWindowActive + ? appState.discoveryWindowTimer.progress + : appState.rxWindowTimer.progress) + : autoPingWaiting && isTxModeRunning + ? appState.autoPingTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode + ? appState.cooldownTimer.progress + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ); + + final passiveModeButton = _CompactActionButton( + icon: Icons.hearing, + label: _getPassiveModeLabel( + isPassiveModeRunning: isPassiveModeRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: passiveModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: activeModeEnabled, - isActive: activeModeActive, - isExpanded: activeModeExpanded, - progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning - ? (discoveryWindowActive + enabled: passiveModeEnabled, + isActive: passiveModeActive, + isExpanded: passiveModeExpanded, + progress: discoveryWindowActive && isPassiveModeRunning ? appState.discoveryWindowTimer.progress - : appState.rxWindowTimer.progress) - : autoPingWaiting && isTxModeRunning - ? appState.autoPingTimer.progress - : cooldownActive && - _lastActiveButton == _LastActiveButton.activeMode - ? appState.cooldownTimer.progress - : null, - onPressed: () => hybridEnabled - ? _toggleHybridAuto(context, appState) - : _toggleTxRxAuto(context, appState), - ); + : autoPingWaiting && isPassiveModeRunning + ? appState.autoPingTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode + ? appState.cooldownTimer.progress + : null, + onPressed: () => _toggleRxAuto(context, appState), + ); - final passiveModeButton = _CompactActionButton( - icon: Icons.hearing, - label: _getPassiveModeLabel( - isPassiveModeRunning: isPassiveModeRunning, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - autoPingWaiting: autoPingWaiting, - autoPingRemaining: autoPingRemaining, - showFullText: passiveModeExpanded, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, - isSkipped: autoPingSkipped, - ), - color: isPassiveModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: passiveModeEnabled, - isActive: passiveModeActive, - isExpanded: passiveModeExpanded, - progress: discoveryWindowActive && isPassiveModeRunning - ? appState.discoveryWindowTimer.progress - : autoPingWaiting && isPassiveModeRunning - ? appState.autoPingTimer.progress - : cooldownActive && - _lastActiveButton == _LastActiveButton.passiveMode - ? appState.cooldownTimer.progress - : null, - onPressed: () => _toggleRxAuto(context, appState), - ); + // Build trace mode button (only used when hasTargetRepeaterId) + final traceModeButton = _CompactActionButton( + icon: Icons.route, + label: _getTraceModeLabel( + isTargetedRunning: isTargetedRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: traceModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isTargetedRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF06B6D4), // cyan-500 + enabled: traceModeEnabled || isTargetedRunning, + isActive: traceModeActive, + isExpanded: traceModeExpanded, + progress: discoveryWindowActive && isTargetedRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isTargetedRunning + ? appState.autoPingTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.targeted + ? appState.cooldownTimer.progress + : null, + onPressed: () { + HapticFeedback.lightImpact(); + appState.toggleAutoPing(AutoMode.targeted); + }, + ); - // Build trace mode button (only used when hasTargetRepeaterId) - final traceModeButton = _CompactActionButton( - icon: Icons.route, - label: _getTraceModeLabel( - isTargetedRunning: isTargetedRunning, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - autoPingWaiting: autoPingWaiting, - autoPingRemaining: autoPingRemaining, - showFullText: traceModeExpanded, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - isExpandedDuringCooldown: traceModeExpanded && cooldownActive, - isSkipped: autoPingSkipped, - ), - color: isTargetedRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF06B6D4), // cyan-500 - enabled: traceModeEnabled || isTargetedRunning, - isActive: traceModeActive, - isExpanded: traceModeExpanded, - progress: discoveryWindowActive && isTargetedRunning - ? appState.discoveryWindowTimer.progress - : autoPingWaiting && isTargetedRunning - ? appState.autoPingTimer.progress - : cooldownActive && - _lastActiveButton == _LastActiveButton.targeted - ? appState.cooldownTimer.progress - : null, - onPressed: () { - HapticFeedback.lightImpact(); - appState.toggleAutoPing(AutoMode.targeted); - }, - ); + final floodTrafficVisible = appState.floodTrafficEnabled; - final floodTrafficVisible = appState.floodTrafficEnabled; + // Layout logic: + // - If button is expanded (including during cooldown): stays big + // - If no button is expanded: all colored buttons share space equally + // - Grey non-expanded buttons are icon-only + return Row( + children: [ + if (!txNotAllowed && floodTrafficVisible) ...[ + // Send Ping - expanded buttons stay big even when grey (cooldown) + if (sendPingExpanded) + Expanded(child: sendPingButton) + else if (!anyExpanded && (sendPingShowColor || allDisabled)) + Expanded(child: sendPingButton) + else + sendPingButton, + const SizedBox(width: 6), + + // Active Mode + if (activeModeExpanded) + Expanded(child: activeModeButton) + else if (!anyExpanded && (activeModeShowColor || allDisabled)) + Expanded(child: activeModeButton) + else + activeModeButton, + const SizedBox(width: 6), + ], - // Layout logic: - // - If button is expanded (including during cooldown): stays big - // - If no button is expanded: all colored buttons share space equally - // - Grey non-expanded buttons are icon-only - return Row( - children: [ - if (!txNotAllowed && floodTrafficVisible) ...[ - // Send Ping - expanded buttons stay big even when grey (cooldown) - if (sendPingExpanded) - Expanded(child: sendPingButton) - else if (!anyExpanded && (sendPingShowColor || allDisabled)) - Expanded(child: sendPingButton) - else - sendPingButton, - const SizedBox(width: 6), - - // Active Mode - if (activeModeExpanded) - Expanded(child: activeModeButton) - else if (!anyExpanded && (activeModeShowColor || allDisabled)) - Expanded(child: activeModeButton) - else - activeModeButton, - const SizedBox(width: 6), - ], - - // Passive Mode - if (passiveModeExpanded) - Expanded(child: passiveModeButton) - else if (!anyExpanded && (passiveModeShowColor || allDisabled)) - Expanded(child: passiveModeButton) - else - passiveModeButton, - - // Trace Mode (only shown when a repeater ID has been entered) - if (hasTargetRepeaterId) ...[ - const SizedBox(width: 6), - if (traceModeExpanded) - Expanded(child: traceModeButton) - else if (!anyExpanded && (traceModeShowColor || allDisabled)) - Expanded(child: traceModeButton) - else - traceModeButton, - ], - ], - ); + // Passive Mode + if (passiveModeExpanded) + Expanded(child: passiveModeButton) + else if (!anyExpanded && (passiveModeShowColor || allDisabled)) + Expanded(child: passiveModeButton) + else + passiveModeButton, + + // Trace Mode (only shown when a repeater ID has been entered) + if (hasTargetRepeaterId) ...[ + const SizedBox(width: 6), + if (traceModeExpanded) + Expanded(child: traceModeButton) + else if (!anyExpanded && (traceModeShowColor || allDisabled)) + Expanded(child: traceModeButton) + else + traceModeButton, + ], + ], + ); }, ); } @@ -1442,199 +1448,203 @@ class LandscapePingControls extends StatelessWidget { return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { - final manualValidation = appState - .manualPingValidation; // Manual ping validation (no distance check) - final autoValidation = appState.autoModeValidation; - final canPingManual = - manualValidation == PingValidation.valid; // For Send Ping button - final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = - appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; - final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final isTargetedRunning = appState.isTargetedModeRunning; - final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; - final isAutoStarting = appState.isAutoPingStarting; - final cooldownActive = appState.cooldownTimer.isRunning; - final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState - .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = - appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; - final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; - final autoPingWaiting = appState.autoPingTimer.isRunning; - final autoPingRemaining = appState.autoPingTimer.remainingSec; - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; - final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; - - // TX is blocked when offline mode is active and connected - final txBlockedByOffline = appState.offlineMode && appState.isConnected; - - // TX not allowed when API says zone is at TX capacity - final txNotAllowed = appState.isConnected && !appState.txAllowed; - - final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || - prefs.powerLevelSet || - appState.deviceModel != null; - - final floodTrafficVisible = appState.floodTrafficEnabled; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Antenna selector (compact) - _LandscapeAntennaSelector( - externalAntenna: prefs.externalAntenna, - externalAntennaSet: prefs.externalAntennaSet, - onChanged: (value) => appState.updatePreferences( - prefs.copyWith(externalAntenna: value, externalAntennaSet: true), - ), - ), - const SizedBox(height: 10), - - // Action buttons row (icon-only) - Row( + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) + final autoValidation = appState.autoModeValidation; + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button + final canStartAuto = autoValidation == PingValidation.valid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; + final hybridEnabled = appState.preferences.hybridModeEnabled; + final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; + final cooldownActive = appState.cooldownTimer.isRunning; + final cooldownRemaining = appState.cooldownTimer.remainingSec; + final manualCooldownActive = appState.manualPingCooldownTimer + .isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = appState.rxWindowTimer.isRunning; + final rxWindowRemaining = appState.rxWindowTimer.remainingSec; + final isPingSending = appState.isPingSending; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryWindowRemaining = + appState.discoveryWindowTimer.remainingSec; + + // TX is blocked when offline mode is active and connected + final txBlockedByOffline = appState.offlineMode && appState.isConnected; + + // TX not allowed when API says zone is at TX capacity + final txNotAllowed = appState.isConnected && !appState.txAllowed; + + final prefs = appState.preferences; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; + + final floodTrafficVisible = appState.floodTrafficEnabled; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!txNotAllowed && floodTrafficVisible) ...[ - // TX Ping button - Expanded( - child: _LandscapeIconButton( - icon: Icons.cell_tower, - tooltip: - txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && - // Grey Send Ping while an auto mode is starting so the - // single-ping icon doesn't show until the first auto ping - // fires (matches active/passive gating) (#389) - !isAutoStarting && - !isTxModeRunning && - !isTargetedRunning && - !cooldownActive && - !manualCooldownActive && - !txBlockedByOffline && - !txNotAllowed && - !rxWindowActive && - !isPingSending && - !discoveryWindowActive && - !isPendingDisable, - isActive: - (isPingSending || rxWindowActive) && !isTxModeRunning, - countdown: isPingSending - ? null - : rxWindowActive && !isTxModeRunning - ? rxWindowRemaining - : manualCooldownActive - ? manualCooldownRemaining - : discoveryWindowActive - ? discoveryWindowRemaining - : cooldownActive - ? cooldownRemaining - : null, - onPressed: () => _sendPing(context, appState), - ), + // Antenna selector (compact) + _LandscapeAntennaSelector( + externalAntenna: prefs.externalAntenna, + externalAntennaSet: prefs.externalAntennaSet, + onChanged: (value) => appState.updatePreferences( + prefs.copyWith( + externalAntenna: value, externalAntennaSet: true), ), - const SizedBox(width: 8), + ), + const SizedBox(height: 10), - // Active/Hybrid Mode button - Expanded( - child: _LandscapeIconButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - tooltip: txNotAllowed - ? 'Zone Full (Passive Only)' - : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && - !isTargetedRunning && - !isAutoStarting && - ((isTxModeRunning || - (canStartAuto && - !isPassiveModeRunning && - !cooldownActive && - !isPingSending && - !rxWindowActive)) && + // Action buttons row (icon-only) + Row( + children: [ + if (!txNotAllowed && floodTrafficVisible) ...[ + // TX Ping button + Expanded( + child: _LandscapeIconButton( + icon: Icons.cell_tower, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + // Grey Send Ping while an auto mode is starting so the + // single-ping icon doesn't show until the first auto ping + // fires (matches active/passive gating) (#389) + !isAutoStarting && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && !txBlockedByOffline && - !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - countdown: isTxModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : rxWindowActive - ? rxWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : isPendingDisable && - (rxWindowActive || discoveryWindowActive) - ? (rxWindowActive + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: + (isPingSending || rxWindowActive) && !isTxModeRunning, + countdown: isPingSending + ? null + : rxWindowActive && !isTxModeRunning ? rxWindowRemaining - : discoveryWindowRemaining) - : null, - onPressed: () => hybridEnabled - ? _toggleHybridAuto(context, appState) - : _toggleTxRxAuto(context, appState), - ), - ), - const SizedBox(width: 8), - ], + : manualCooldownActive + ? manualCooldownRemaining + : discoveryWindowActive + ? discoveryWindowRemaining + : cooldownActive + ? cooldownRemaining + : null, + onPressed: () => _sendPing(context, appState), + ), + ), + const SizedBox(width: 8), + + // Active/Hybrid Mode button + Expanded( + child: _LandscapeIconButton( + icon: + hybridEnabled ? Icons.compare_arrows : Icons.sensors, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + !isAutoStarting && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + countdown: isTxModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : rxWindowActive + ? rxWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : isPendingDisable && + (rxWindowActive || discoveryWindowActive) + ? (rxWindowActive + ? rxWindowRemaining + : discoveryWindowRemaining) + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ), + ), + const SizedBox(width: 8), + ], - // Passive Mode button - Expanded( - child: _LandscapeIconButton( - icon: Icons.hearing, - tooltip: 'Passive Mode', - color: isPassiveModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || - (appState.isConnected && - !isTxModeRunning && - !isTargetedRunning && - !isPendingDisable && - !isAutoStarting && - !isPingSending && - !rxWindowActive && - !cooldownActive && - prefs.externalAntennaSet && - isPowerSet), - isActive: isPassiveModeRunning && - (discoveryWindowActive || autoPingWaiting), - countdown: isPassiveModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : null, - onPressed: () => _toggleRxAuto(context, appState), - ), + // Passive Mode button + Expanded( + child: _LandscapeIconButton( + icon: Icons.hearing, + tooltip: 'Passive Mode', + color: isPassiveModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isAutoStarting && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || autoPingWaiting), + countdown: isPassiveModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : null, + onPressed: () => _toggleRxAuto(context, appState), + ), + ), + ], + ), + const SizedBox(height: 10), + + // Targeted Ping controls (Trace Mode) + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + compact: true, ), ], - ), - const SizedBox(height: 10), - - // Targeted Ping controls (Trace Mode) - _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || - isPassiveModeRunning || - isHybridModeRunning, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - compact: true, - ), - ], - ); + ); }, ); } @@ -1849,78 +1859,78 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> { final bgOpacity = widget.isActive ? 0.25 : 0.10; return Tooltip( - message: widget.tooltip, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.enabled ? widget.onPressed : null, + message: widget.tooltip, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.enabled ? widget.onPressed : null, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), - child: Container( - height: 48, - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: bgOpacity), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: effectiveColor.withValues( - alpha: widget.isActive ? 0.5 : 0.25), - width: widget.isActive ? 1.5 : 1, - ), + border: Border.all( + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.25), + width: widget.isActive ? 1.5 : 1, + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Main icon + Icon( + widget.icon, + size: 24, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - child: Stack( - alignment: Alignment.center, - children: [ - // Main icon - Icon( - widget.icon, - size: 24, - color: showColor - ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - // Countdown badge (bottom right) - if (widget.countdown != null) - Positioned( - bottom: 4, - right: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: effectiveColor, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${widget.countdown}', - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), + // Countdown badge (bottom right) + if (widget.countdown != null) + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: effectiveColor, + borderRadius: BorderRadius.circular(6), ), - // Active indicator dot (top right) - if (widget.isActive && widget.countdown == null) - Positioned( - top: 4, - right: 4, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: const Color(0xFF22C55E), - shape: BoxShape.circle, - border: Border.all( - color: colorScheme.surface, width: 1.5), - ), + child: Text( + '${widget.countdown}', + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Colors.white, ), ), - ], - ), - ), + ), + ), + // Active indicator dot (top right) + if (widget.isActive && widget.countdown == null) + Positioned( + top: 4, + right: 4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: + Border.all(color: colorScheme.surface, width: 1.5), + ), + ), + ), + ], ), ), - ); + ), + ), + ); } } @@ -1967,95 +1977,95 @@ class _CompactActionButtonState extends State<_CompactActionButton> { final bgOpacity = widget.isActive ? 0.25 : 0.12; return Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.enabled ? widget.onPressed : null, - borderRadius: BorderRadius.circular(14), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - height: 32, - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: bgOpacity), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: effectiveColor.withValues( - alpha: widget.isActive ? 0.5 : 0.3), - width: widget.isActive ? 1.5 : 1, - ), - ), - child: Stack( - children: [ - // Progress fill (behind content) - if (widget.progress != null && widget.progress! > 0) - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: widget.progress!, - child: Container( - color: effectiveColor.withValues(alpha: 0.3), - ), - ), + color: Colors.transparent, + child: InkWell( + onTap: widget.enabled ? widget.onPressed : null, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + height: 32, + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: bgOpacity), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), + width: widget.isActive ? 1.5 : 1, + ), + ), + child: Stack( + children: [ + // Progress fill (behind content) + if (widget.progress != null && widget.progress! > 0) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: widget.progress!, + child: Container( + color: effectiveColor.withValues(alpha: 0.3), ), ), - // Existing button content - Padding( - padding: EdgeInsets.symmetric( - horizontal: hasLabel ? 10 : 8, - vertical: 6, - ), - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.icon, - size: 18, - color: showColor - ? effectiveColor - : colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), - ), - // Animated label - show when label is provided - AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: hasLabel - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 5), - Text( - widget.label!, - style: TextStyle( - fontSize: 11, - fontWeight: widget.isActive - ? FontWeight.w600 - : FontWeight.w500, - color: showColor - ? effectiveColor - : colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), - ), - ), - ], - ) - : const SizedBox.shrink(), - ), - ], + ), + ), + // Existing button content + Padding( + padding: EdgeInsets.symmetric( + horizontal: hasLabel ? 10 : 8, + vertical: 6, + ), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + size: 18, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), - ), + // Animated label - show when label is provided + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: hasLabel + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + Text( + widget.label!, + style: TextStyle( + fontSize: 11, + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), + ), + ), + ], + ) + : const SizedBox.shrink(), + ), + ], ), ), - ], + ), ), - ), + ], ), - ); + ), + ), + ); } } diff --git a/test/services/transport/stream_frame_codec_test.dart b/test/services/transport/stream_frame_codec_test.dart index ccabac5c..8a4dac82 100644 --- a/test/services/transport/stream_frame_codec_test.dart +++ b/test/services/transport/stream_frame_codec_test.dart @@ -104,8 +104,7 @@ void main() { final frame1 = makeIncomingFrame([0x01]); final frame2 = makeIncomingFrame([0x02]); final frame3 = makeIncomingFrame([0x03]); - final combined = - Uint8List.fromList([...frame1, ...frame2, ...frame3]); + final combined = Uint8List.fromList([...frame1, ...frame2, ...frame3]); codec.addBytes(combined); await Future.delayed(Duration.zero); @@ -183,8 +182,7 @@ void main() { final frame1 = makeIncomingFrame([0xAA]); final junk = [0x44, 0x45, 0x42, 0x55, 0x47]; // "DEBUG" final frame2 = makeIncomingFrame([0xBB]); - final combined = - Uint8List.fromList([...frame1, ...junk, ...frame2]); + final combined = Uint8List.fromList([...frame1, ...junk, ...frame2]); codec.addBytes(combined); await Future.delayed(Duration.zero); @@ -213,8 +211,7 @@ void main() { // Frame claiming 301 bytes: marker + [0x2D, 0x01] = 301 final oversizedHeader = Uint8List.fromList([0x3E, 0x2D, 0x01]); final validFrame = makeIncomingFrame([0x42]); - final combined = - Uint8List.fromList([...oversizedHeader, ...validFrame]); + final combined = Uint8List.fromList([...oversizedHeader, ...validFrame]); codec.addBytes(combined); await Future.delayed(Duration.zero); diff --git a/test/services/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart index 926a723b..43b755a0 100644 --- a/test/services/wire_tag_codec_test.dart +++ b/test/services/wire_tag_codec_test.dart @@ -47,7 +47,8 @@ void main() { group('key fallback', () { test('null key == empty-string key, and both differ from a real key', () { const sid = 'AAR-20260611-0123'; - expect(WireTagCodec.encode(sid, 5, null), WireTagCodec.encode(sid, 5, '')); + expect( + WireTagCodec.encode(sid, 5, null), WireTagCodec.encode(sid, 5, '')); expect(WireTagCodec.encode(sid, 5, null), isNot(WireTagCodec.encode(sid, 5, key))); }); @@ -83,7 +84,10 @@ void main() { test('boundary dates and the 14-bit NNNN / 11-bit counter extremes', () { final cases = <(String, int)>[ ('AAA-20200101-0001', 1), // epoch floor of the date field (year 2020) - ('ZZZ-20831231-9999', 2047), // ceilings: year 2083, NNNN 9999, counter 2047 + ( + 'ZZZ-20831231-9999', + 2047 + ), // ceilings: year 2083, NNNN 9999, counter 2047 ('PAE-20260101-0007', 1), ('PAE-20261231-0007', 2047), ('JKG-20260229-0009', 100), // leap day @@ -130,7 +134,8 @@ void main() { ('YOW-20260504-0005', 1): 'MM:q2REy6j1xQ', ('ZZZ-20260101-9999', 2047): 'MM:tbOGo9kJHg', ('PAE-20260620-0007', 18): 'MM:YPKG3YBefw', - ('PAE-20260621-0007', 18): 'MM:gCXS1s-0ew', // same NNNN/counter, next day → different tag + ('PAE-20260621-0007', 18): + 'MM:gCXS1s-0ew', // same NNNN/counter, next day → different tag ('AAA-20200101-0001', 1): 'MM:mcvZkYjWyw', ('ZZZ-20831231-9999', 2047): 'MM:lT-SF6aZAw', }; diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart index 535ba335..4ec96ec4 100644 --- a/test/utils/coverage_summary_test.dart +++ b/test/utils/coverage_summary_test.dart @@ -210,7 +210,12 @@ void main() { test('no matches yields zeros + null range', () { final none = RepeaterStats.fromCoverage( const [ - {'status': 1, 'lat': 40.0, 'lon': -70.0, 'heard_repeats': 'ff99(9)[40,-70]'}, + { + 'status': 1, + 'lat': 40.0, + 'lon': -70.0, + 'heard_repeats': 'ff99(9)[40,-70]' + }, ], target, lookup, @@ -224,7 +229,8 @@ void main() { // 100 m Detailed grid steps (kCoverageGridSteps[100]). const latStep = 0.0009, lonStep = 0.00128; - test('taps anywhere in a cell resolve to the same cell; centre is inside', () { + test('taps anywhere in a cell resolve to the same cell; centre is inside', + () { final cell = GridCell.containing(45.26970, -75.77795, latStep, lonStep); final a = GridCell.containing(cell.centerLat, cell.centerLon, latStep, lonStep); @@ -241,7 +247,10 @@ void main() { final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); final pts = >[ {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell (num) - {'lat': '${cell.centerLat}', 'lon': '${cell.centerLon}'}, // in cell (string) + { + 'lat': '${cell.centerLat}', + 'lon': '${cell.centerLon}' + }, // in cell (string) { 'lat': (cell.i + 3) * latStep + latStep * 0.5, 'lon': cell.centerLon @@ -288,7 +297,8 @@ void main() { expect(cell.filterWithinBlob(pts, 0).length, 1); }); - test('blob fetch radius reaches the ±blob block corner, floored at gridSize', + test( + 'blob fetch radius reaches the ±blob block corner, floored at gridSize', () { // Detailed: blob=1, 100 m floor → must exceed 100 m and reach the block's // far corner (~212 m here) so blob-neighbour pings get fetched. @@ -414,7 +424,8 @@ void main() { }); test('picks the highest-priority (lowest st) across a mix', () { - expect(dominantCoverageStatus([p(2), p(6), p(3)]), 2); // orange/cyan/grey -> cyan + expect(dominantCoverageStatus([p(2), p(6), p(3)]), + 2); // orange/cyan/grey -> cyan }); }); } From 671f3d849f0e2411aadf0b89172b0236bfcc180c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 20 Aug 2026 21:33:09 -0400 Subject: [PATCH 276/360] Remove web deploy, focus on Android and iOS --- .github/workflows/deploy.yml | 46 ------------------------------------ CNAME | 1 - CONTRIBUTING.md | 2 ++ DEVELOPMENT.md | 2 +- README.md | 3 +-- 5 files changed, 4 insertions(+), 50 deletions(-) delete mode 100644 .github/workflows/deploy.yml delete mode 100644 CNAME diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index e9ea065b..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy to GitHub Pages - -on: - push: - branches: [main, dev] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment. outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - - uses: subosito/flutter-action@v2 - with: - channel: 'stable' - - - name: Generate build version - id: version - run: echo "APP_VERSION=APP-$(date +%s)" >> $GITHUB_OUTPUT - - - run: flutter pub get - - run: dart run build_runner build --delete-conflicting-outputs - - run: flutter build web --release --base-href "/" --dart-define=API_KEY=${{ secrets.API_KEY }} --dart-define=APP_VERSION=${{ steps.version.outputs.APP_VERSION }} - - - uses: actions/configure-pages@v4 - - - uses: actions/upload-pages-artifact@v3 - with: - path: build/web - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/CNAME b/CNAME deleted file mode 100644 index e4ba2a27..00000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -wd.meshmapper.net \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c37c69b..a46f5132 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,8 @@ Without an API key, the app will function normally for UI work, BLE connectivity ### Web Development +> **Note:** The web app is no longer built or published — development focuses on Android and iOS. The web code remains in the repo, so these instructions still work if you need to run the web target locally. + For local web development with CORS issues: ```bash flutter run -d chrome --web-browser-flag="--disable-web-security" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index eefd537a..66aeadc8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -4,7 +4,7 @@ This document provides comprehensive architecture documentation and development ## Project Overview -MeshMapper Flutter App is a cross-platform wardriving application for MeshCore mesh network devices. It's a Flutter port of the [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient), supporting Android, iOS, and Web (Chrome/Edge only). +MeshMapper Flutter App is a cross-platform wardriving application for MeshCore mesh network devices. It's a Flutter port of the [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient), supporting Android and iOS. The web (Chrome/Edge) target's code is retained in the codebase but the web app is no longer built or published. **Purpose**: Connect to MeshCore devices via Bluetooth Low Energy, send GPS-tagged pings to the `#wardriving` channel, track repeater echoes, and post coverage data to the MeshMapper API for community mesh mapping. diff --git a/README.md b/README.md index 5f46dd08..f7951a3d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Built with contributions by **The Greater Ottawa Mesh Radio Enthusiasts** **App Store Releases** - **Android:** [Google Play](https://play.google.com/store/apps/details?id=net.meshmapper.app) or grab the [APK from GitHub](https://github.com/MeshMapper/MeshMapper_Project/releases/) - **iOS:** [App Store](https://apps.apple.com/us/app/meshmapper/id6758073991) -- **Web:** [wd.meshmapper.net](https://wd.meshmapper.net) (Chrome/Edge only) **Beta Releases** - **iOS:** [TestFlight](https://testflight.apple.com/join/PXxfr5Jr) @@ -27,7 +26,7 @@ Built with contributions by **The Greater Ottawa Mesh Radio Enthusiasts** ## Features -- **Cross-Platform** — Android, iOS, and Web (Chrome/Edge) +- **Cross-Platform** — Android and iOS - **BLE Connectivity** — Connect to MeshCore companion devices via Bluetooth Low Energy - **GPS Tracking** — High-accuracy location tracking with 50m distance filter - **Real-time Map** — View TX/RX/DISC ping markers on OpenStreetMap with dark mode tiles From b09ebac0ce770990082e6876899ae73fdb79d166 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 02:51:14 -0400 Subject: [PATCH 277/360] Add deep-link and secure-storage packages for portal sign-in --- .../plugins/GeneratedPluginRegistrant.java | 10 ++ pubspec.lock | 116 +++++++++++++++--- pubspec.yaml | 5 + 3 files changed, 117 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index b8be97d2..91adcc02 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e); + } try { flutterEngine.getPlugins().add(new com.ryanheise.audio_session.AudioSessionPlugin()); } catch (Exception e) { @@ -50,6 +55,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); + } try { flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin()); } catch (Exception e) { diff --git a/pubspec.lock b/pubspec.lock index c63f0b25..41e08b26 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.1" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -133,10 +165,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -446,6 +478,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -544,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hive: dependency: "direct main" description: @@ -628,10 +716,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: transitive description: @@ -731,26 +819,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1160,10 +1248,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" timezone: dependency: transitive description: @@ -1357,5 +1445,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index adba3007..05cf8741 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,11 @@ dependencies: fl_chart: ^0.69.0 web: ^1.1.0 disk_space_plus: ^0.2.3 + # Custom-scheme deep links for the MyMeshMapper portal sign-in callback + # (meshmapper-auth://callback). uni_links was rejected — unmaintained. + app_links: ^6.3.0 + # Keychain / Android Keystore storage for the portal app token + flutter_secure_storage: ^9.2.0 dev_dependencies: flutter_test: From b2b7fbd64c06f4b49a0ebcc32ddc7e3808f44807 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 02:58:48 -0400 Subject: [PATCH 278/360] Add the PKCE pair the portal sign-in handshake needs --- lib/utils/pkce.dart | 58 +++++++++++++++++++++++++++++++ test/utils/pkce_test.dart | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 lib/utils/pkce.dart create mode 100644 test/utils/pkce_test.dart diff --git a/lib/utils/pkce.dart b/lib/utils/pkce.dart new file mode 100644 index 00000000..7e889da9 --- /dev/null +++ b/lib/utils/pkce.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// A PKCE verifier/challenge pair plus an independent OAuth `state`, generated +/// for one MyMeshMapper portal sign-in attempt (RFC 7636, S256 only). +/// +/// The portal REQUIRES `code_challenge_method=S256` and rejects a challenge +/// that is not exactly 43 base64url characters, so both values are emitted +/// unpadded. `state` is generated separately from the verifier so that a +/// leaked callback URL (which carries `state`) reveals nothing about the +/// verifier that will redeem the code. +class PkcePair { + /// 43-char base64url (32 random bytes), sent only in the `token` exchange. + final String verifier; + + /// base64url(SHA-256(ASCII(verifier))), sent in the authorize URL. + final String challenge; + + /// 22-char base64url (16 random bytes), echoed back by the portal. + final String state; + + const PkcePair({ + required this.verifier, + required this.challenge, + required this.state, + }); + + /// Generate a fresh pair. [random] exists for tests only — production always + /// uses `Random.secure()`. + static PkcePair generate({Random? random}) { + final rng = random ?? Random.secure(); + final verifier = _base64UrlNoPad(_randomBytes(rng, 32)); + return PkcePair( + verifier: verifier, + challenge: challengeFor(verifier), + state: _base64UrlNoPad(_randomBytes(rng, 16)), + ); + } + + /// RFC 7636 §4.2: `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`. + static String challengeFor(String verifier) => _base64UrlNoPad( + Uint8List.fromList(sha256.convert(ascii.encode(verifier)).bytes), + ); + + static Uint8List _randomBytes(Random rng, int count) { + final bytes = Uint8List(count); + for (var i = 0; i < count; i++) { + bytes[i] = rng.nextInt(256); + } + return bytes; + } + + static String _base64UrlNoPad(Uint8List bytes) => + base64UrlEncode(bytes).replaceAll('=', ''); +} diff --git a/test/utils/pkce_test.dart b/test/utils/pkce_test.dart new file mode 100644 index 00000000..b6bbf901 --- /dev/null +++ b/test/utils/pkce_test.dart @@ -0,0 +1,73 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/pkce.dart'; + +/// PKCE contract for the MyMeshMapper portal sign-in lane. The server rejects +/// anything that is not S256 and not exactly 43 base64url chars, so the shape +/// assertions below are the wire contract, not style preferences. +void main() { + group('challengeFor', () { + test('matches the RFC 7636 Appendix B vector', () { + expect( + PkcePair.challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'), + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ); + }); + + test('is deterministic', () { + expect(PkcePair.challengeFor('abc'), PkcePair.challengeFor('abc')); + }); + }); + + group('generate', () { + test('verifier is 43 base64url chars with no padding', () { + final pair = PkcePair.generate(); + expect(pair.verifier.length, 43); + expect(pair.verifier.contains('='), isFalse); + expect(RegExp(r'^[A-Za-z0-9._~-]+$').hasMatch(pair.verifier), isTrue); + expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(pair.verifier), isTrue); + }); + + test('challenge is 43 base64url chars with no padding', () { + final pair = PkcePair.generate(); + expect(pair.challenge.length, 43); + expect(pair.challenge.contains('='), isFalse); + expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(pair.challenge), isTrue); + }); + + test('challenge is the S256 of the verifier it shipped with', () { + final pair = PkcePair.generate(); + expect(pair.challenge, PkcePair.challengeFor(pair.verifier)); + }); + + test('state is an independent 22-char base64url value', () { + final pair = PkcePair.generate(); + expect(pair.state.length, 22); + expect(pair.state.contains('='), isFalse); + // Must satisfy the server's state charset: [A-Za-z0-9._~-]{1,128} + expect(RegExp(r'^[A-Za-z0-9._~-]{1,128}$').hasMatch(pair.state), isTrue); + expect(pair.state, isNot(pair.verifier)); + }); + + test('successive pairs are unique', () { + final verifiers = {}; + final states = {}; + for (var i = 0; i < 50; i++) { + final pair = PkcePair.generate(); + verifiers.add(pair.verifier); + states.add(pair.state); + } + expect(verifiers.length, 50); + expect(states.length, 50); + }); + + test('an injected Random makes generation reproducible', () { + final a = PkcePair.generate(random: Random(42)); + final b = PkcePair.generate(random: Random(42)); + expect(a.verifier, b.verifier); + expect(a.challenge, b.challenge); + expect(a.state, b.state); + }); + }); +} From 130ba4a042b64193840e973c7fc53e8b37627f7c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 03:06:06 -0400 Subject: [PATCH 279/360] Teach the radio connection to sign a nonce --- lib/services/meshcore/connection.dart | 184 +++++++++++++++++- .../meshcore/fake_companion_transport.dart | 62 ++++++ test/services/meshcore/sign_flow_test.dart | 112 +++++++++++ 3 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 test/services/meshcore/fake_companion_transport.dart create mode 100644 test/services/meshcore/sign_flow_test.dart diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 9da3926a..1fed30a8 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -99,6 +99,25 @@ class SelfInfo { } } +/// Thrown by [MeshCoreConnection.sign] for protocol-level sign failures. +/// +/// [code] is stable and is what callers branch on: +/// * `unsupported` — the radio answered CMD_SIGN_START with ERR (old firmware) +/// * `data_too_long` — payload exceeds the radio's `maxSignDataLen` +/// * `malformed_response` — a truncated RESP_SIGN_START / RESP_SIGNATURE frame +/// * `bad_signature_length` — the radio returned something other than 64 bytes +/// * `err` — the radio answered ERR mid-sign +/// * `aborted` — the connection closed while a sign was in flight +class SignException implements Exception { + final String code; + final String message; + + const SignException(this.code, this.message); + + @override + String toString() => 'SignException($code): $message'; +} + /// MeshCore connection manager /// Ported from content/mc/connection/connection.js in WebClient repo /// @@ -143,6 +162,15 @@ class MeshCoreConnection { Completer? _exportContactCompleter; Completer? _getTimeCompleter; + // CMD_SIGN state. `_signGate` is non-null for the whole duration of a sign; + // Task-4's write funnel queues every non-sign frame behind it so no other + // command's OK can be mistaken for a per-chunk sign ack. + bool _signInProgress = false; + Completer? _signStartCompleter; // resolves with maxSignDataLen + Completer? _signChunkOkCompleter; // resolves on each chunk's OK + Completer? _signatureCompleter; // resolves with the 64-byte sig + Completer? _signGate; + // Device self info (contains public key) SelfInfo? _selfInfo; @@ -454,10 +482,21 @@ class MeshCoreConnection { switch (responseCode) { case ResponseCodes.ok: - debugLog('[CONN] Received OK response'); - _setTimeCompleter?.complete(); - _setTimeCompleter = null; - break; + { + debugLog('[CONN] Received OK response'); + // A pending sign owns the bare OK: while a sign runs, the write + // gate has queued every other OK-emitting command, so nothing else + // can be waiting on one. + final signOk = _signChunkOkCompleter; + if (signOk != null) { + _signChunkOkCompleter = null; + if (!signOk.isCompleted) signOk.complete(); + break; + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } case ResponseCodes.err: final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; @@ -530,6 +569,45 @@ class MeshCoreConnection { case ResponseCodes.exportContact: _onExportContactResponse(reader); break; + case ResponseCodes.signStart: + { + final completer = _signStartCompleter; + _signStartCompleter = null; + if (completer == null || completer.isCompleted) { + debugLog('[CONN] Ignoring unsolicited SIGN_START response'); + break; + } + // [reserved:1][maxSignDataLen:u32 LE] + if (reader.remainingBytesCount < 5) { + completer.completeError(const SignException('malformed_response', + 'SIGN_START response is shorter than 5 bytes')); + break; + } + reader.readByte(); // reserved + completer.complete(reader.readUInt32LE()); + break; + } + case ResponseCodes.signature: + { + final completer = _signatureCompleter; + _signatureCompleter = null; + if (completer == null || completer.isCompleted) { + debugLog('[CONN] Ignoring unsolicited SIGNATURE response'); + break; + } + // Guard BEFORE readBytes(64): a short frame must fast-fail here, + // otherwise it becomes a RangeError swallowed by the outer catch + // and the caller waits out the full 5s timeout for nothing. + if (reader.remainingBytesCount < 64) { + completer.completeError(SignException( + 'malformed_response', + 'SIGNATURE response carries ${reader.remainingBytesCount} ' + 'bytes, expected 64')); + break; + } + completer.complete(reader.readBytes(64)); + break; + } default: // Log unhandled response codes (like JS implementation) debugLog( @@ -847,9 +925,16 @@ class MeshCoreConnection { } } + /// The ONE outbound funnel. Every frame this class sends goes through here so + /// there is a single place to serialize writes (see the sign gate) — a second + /// path would silently escape it. + Future _write(Uint8List bytes) async { + await _transport.write(bytes); + } + /// Write frame to device Future _sendToRadio(BufferWriter data) async { - await _transport.write(data.toBytes()); + await _write(data.toBytes()); } // ============================================ @@ -987,7 +1072,7 @@ class MeshCoreConnection { final bytes = data.toBytes(); debugLog( '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); - await _transport.write(bytes); + await _write(bytes); return future.timeout( const Duration(seconds: 5), @@ -1226,6 +1311,93 @@ class MeshCoreConnection { ); } + /// Ask the radio to Ed25519-sign [data] with its device private key. + /// + /// Framing (byte-identical to the portal's meshcore.js, which minted every + /// existing `portal_pubkeys` row): + /// CMD_SIGN_START (0x21) -> RESP_SIGN_START (19) + /// CMD_SIGN_DATA (0x22) + <=128 data bytes -> OK (0x00), one per chunk + /// CMD_SIGN_FINISH (0x23) -> RESP_SIGNATURE (20) [64] + /// + /// Sign the RAW bytes, never their hex text. Throws [SignException] for + /// protocol failures, [TimeoutException] when the radio goes quiet, and + /// [StateError] when the connection is disposed or a sign is already running. + Future sign(Uint8List data, + {Duration timeout = const Duration(seconds: 5)}) async { + if (_disposed) { + throw StateError('Cannot sign on a disposed connection'); + } + if (_signInProgress) { + throw StateError('A sign is already in progress'); + } + + _signInProgress = true; + final gate = Completer(); + _signGate = gate; + debugLog('[CONN] sign: starting (${data.length} bytes)'); + + try { + // 1) SIGN_START -> maxSignDataLen + final startCompleter = Completer(); + _signStartCompleter = startCompleter; + final startFrame = BufferWriter()..writeByte(CommandCodes.signStart); + await _write(startFrame.toBytes()); + final maxSignDataLen = await startCompleter.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('sign: SIGN_START timed out'), + ); + debugLog('[CONN] sign: maxSignDataLen=$maxSignDataLen'); + + if (data.length > maxSignDataLen) { + throw SignException('data_too_long', + 'Payload is ${data.length} bytes, radio accepts $maxSignDataLen'); + } + + // 2) SIGN_DATA chunks, one OK each + final chunkSize = min(128, maxSignDataLen); + for (var offset = 0; offset < data.length; offset += chunkSize) { + final end = min(offset + chunkSize, data.length); + final okCompleter = Completer(); + _signChunkOkCompleter = okCompleter; + final chunkFrame = BufferWriter() + ..writeByte(CommandCodes.signData) + ..writeBytes(data.sublist(offset, end)); + await _write(chunkFrame.toBytes()); + await okCompleter.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('sign: chunk ack timed out'), + ); + debugLog('[CONN] sign: chunk acked (${end - offset} bytes)'); + } + + // 3) SIGN_FINISH -> signature + final sigCompleter = Completer(); + _signatureCompleter = sigCompleter; + final finishFrame = BufferWriter()..writeByte(CommandCodes.signFinish); + await _write(finishFrame.toBytes()); + final signature = await sigCompleter.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('sign: signature timed out'), + ); + + if (signature.length != 64) { + throw SignException('bad_signature_length', + 'Radio returned ${signature.length} bytes, expected 64'); + } + debugLog('[CONN] sign: signature received'); + return signature; + } finally { + // Clear on EVERY path (success, throw, timeout) or the next sign + // inherits a stale completer and hangs. + _signStartCompleter = null; + _signChunkOkCompleter = null; + _signatureCompleter = null; + _signInProgress = false; + if (identical(_signGate, gate)) _signGate = null; + if (!gate.isCompleted) gate.complete(); + } + } + /// Get radio statistics (noise floor) /// Reference: sendCommandGetStats in connection.js Future getStats(int statsType) async { diff --git a/test/services/meshcore/fake_companion_transport.dart b/test/services/meshcore/fake_companion_transport.dart new file mode 100644 index 00000000..35992d4f --- /dev/null +++ b/test/services/meshcore/fake_companion_transport.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:mesh_mapper/models/connection_state.dart'; +import 'package:mesh_mapper/services/bluetooth/bluetooth_service.dart'; +import 'package:mesh_mapper/services/transport/companion_transport.dart'; + +/// In-memory [CompanionTransport] for protocol tests. +/// +/// `MeshCoreConnection` does all of its dispatching off `transport.dataStream` +/// and all of its sending through `transport.write`, so a fake at this seam +/// exercises the real framing code with no radio and no platform channels. +class FakeCompanionTransport implements CompanionTransport { + final StreamController _data = + StreamController.broadcast(); + final StreamController _status = + StreamController.broadcast(); + + /// Every frame the connection has written, oldest first. + final List writes = []; + + /// Set true to make [write] throw, simulating a dead link. + bool failWrites = false; + + @override + Stream get dataStream => _data.stream; + + @override + Stream get connectionStream => _status.stream; + + @override + ConnectionStatus get connectionStatus => ConnectionStatus.connected; + + @override + DiscoveredDevice? get connectedDevice => + const DiscoveredDevice(id: 'fake-transport', name: 'FakeRadio'); + + @override + Future write(Uint8List data) async { + if (failWrites) throw StateError('fake transport link is down'); + writes.add(Uint8List.fromList(data)); + } + + @override + Future disconnect() async {} + + @override + void dispose() { + _data.close(); + _status.close(); + } + + /// Push a frame from the "radio" into the connection's dispatcher. + void emit(List frame) => _data.add(Uint8List.fromList(frame)); + + /// Let the broadcast stream deliver and any awaiting futures resume. + Future settle() => Future.delayed(Duration.zero); + + /// The command byte of the nth write (or -1 when there is no nth write). + int commandAt(int index) => + index < writes.length && writes[index].isNotEmpty ? writes[index][0] : -1; +} diff --git a/test/services/meshcore/sign_flow_test.dart b/test/services/meshcore/sign_flow_test.dart new file mode 100644 index 00000000..a7bff443 --- /dev/null +++ b/test/services/meshcore/sign_flow_test.dart @@ -0,0 +1,112 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/meshcore/connection.dart'; +import 'package:mesh_mapper/services/meshcore/protocol_constants.dart'; + +import 'fake_companion_transport.dart'; + +/// CMD_SIGN framing contract (mirrors the portal's meshcore.js, which is what +/// today's 1,475 live companion links were made with): +/// [0x21] -> RESP 19 [0x13][reserved:1][maxSignDataLen:u32 LE] +/// [0x22][<=128 bytes] -> OK [0x00] (one per chunk) +/// [0x23] -> RESP 20 [0x14][sig:64] +void main() { + late FakeCompanionTransport transport; + late MeshCoreConnection connection; + + setUp(() { + transport = FakeCompanionTransport(); + connection = MeshCoreConnection(transport: transport); + }); + + tearDown(() { + connection.dispose(); + transport.dispose(); + }); + + /// RESP_SIGN_START carrying [maxLen]. + List signStartResponse(int maxLen) => [ + ResponseCodes.signStart, + 0x00, // reserved + maxLen & 0xFF, + (maxLen >> 8) & 0xFF, + (maxLen >> 16) & 0xFF, + (maxLen >> 24) & 0xFF, + ]; + + /// RESP_SIGNATURE carrying 64 bytes of [fill]. + List signatureResponse(int fill) => + [ResponseCodes.signature, ...List.filled(64, fill)]; + + Uint8List nonce32() => + Uint8List.fromList(List.generate(32, (i) => i + 1)); + + test('signs a 32-byte nonce in one chunk', () async { + final future = connection.sign(nonce32()); + + // 1) CMD_SIGN_START goes out first. + await transport.settle(); + expect(transport.writes.length, 1); + expect(transport.commandAt(0), CommandCodes.signStart); + expect(transport.writes[0].length, 1); + + transport.emit(signStartResponse(128)); + await transport.settle(); + + // 2) One CMD_SIGN_DATA frame carrying the raw 32 bytes. + expect(transport.writes.length, 2); + expect(transport.commandAt(1), CommandCodes.signData); + expect(transport.writes[1].sublist(1), nonce32()); + + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + // 3) CMD_SIGN_FINISH. + expect(transport.writes.length, 3); + expect(transport.commandAt(2), CommandCodes.signFinish); + expect(transport.writes[2].length, 1); + + transport.emit(signatureResponse(0xAB)); + + final signature = await future; + expect(signature.length, 64); + expect(signature.every((b) => b == 0xAB), isTrue); + }); + + test('a second sign works after the first one finished', () async { + Future runSign() async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x11)); + return future; + } + + expect((await runSign()).length, 64); + transport.writes.clear(); + expect((await runSign()).length, 64); + }); + + test('a concurrent sign is refused instead of interleaving', () async { + final first = connection.sign(nonce32()); + await transport.settle(); + + expect(() => connection.sign(nonce32()), throwsA(isA())); + + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x22)); + expect((await first).length, 64); + }); + + test('signing on a disposed connection throws StateError', () { + connection.dispose(); + expect(() => connection.sign(nonce32()), throwsA(isA())); + }); +} From e38a3410b349abaa7adf112cdaf4d9eb77ecaad3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 03:15:59 -0400 Subject: [PATCH 280/360] Keep the signature out of the frame hexdump --- lib/services/meshcore/connection.dart | 13 +++-- test/services/meshcore/sign_flow_test.dart | 61 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 1fed30a8..306db6a9 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -470,9 +470,16 @@ class MeshCoreConnection { void _onFrameReceived(Uint8List frame) { if (frame.isEmpty) return; + // A RESP_SIGNATURE payload is 64 bytes of Ed25519 signature over the + // portal's login nonce, and debug logs are uploadable to the bug-report + // endpoint — so this one frame is logged by length only, never as hex. + // Every other frame keeps the full hexdump. + final frameDump = frame[0] == ResponseCodes.signature + ? 'SIGNATURE payload redacted' + : _hexDump(frame); + try { - debugLog( - '[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); + debugLog('[CONN] Frame received (${frame.length} bytes): $frameDump'); final reader = BufferReader(frame); final responseCode = reader.readByte(); @@ -616,7 +623,7 @@ class MeshCoreConnection { } } catch (e, stack) { debugError('[CONN] Error processing frame (${frame.length} bytes): $e'); - debugError('[CONN] Frame hex: ${_hexDump(frame)}'); + debugError('[CONN] Frame hex: $frameDump'); debugError('[CONN] Stack trace: $stack'); } } diff --git a/test/services/meshcore/sign_flow_test.dart b/test/services/meshcore/sign_flow_test.dart index a7bff443..3c5ede4e 100644 --- a/test/services/meshcore/sign_flow_test.dart +++ b/test/services/meshcore/sign_flow_test.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/meshcore/connection.dart'; import 'package:mesh_mapper/services/meshcore/protocol_constants.dart'; +import 'package:mesh_mapper/utils/debug_logger_io.dart'; import 'fake_companion_transport.dart'; @@ -109,4 +111,63 @@ void main() { connection.dispose(); expect(() => connection.sign(nonce32()), throwsA(isA())); }); + + test('the signature never reaches the debug log', () async { + // Debug logs are uploadable to the bug-report endpoint, so the 64-byte + // Ed25519 signature must not appear in one — not even via the generic + // per-frame hexdump that runs before the dispatch switch. + final logLines = []; + final originalDebugPrint = debugPrint; + final originalEnabled = DebugLogger.isEnabled; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logLines.add(message); + }; + DebugLogger.setEnabled(true); + addTearDown(() { + debugPrint = originalDebugPrint; + DebugLogger.setEnabled(originalEnabled); + }); + + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0xAB)); + expect((await future).length, 64); + + // The harness actually captured something, or the assertions below are + // vacuously true. + expect(logLines, isNotEmpty); + + // No line carries the signature bytes in any quantity. + expect( + logLines.where((line) => line.contains('ab ab ab')), + isEmpty, + reason: 'signature bytes were hex-dumped into the debug log', + ); + expect(logLines.where((line) => line.contains('ab ab')), isEmpty); + + // The frame is still logged — by length, with the payload redacted. + expect( + logLines.where((line) => + line.contains('Frame received (65 bytes)') && + line.contains('SIGNATURE payload redacted')), + isNotEmpty, + ); + + // Ordinary frames are untouched: the SIGN_START response and the bare OK + // still hexdump in full. + expect( + logLines.where((line) => + line.contains('Frame received (6 bytes): 13 00 80 00 00 00')), + isNotEmpty, + reason: 'non-signature frames must keep the existing hexdump', + ); + expect( + logLines.where((line) => line.contains('Frame received (1 bytes): 00')), + isNotEmpty, + ); + }); } From 0e9c305062f97d7d97b550d447cfb8f3bd85f5dc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 03:31:08 -0400 Subject: [PATCH 281/360] Stop a stray command stealing the sign handshake's ack --- lib/services/meshcore/connection.dart | 94 +++++++- test/services/meshcore/sign_flow_test.dart | 237 +++++++++++++++++++++ 2 files changed, 321 insertions(+), 10 deletions(-) diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 306db6a9..2201d009 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -407,6 +407,9 @@ class MeshCoreConnection { /// Delete wardriving channel early (before stopping services) /// This should be called FIRST in the disconnect flow to ensure BLE is still connected Future deleteWardrivingChannelEarly() async { + // Channel deletion is a gated write; a live sign would park it behind the + // sign timeout while BLE is still up. Abort the sign first. + _abortPendingSign(); final channel = _wardrivingChannel; if (channel != null) { await ChannelService.deleteWardrivingChannel(this, channel.channelIndex); @@ -417,6 +420,7 @@ class MeshCoreConnection { Future disconnect() async { try { debugLog('[CONN] Disconnecting'); + _abortPendingSign(); // Stop noise floor polling _stopNoiseFloorPolling(); @@ -491,9 +495,11 @@ class MeshCoreConnection { case ResponseCodes.ok: { debugLog('[CONN] Received OK response'); - // A pending sign owns the bare OK: while a sign runs, the write - // gate has queued every other OK-emitting command, so nothing else - // can be waiting on one. + // A pending sign owns the bare OK. The write gate keeps every + // OK-emitting command *issued during* the sign off the wire, but it + // cannot recall one that was already awaiting its OK when the sign + // began. That is safe only because the sole other bare-OK consumer + // is setDeviceTime, which runs once during connect(). final signOk = _signChunkOkCompleter; if (signOk != null) { _signChunkOkCompleter = null; @@ -508,6 +514,17 @@ class MeshCoreConnection { final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); + // A pending sign owns this ERR: the write gate has queued every other + // command, so nothing else can be in flight. Without this the + // old-firmware feature detect hangs for the full sign timeout. + if (_failPendingSign( + SignException('err', + 'Radio returned ERR during sign (error code $errorCode)'), + startError: SignException('unsupported', + 'Radio rejected CMD_SIGN_START (error code $errorCode)'), + )) { + break; + } // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success if (_setTimeCompleter != null) { if (errorCode == 6) { @@ -633,6 +650,47 @@ class MeshCoreConnection { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); } + /// Complete every pending sign completer with an error. The SIGN_START + /// completer gets [startError] when supplied, so an ERR at that stage reads + /// as "this firmware has no CMD_SIGN" rather than a generic failure. + /// + /// Returns true when a sign was actually pending, so the caller can treat the + /// frame as consumed. + bool _failPendingSign(SignException error, {SignException? startError}) { + final start = _signStartCompleter; + final chunk = _signChunkOkCompleter; + final signature = _signatureCompleter; + if (start == null && chunk == null && signature == null) return false; + + _signStartCompleter = null; + _signChunkOkCompleter = null; + _signatureCompleter = null; + + if (start != null && !start.isCompleted) { + start.completeError(startError ?? error); + } + if (chunk != null && !chunk.isCompleted) chunk.completeError(error); + if (signature != null && !signature.isCompleted) { + signature.completeError(error); + } + return true; + } + + /// Tear down an in-flight sign on disconnect/dispose. + /// + /// Releasing the gate here is what keeps disconnect fast: the wardriving + /// channel deletion is a normal (gated) write, so leaving the gate closed + /// would park it behind the sign's 5s timeout while the link is dying. + void _abortPendingSign() { + final wasPending = _failPendingSign( + const SignException('aborted', 'Connection closed during sign')); + final gate = _signGate; + _signGate = null; + _signInProgress = false; + if (gate != null && !gate.isCompleted) gate.complete(); + if (wasPending) debugLog('[CONN] Aborted in-flight sign'); + } + void _onDeviceInfoResponse(BufferReader reader) { // Protocol format changed in v7/v8: // v1-v6: protoVer (1) + manufacturer C-string (64) + publicKey (32) @@ -932,10 +990,25 @@ class MeshCoreConnection { } } - /// The ONE outbound funnel. Every frame this class sends goes through here so - /// there is a single place to serialize writes (see the sign gate) — a second - /// path would silently escape it. - Future _write(Uint8List bytes) async { + /// The ONE outbound funnel. Every frame this class sends goes through here. + /// + /// While a sign is in flight, non-sign frames wait for it to finish. That + /// matters because CMD_SIGN_DATA is acked with a bare OK (0x00) and so are + /// setFloodScope, setChannel, setPathHashMode, setAdvertName and setTxPower — + /// letting one of those onto the wire mid-sign means its OK is consumed as + /// the chunk ack and the handshake desynchronises. Sign's own frames pass + /// [isSignFrame] and bypass the gate. + Future _write(Uint8List bytes, {bool isSignFrame = false}) async { + if (!isSignFrame) { + final gate = _signGate; + if (gate != null && !gate.isCompleted) { + final code = bytes.isNotEmpty ? bytes[0] : -1; + debugLog('[CONN] Queuing command $code behind an in-progress sign'); + // The gate future NEVER completes with an error, so a queued command + // is delayed, never failed. + await gate.future; + } + } await _transport.write(bytes); } @@ -1348,7 +1421,7 @@ class MeshCoreConnection { final startCompleter = Completer(); _signStartCompleter = startCompleter; final startFrame = BufferWriter()..writeByte(CommandCodes.signStart); - await _write(startFrame.toBytes()); + await _write(startFrame.toBytes(), isSignFrame: true); final maxSignDataLen = await startCompleter.future.timeout( timeout, onTimeout: () => throw TimeoutException('sign: SIGN_START timed out'), @@ -1369,7 +1442,7 @@ class MeshCoreConnection { final chunkFrame = BufferWriter() ..writeByte(CommandCodes.signData) ..writeBytes(data.sublist(offset, end)); - await _write(chunkFrame.toBytes()); + await _write(chunkFrame.toBytes(), isSignFrame: true); await okCompleter.future.timeout( timeout, onTimeout: () => throw TimeoutException('sign: chunk ack timed out'), @@ -1381,7 +1454,7 @@ class MeshCoreConnection { final sigCompleter = Completer(); _signatureCompleter = sigCompleter; final finishFrame = BufferWriter()..writeByte(CommandCodes.signFinish); - await _write(finishFrame.toBytes()); + await _write(finishFrame.toBytes(), isSignFrame: true); final signature = await sigCompleter.future.timeout( timeout, onTimeout: () => throw TimeoutException('sign: signature timed out'), @@ -1531,6 +1604,7 @@ class MeshCoreConnection { _disposed = true; _stopNoiseFloorPolling(); _stopBatteryPolling(); + _abortPendingSign(); _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); diff --git a/test/services/meshcore/sign_flow_test.dart b/test/services/meshcore/sign_flow_test.dart index 3c5ede4e..4d812d42 100644 --- a/test/services/meshcore/sign_flow_test.dart +++ b/test/services/meshcore/sign_flow_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show debugPrint; @@ -170,4 +171,240 @@ void main() { isNotEmpty, ); }); + + group('write gate', () { + test('a concurrent command cannot steal the chunk ack', () async { + final signFuture = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + + // The sign is now waiting for its chunk OK. A zone-transfer timer fires. + final floodFuture = + connection.setFloodScope(Uint8List.fromList(List.filled(16, 7))); + await transport.settle(); + + // The gate must have held it back: only SIGN_START and SIGN_DATA are out. + expect(transport.writes.length, 2); + expect(transport.commandAt(0), CommandCodes.signStart); + expect(transport.commandAt(1), CommandCodes.signData); + expect( + transport.writes.any((w) => w[0] == CommandCodes.setFloodScope), + isFalse, + reason: 'setFloodScope escaped the sign gate', + ); + + // The single OK on the wire belongs to the sign. + transport.emit([ResponseCodes.ok]); + await transport.settle(); + expect(transport.commandAt(2), CommandCodes.signFinish); + + transport.emit(signatureResponse(0x33)); + expect((await signFuture).length, 64); + + // Once the sign is done the queued command goes out. + await floodFuture; + expect(transport.commandAt(3), CommandCodes.setFloodScope); + }); + + test('queued commands are released in the order they were issued', + () async { + final signFuture = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + + final a = connection.setPathHashMode(1); + final b = connection.getBatteryVoltage(); + await transport.settle(); + expect(transport.writes.length, 2); + + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x44)); + await signFuture; + await a; + await b; + + expect(transport.commandAt(3), CommandCodes.setPathHashMode); + expect(transport.commandAt(4), CommandCodes.getBatteryVoltage); + }); + + test('the gate is released even when the sign fails', () async { + final signFuture = connection.sign(nonce32()); + await transport.settle(); + transport.emit([ResponseCodes.err, 5]); + + await expectLater(signFuture, throwsA(isA())); + + await connection.getBatteryVoltage(); + expect(transport.commandAt(1), CommandCodes.getBatteryVoltage); + }); + }); + + group('failure paths', () { + test('ERR at SIGN_START reports unsupported and does not hang', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit([ResponseCodes.err, 1]); + + await expectLater( + future, + throwsA( + isA().having((e) => e.code, 'code', 'unsupported')), + ); + }); + + test('ERR mid-sign reports err, not unsupported', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.err, 3]); + + await expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'err')), + ); + }); + + test('a 300-byte payload is sent as three chunks of 128/128/44', () async { + final payload = + Uint8List.fromList(List.generate(300, (i) => i & 0xFF)); + final future = connection.sign(payload); + await transport.settle(); + transport.emit(signStartResponse(1024)); + await transport.settle(); + + expect(transport.writes[1].length, 1 + 128); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + expect(transport.writes[2].length, 1 + 128); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + expect(transport.writes[3].length, 1 + 44); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + expect(transport.commandAt(4), CommandCodes.signFinish); + transport.emit(signatureResponse(0x55)); + expect((await future).length, 64); + }); + + test('a payload longer than maxSignDataLen is refused before chunking', + () async { + final payload = Uint8List.fromList(List.filled(64, 1)); + final future = connection.sign(payload); + await transport.settle(); + transport.emit(signStartResponse(32)); + + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', 'data_too_long')), + ); + expect(transport.writes.length, 1, reason: 'no chunk should be written'); + }); + + test('a short RESP_SIGNATURE fast-fails instead of timing out', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + // 20 bytes instead of 64. + transport.emit([ResponseCodes.signature, ...List.filled(20, 9)]); + + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', 'malformed_response')), + ); + }); + + test('a short RESP_SIGN_START fast-fails', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit([ResponseCodes.signStart, 0x00]); // missing the u32 + + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', 'malformed_response')), + ); + }); + + test('a sign that timed out leaves no state behind', () async { + final timedOut = + connection.sign(nonce32(), timeout: const Duration(milliseconds: 30)); + await expectLater(timedOut, throwsA(isA())); + + // A late response for the abandoned sign must be ignored, not crash. + transport.emit(signStartResponse(128)); + await transport.settle(); + + transport.writes.clear(); + final second = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x66)); + expect((await second).length, 64); + }); + + test('an interleaved battery push does not disturb the sign', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + + // Battery voltage response (0x0C) arriving mid-sign. + transport.emit([ResponseCodes.batteryVoltage, 0x10, 0x0F, 0x00, 0x00]); + await transport.settle(); + expect(transport.writes.length, 2, reason: 'sign must not advance'); + + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x77)); + expect((await future).length, 64); + }); + + test('disconnect aborts an in-flight sign', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + + // Attach the expectation BEFORE triggering the abort. The teardown paths + // reject the sign synchronously, and a future that errors while nothing + // is listening yet is reported to the zone as an unhandled error — which + // fails the test even though expectLater would match it a microtask + // later. The assertion itself is unchanged. + final aborted = expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'aborted')), + ); + + await connection.disconnect(); + await aborted; + }); + + test('deleteWardrivingChannelEarly is not blocked by a pending sign', + () async { + final future = connection.sign(nonce32()); + await transport.settle(); + + final aborted = expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'aborted')), + ); + + // Must return promptly, not sit behind the 5s sign timeout. + await connection.deleteWardrivingChannelEarly(); + await aborted; + }); + }); } From c9602cdc078875d54701660584d5f7dac6b59dfa Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 06:58:05 -0400 Subject: [PATCH 282/360] Let disconnect cut a sign loose without waiting on it --- lib/services/meshcore/connection.dart | 28 +++++++++++++++++++--- test/services/meshcore/sign_flow_test.dart | 27 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 2201d009..44df99e6 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -514,9 +514,24 @@ class MeshCoreConnection { final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); - // A pending sign owns this ERR: the write gate has queued every other - // command, so nothing else can be in flight. Without this the - // old-firmware feature detect hangs for the full sign timeout. + // A pending sign claims this ERR. Without it the old-firmware feature + // detect hangs for the full sign timeout. + // + // The claim is not airtight, and deliberately so. The write gate + // keeps every command *issued during* the sign off the wire, but it + // cannot recall one that was already awaiting its response when the + // sign began — and unlike the bare OK (whose only other consumer is + // setDeviceTime, once per connect), ERR has five: stats, channel + // info, device query, export contact and get time, two of them + // timer-driven (getStats every 5s, battery every 30s). So a foreign + // ERR that lands in that window is misattributed to the sign. + // + // Bounded, not dangerous: the foreign command still times out on its + // own (getStats carries its own 5s timeout), and the sign fails and + // is retried. The one verdict worth protecting is "this firmware has + // no CMD_SIGN" — the persistence layer requires two strikes before + // recording it, so a single misattributed ERR cannot condemn a radio + // that actually supports signing. if (_failPendingSign( SignException('err', 'Radio returned ERR during sign (error code $errorCode)'), @@ -691,6 +706,13 @@ class MeshCoreConnection { if (wasPending) debugLog('[CONN] Aborted in-flight sign'); } + /// Abort any in-flight sign immediately, releasing the write gate so + /// teardown-time writes (advert-name restore, path-hash restore, flood + /// scope, channel deletion) are not parked behind the sign timeout. + /// Safe to call when no sign is running. The provider calls this FIRST + /// in its disconnect sequence (wired in a later task). + void abortPendingSign() => _abortPendingSign(); + void _onDeviceInfoResponse(BufferReader reader) { // Protocol format changed in v7/v8: // v1-v6: protoVer (1) + manufacturer C-string (64) + publicKey (32) diff --git a/test/services/meshcore/sign_flow_test.dart b/test/services/meshcore/sign_flow_test.dart index 4d812d42..58e7c2f3 100644 --- a/test/services/meshcore/sign_flow_test.dart +++ b/test/services/meshcore/sign_flow_test.dart @@ -406,5 +406,32 @@ void main() { await connection.deleteWardrivingChannelEarly(); await aborted; }); + + test('abortPendingSign releases the gate for teardown-time writes', + () async { + final future = connection.sign(nonce32()); + await transport.settle(); + expect(transport.commandAt(0), CommandCodes.signStart); + + final aborted = expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'aborted')), + ); + + // What the provider will call FIRST in its disconnect sequence. + connection.abortPendingSign(); + await aborted; + + // A gated write now goes out immediately instead of waiting out the + // sign's 5s timeout — this is the whole point of the public hook. + await connection.getBatteryVoltage(); + expect(transport.commandAt(1), CommandCodes.getBatteryVoltage); + }); + + test('abortPendingSign is safe when no sign is running', () async { + connection.abortPendingSign(); + await connection.getBatteryVoltage(); + expect(transport.commandAt(0), CommandCodes.getBatteryVoltage); + }); }); } From f5fe95636eaecddd3b36b8c4675c50fd92da3ee6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 07:05:44 -0400 Subject: [PATCH 283/360] Keep the portal token where a restored backup can't break it --- lib/services/portal_token_store.dart | 200 +++++++++++++++++++++ test/services/portal_token_store_test.dart | 70 ++++++++ 2 files changed, 270 insertions(+) create mode 100644 lib/services/portal_token_store.dart create mode 100644 test/services/portal_token_store_test.dart diff --git a/lib/services/portal_token_store.dart b/lib/services/portal_token_store.dart new file mode 100644 index 00000000..4540cc04 --- /dev/null +++ b/lib/services/portal_token_store.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../utils/debug_logger_io.dart'; + +/// The half-finished PKCE handshake for one sign-in attempt. +/// +/// WHY this is PERSISTED rather than held in memory: on iOS the app is +/// routinely killed while the user is typing their portal password in Safari. +/// A memory-only verifier would fail the COMMON case, not an edge case. The +/// pair carries a 10-minute TTL and is burned after a single exchange attempt. +class PendingPkce { + final String verifier; + final String state; + final DateTime createdAt; + + const PendingPkce({ + required this.verifier, + required this.state, + required this.createdAt, + }); + + Map toJson() => { + 'verifier': verifier, + 'state': state, + 'created_at': createdAt.millisecondsSinceEpoch, + }; + + /// Returns null for anything unusable — a partially written or corrupted + /// blob must degrade to "no pending sign-in", never throw. + static PendingPkce? fromJson(Map json) { + final verifier = json['verifier']; + final state = json['state']; + final createdAt = json['created_at']; + if (verifier is! String || verifier.isEmpty) return null; + if (state is! String || state.isEmpty) return null; + if (createdAt is! int) return null; + return PendingPkce( + verifier: verifier, + state: state, + createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt), + ); + } +} + +/// Storage for the MyMeshMapper portal bearer token and the in-flight PKCE +/// pair. Split behind an interface so tests never touch the platform keystore. +abstract class PortalTokenStore { + Future readToken(); + Future writeToken(String token); + Future deleteToken(); + + Future readPendingPkce(); + Future writePendingPkce(PendingPkce pending); + Future deletePendingPkce(); +} + +/// Keychain (iOS) / Keystore-backed EncryptedSharedPreferences (Android). +class SecureTokenStore implements PortalTokenStore { + static const String tokenKey = 'portal_app_token'; + static const String pendingPkceKey = 'portal_pending_pkce'; + + /// Named explicitly so the Android backup-exclusion rules can point at the + /// exact file (`res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml`). + static const String androidPreferencesName = 'MeshMapperSecure'; + + static const IOSOptions _iosOptions = IOSOptions( + // The token must be readable by a background wardriving session on a + // locked phone, but must never ride an iCloud backup to another device. + accessibility: KeychainAccessibility.first_unlock_this_device, + ); + + static const AndroidOptions _androidOptions = AndroidOptions( + encryptedSharedPreferences: true, + sharedPreferencesName: androidPreferencesName, + resetOnError: true, + ); + + final FlutterSecureStorage _storage; + + SecureTokenStore({FlutterSecureStorage? storage}) + : _storage = storage ?? + const FlutterSecureStorage( + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + + /// Reads never throw. + /// + /// Android auto-backup restores the EncryptedSharedPreferences ciphertext + /// without the hardware Keystore key that wrapped it, so the first read after + /// a device transfer raises BadPaddingException. Nuking the store and + /// reporting "signed out" is the only sane recovery — a throw here would take + /// down `AppStateProvider._initialize()` and the whole app with it. + Future _readSafely(String key) async { + try { + return await _storage.read( + key: key, + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (e) { + debugWarn('[ACCOUNT] Secure storage read failed for "$key" ' + '(${e.runtimeType}) — resetting to signed-out'); + try { + await _storage.deleteAll( + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (deleteError) { + debugError('[ACCOUNT] Secure storage reset failed: ' + '${deleteError.runtimeType}'); + } + return null; + } + } + + Future _writeSafely(String key, String value) async { + try { + await _storage.write( + key: key, + value: value, + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (e) { + debugError( + '[ACCOUNT] Secure storage write failed for "$key" (${e.runtimeType})'); + } + } + + Future _deleteSafely(String key) async { + try { + await _storage.delete( + key: key, + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (e) { + debugWarn('[ACCOUNT] Secure storage delete failed for "$key" ' + '(${e.runtimeType})'); + } + } + + @override + Future readToken() => _readSafely(tokenKey); + + @override + Future writeToken(String token) => _writeSafely(tokenKey, token); + + @override + Future deleteToken() => _deleteSafely(tokenKey); + + @override + Future readPendingPkce() async { + final raw = await _readSafely(pendingPkceKey); + if (raw == null || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return PendingPkce.fromJson(decoded); + } catch (e) { + debugWarn('[ACCOUNT] Pending PKCE blob unreadable — discarding'); + await _deleteSafely(pendingPkceKey); + return null; + } + } + + @override + Future writePendingPkce(PendingPkce pending) => + _writeSafely(pendingPkceKey, jsonEncode(pending.toJson())); + + @override + Future deletePendingPkce() => _deleteSafely(pendingPkceKey); +} + +/// Test double. Also useful as a null-object on platforms with no keystore. +class InMemoryTokenStore implements PortalTokenStore { + String? token; + PendingPkce? pending; + + @override + Future readToken() async => token; + + @override + Future writeToken(String value) async => token = value; + + @override + Future deleteToken() async => token = null; + + @override + Future readPendingPkce() async => pending; + + @override + Future writePendingPkce(PendingPkce value) async => pending = value; + + @override + Future deletePendingPkce() async => pending = null; +} diff --git a/test/services/portal_token_store_test.dart b/test/services/portal_token_store_test.dart new file mode 100644 index 00000000..0d945bfa --- /dev/null +++ b/test/services/portal_token_store_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/portal_token_store.dart'; + +void main() { + group('PendingPkce', () { + test('round-trips through JSON', () { + final created = DateTime.fromMillisecondsSinceEpoch(1750000000000); + final original = PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'Zm9vYmFyYmF6cXV4MTIzNA', + createdAt: created, + ); + + final restored = PendingPkce.fromJson(original.toJson()); + + expect(restored, isNotNull); + expect(restored!.verifier, original.verifier); + expect(restored.state, original.state); + expect(restored.createdAt, created); + }); + + test('rejects a body with missing fields', () { + expect(PendingPkce.fromJson(const {'state': 'x'}), isNull); + expect(PendingPkce.fromJson(const {}), isNull); + }); + + test('rejects a body with a non-numeric timestamp', () { + expect( + PendingPkce.fromJson( + const {'verifier': 'v', 'state': 's', 'created_at': 'nope'}), + isNull, + ); + }); + }); + + group('InMemoryTokenStore', () { + late InMemoryTokenStore store; + + setUp(() => store = InMemoryTokenStore()); + + test('token write / read / delete', () async { + expect(await store.readToken(), isNull); + await store.writeToken('a' * 64); + expect(await store.readToken(), 'a' * 64); + await store.deleteToken(); + expect(await store.readToken(), isNull); + }); + + test('pending PKCE write / read / delete', () async { + expect(await store.readPendingPkce(), isNull); + final pending = PendingPkce( + verifier: 'v' * 43, + state: 's' * 22, + createdAt: DateTime.fromMillisecondsSinceEpoch(1750000000000), + ); + await store.writePendingPkce(pending); + expect((await store.readPendingPkce())!.verifier, 'v' * 43); + await store.deletePendingPkce(); + expect(await store.readPendingPkce(), isNull); + }); + }); + + group('key names are the wire contract', () { + test('keys are stable', () { + expect(SecureTokenStore.tokenKey, 'portal_app_token'); + expect(SecureTokenStore.pendingPkceKey, 'portal_pending_pkce'); + expect(SecureTokenStore.androidPreferencesName, 'MeshMapperSecure'); + }); + }); +} From 76cbd9b222edb0c7fd23ecffd39ed1d5ef29658c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 07:18:04 -0400 Subject: [PATCH 284/360] Cover the keystore failure paths with a throwing fake --- lib/services/portal_token_store.dart | 4 + test/services/portal_token_store_test.dart | 244 +++++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/lib/services/portal_token_store.dart b/lib/services/portal_token_store.dart index 4540cc04..d521bcbc 100644 --- a/lib/services/portal_token_store.dart +++ b/lib/services/portal_token_store.dart @@ -116,6 +116,8 @@ class SecureTokenStore implements PortalTokenStore { } } + /// Failures are swallowed by design — a completed write is NOT proof the + /// value was persisted; callers learn that only from a later null read. Future _writeSafely(String key, String value) async { try { await _storage.write( @@ -130,6 +132,8 @@ class SecureTokenStore implements PortalTokenStore { } } + /// Failures are swallowed by design — a completed delete is NOT proof the + /// value is gone; sign-out must never hard-fail on a flaky keystore. Future _deleteSafely(String key) async { try { await _storage.delete( diff --git a/test/services/portal_token_store_test.dart b/test/services/portal_token_store_test.dart index 0d945bfa..ada7e864 100644 --- a/test/services/portal_token_store_test.dart +++ b/test/services/portal_token_store_test.dart @@ -1,6 +1,100 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/portal_token_store.dart'; +/// A keystore stand-in that can be told to fail. +/// +/// `FlutterSecureStorage`'s read/write/delete/deleteAll are ordinary +/// overridable instance methods, so a subclass injected through +/// `SecureTokenStore({storage:})` exercises the real recovery paths with no +/// platform channel and no mocking package. +class _FakeSecureStorage extends FlutterSecureStorage { + _FakeSecureStorage({ + Map? values, + this.throwOnRead = false, + this.throwOnWrite = false, + this.throwOnDelete = false, + this.throwOnDeleteAll = false, + }) : values = values ?? {}; + + final Map values; + final bool throwOnRead; + final bool throwOnWrite; + final bool throwOnDelete; + final bool throwOnDeleteAll; + + bool deleteAllCalled = false; + final List deletedKeys = []; + final List writtenKeys = []; + + /// Shaped like the real failure: Android hands back a restored backup's + /// ciphertext without the Keystore key that wrapped it. + static PlatformException get _badPadding => PlatformException( + code: 'Exception encountered', + message: 'javax.crypto.BadPaddingException: pad block corrupted', + ); + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (throwOnRead) throw _badPadding; + return values[key]; + } + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (throwOnWrite) throw _badPadding; + writtenKeys.add(key); + if (value != null) values[key] = value; + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (throwOnDelete) throw _badPadding; + deletedKeys.add(key); + values.remove(key); + } + + @override + Future deleteAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + deleteAllCalled = true; + if (throwOnDeleteAll) throw _badPadding; + values.clear(); + } +} + void main() { group('PendingPkce', () { test('round-trips through JSON', () { @@ -67,4 +161,154 @@ void main() { expect(SecureTokenStore.androidPreferencesName, 'MeshMapperSecure'); }); }); + + group('SecureTokenStore stores under the contract keys', () { + test('the token round-trips under portal_app_token', () async { + final fake = _FakeSecureStorage(); + final store = SecureTokenStore(storage: fake); + + await store.writeToken('t' * 64); + + expect(fake.values[SecureTokenStore.tokenKey], 't' * 64); + expect(await store.readToken(), 't' * 64); + + await store.deleteToken(); + + expect(fake.deletedKeys, contains(SecureTokenStore.tokenKey)); + expect(await store.readToken(), isNull); + }); + + test('the pending pair round-trips as JSON under portal_pending_pkce', + () async { + final fake = _FakeSecureStorage(); + final store = SecureTokenStore(storage: fake); + final created = DateTime.fromMillisecondsSinceEpoch(1750000000000); + + await store.writePendingPkce(PendingPkce( + verifier: 'v' * 43, + state: 's' * 22, + createdAt: created, + )); + + expect( + fake.values[SecureTokenStore.pendingPkceKey], contains('"verifier"')); + + final restored = await store.readPendingPkce(); + + expect(restored, isNotNull); + expect(restored!.verifier, 'v' * 43); + expect(restored.state, 's' * 22); + expect(restored.createdAt, created); + + await store.deletePendingPkce(); + + expect(await store.readPendingPkce(), isNull); + }); + + test('an empty stored blob reads as no pending sign-in', () async { + final store = SecureTokenStore( + storage: + _FakeSecureStorage(values: {SecureTokenStore.pendingPkceKey: ''}), + ); + + expect(await store.readPendingPkce(), isNull); + }); + }); + + group('SecureTokenStore reads never throw', () { + test('a throwing read reports signed-out and nukes the store', () async { + final fake = _FakeSecureStorage(throwOnRead: true); + final store = SecureTokenStore(storage: fake); + + expect(await store.readToken(), isNull); + expect(fake.deleteAllCalled, isTrue); + }); + + test('a throwing pending read also degrades to null', () async { + final fake = _FakeSecureStorage(throwOnRead: true); + final store = SecureTokenStore(storage: fake); + + expect(await store.readPendingPkce(), isNull); + expect(fake.deleteAllCalled, isTrue); + }); + + test('a reset that itself fails still degrades to null, never throws', + () async { + final fake = + _FakeSecureStorage(throwOnRead: true, throwOnDeleteAll: true); + final store = SecureTokenStore(storage: fake); + + expect(await store.readToken(), isNull); + expect(fake.deleteAllCalled, isTrue); + }); + + test('a corrupt pending blob is discarded, not thrown', () async { + final fake = _FakeSecureStorage( + values: {SecureTokenStore.pendingPkceKey: 'garbage{{'}); + final store = SecureTokenStore(storage: fake); + + expect(await store.readPendingPkce(), isNull); + expect(fake.deletedKeys, contains(SecureTokenStore.pendingPkceKey)); + }); + + test('a pending blob that is valid JSON but not an object reads as null', + () async { + // Left in place rather than deleted — it decodes fine, it just isn't a + // pending sign-in. Reading it must still not throw. + final store = SecureTokenStore( + storage: _FakeSecureStorage( + values: {SecureTokenStore.pendingPkceKey: '["not","a","map"]'}, + ), + ); + + expect(await store.readPendingPkce(), isNull); + }); + + test('a pending blob missing fields reads as null', () async { + final store = SecureTokenStore( + storage: _FakeSecureStorage( + values: {SecureTokenStore.pendingPkceKey: '{"state":"s"}'}, + ), + ); + + expect(await store.readPendingPkce(), isNull); + }); + }); + + group('SecureTokenStore writes and deletes swallow failures', () { + test('a throwing token write completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnWrite: true)); + + await expectLater(store.writeToken('t' * 64), completes); + }); + + test('a throwing token delete completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnDelete: true)); + + await expectLater(store.deleteToken(), completes); + }); + + test('a throwing pending write completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnWrite: true)); + + await expectLater( + store.writePendingPkce(PendingPkce( + verifier: 'v' * 43, + state: 's' * 22, + createdAt: DateTime.fromMillisecondsSinceEpoch(1750000000000), + )), + completes, + ); + }); + + test('a throwing pending delete completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnDelete: true)); + + await expectLater(store.deletePendingPkce(), completes); + }); + }); } From 1b80b05b35be498070cebbe4b459059def94b34a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 07:26:30 -0400 Subject: [PATCH 285/360] Sign in to the portal through the system browser --- lib/services/portal_account_service.dart | 447 ++++++++++++++++++ .../services/portal_account_service_test.dart | 259 ++++++++++ 2 files changed, 706 insertions(+) create mode 100644 lib/services/portal_account_service.dart create mode 100644 test/services/portal_account_service_test.dart diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart new file mode 100644 index 00000000..0173eba7 --- /dev/null +++ b/lib/services/portal_account_service.dart @@ -0,0 +1,447 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:app_links/app_links.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import '../utils/constants.dart'; +import '../utils/debug_logger_io.dart'; +import '../utils/pkce.dart'; +import 'portal_token_store.dart'; + +/// The MyMeshMapper portal wire contract. +/// +/// Mirror of `MeshMapper_Server/docs/SPEC-app-portal-link.md`. If the server +/// renames a route, a parameter or the host, THIS CLASS is the only thing that +/// has to change. +class PortalApi { + PortalApi._(); + + static const String baseUrl = 'https://portal.meshmapper.net'; + static const String authorizePath = '/portal.php'; + static const String apiPath = '/portal_app_api.php'; + + /// Deliberately NOT `meshmapper` — that bare scheme is a documented + /// clipboard-only format (`docs/CUSTOM_API_ENDPOINT.md`, + /// `meshmapper://custom-api?...`) and OS-registering it would hijack it. + static const String callbackScheme = 'meshmapper-auth'; + static const String callbackHost = 'callback'; + static const String redirectUri = '$callbackScheme://$callbackHost'; + + static const Duration requestTimeout = Duration(seconds: 10); + + /// A stashed PKCE pair older than this is dead; the portal's own code TTL is + /// 300s, so 10 minutes is generous and still bounds the window. + static const Duration pkceTtl = Duration(minutes: 10); + + /// `me` writes the site-wide auth DB, so it is refreshed at most hourly. + static const Duration meThrottle = Duration(hours: 1); + + static Uri authorizeUrl({ + required String codeChallenge, + required String state, + }) => + Uri.parse('$baseUrl$authorizePath').replace(queryParameters: { + 'app_authorize': '1', + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + 'redirect_uri': redirectUri, + 'state': state, + }); + + /// `?action=` is the primary router; PATH_INFO is not used. + static Uri action(String name) => + Uri.parse('$baseUrl$apiPath').replace(queryParameters: {'action': name}); +} + +/// The signed-in portal identity. +class PortalAccount { + final int id; + final String username; + final String displayName; + + const PortalAccount({ + required this.id, + required this.username, + required this.displayName, + }); + + /// Parses the API shape `{id, username, display_name}`. `display_name` is a + /// lazily-added nullable column server-side, so it coalesces to `username`. + static PortalAccount? fromJson(Map? json) { + if (json == null) return null; + final id = (json['id'] as num?)?.toInt(); + final username = json['username']; + if (id == null || username is! String || username.isEmpty) return null; + final display = json['display_name']; + return PortalAccount( + id: id, + username: username, + displayName: + (display is String && display.isNotEmpty) ? display : username, + ); + } + + /// Hive cache shape (`portal_account_info`). + Map toCache() => { + 'user_id': id, + 'username': username, + 'display_name': displayName, + }; + + static PortalAccount? fromCache(Map? cache) { + if (cache == null) return null; + final id = (cache['user_id'] as num?)?.toInt(); + final username = cache['username']; + if (id == null || username is! String || username.isEmpty) return null; + final display = cache['display_name']; + return PortalAccount( + id: id, + username: username, + displayName: + (display is String && display.isNotEmpty) ? display : username, + ); + } +} + +/// One HTTP answer from the portal's machine lane. +class _PortalResponse { + /// 0 means no HTTP response at all (timeout, DNS, socket). + final int status; + final Map json; + + /// The value of `{"error": "..."}` when the body carried one. + final String? error; + + const _PortalResponse(this.status, this.json, this.error); + + bool get ok => status >= 200 && status < 300 && json['ok'] == true; + bool get networkFailure => status == 0; +} + +/// The MyMeshMapper portal lane. +/// +/// Deliberately separate from `ApiService`: different host, different auth +/// model (long-lived bearer vs. per-session geo-auth), different body encoding +/// (form vs. JSON). Nothing here may block or fail a wardriving connection. +class PortalAccountService { + final http.Client _client; + final PortalTokenStore _store; + final AppLinks? _appLinks; + final Future Function(Uri uri) _launcher; + + String? _token; + PortalAccount? _account; + PendingPkce? _pendingPkce; + String? _lastCode; + StreamSubscription? _linkSub; + + /// Codes already exchanged this app session (dedupe — see handleAuthCallback). + final Set _consumedCodes = {}; + + /// Optional friendly label sent with `token` and `link` (device name). + String? Function()? deviceLabelProvider; + + /// Fired whenever the cached identity or linked-device list changes. + void Function()? onAccountChanged; + + /// Fired on sign-out. `reason` is `token_invalid` or `user`. + void Function(String reason)? onSignedOut; + + /// Fired once per completed sign-in attempt. + void Function(bool success, String? errorCode)? onSignInComplete; + + PortalAccountService({ + http.Client? client, + PortalTokenStore? store, + AppLinks? appLinks, + Future Function(Uri uri)? launcher, + }) : _client = client ?? http.Client(), + _store = store ?? SecureTokenStore(), + _appLinks = appLinks, + _launcher = launcher ?? _launchExternally; + + /// `externalApplication` is load-bearing: an in-app WebView would see the + /// user's portal password, which is exactly what this design avoids. + static Future _launchExternally(Uri uri) => + launchUrl(uri, mode: LaunchMode.externalApplication); + + bool get isSignedIn => _token != null; + PortalAccount? get account => _account; + + /// Restore the identity cached in Hive so the UI has a name before any + /// network call happens. + void hydrateAccount(PortalAccount account) => _account = account; + + Future init() async { + _token = await _store.readToken(); + _log('init: signedIn=${_token != null}'); + + final links = _appLinks; + if (links == null) { + _log('init: deep links not wired (web or test)'); + return; + } + + // Subscribe FIRST. In app_links 6.x uriLinkStream carries the cold-start + // link as well as later ones, so a late subscription can miss it outright. + _linkSub = links.uriLinkStream.listen( + (uri) => unawaited(handleAuthCallback(uri)), + onError: (Object e) => _warn('link stream error: ${e.runtimeType}'), + ); + + try { + final initial = await links.getInitialLink(); + if (initial != null) await handleAuthCallback(initial); + } catch (e) { + _warn('getInitialLink failed: ${e.runtimeType}'); + } + } + + /// Re-check the last deep link on app resume — iOS can deliver the callback + /// while the Dart isolate is suspended. + Future checkLinkOnResume() async { + final links = _appLinks; + if (links == null) return; + try { + final latest = await links.getLatestLink(); + if (latest != null) await handleAuthCallback(latest); + } catch (e) { + _warn('getLatestLink failed: ${e.runtimeType}'); + } + } + + Future beginSignIn() async { + final pkce = PkcePair.generate(); + final pending = PendingPkce( + verifier: pkce.verifier, + state: pkce.state, + createdAt: DateTime.now(), + ); + _pendingPkce = pending; + await _store.writePendingPkce(pending); + + final url = PortalApi.authorizeUrl( + codeChallenge: pkce.challenge, + state: pkce.state, + ); + _log('opening the portal consent page (${url.host}${url.path})'); + try { + final launched = await _launcher(url); + if (!launched) _warn('the system browser refused the authorize URL'); + return launched; + } catch (e) { + _err('could not open the authorize URL: ${e.runtimeType}'); + return false; + } + } + + /// Handle one inbound deep link. Anything that is not our callback is + /// silently ignored — other schemes belong to other features. + Future handleAuthCallback(Uri uri) async { + if (uri.scheme != PortalApi.callbackScheme || + uri.host != PortalApi.callbackHost) { + return; + } + // Query stripped on purpose: it carries the code and the state. + _log('callback received: ${uri.scheme}://${uri.host}${uri.path}'); + + final error = uri.queryParameters['error']; + if (error != null && error.isNotEmpty) { + _log('callback carried error=$error — clearing the pending pair'); + _pendingPkce = null; + await _store.deletePendingPkce(); + onSignInComplete?.call(false, error); + return; + } + + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + if (code == null || code.isEmpty || state == null || state.isEmpty) { + _warn('callback missing code or state — ignoring'); + return; + } + + // app_links 6.x can deliver the same cold-start URI twice (getInitialLink + // AND uriLinkStream). The portal burns a code on first use, so a second + // exchange fails with code_invalid and looks like a broken sign-in. + if (_consumedCodes.contains(code)) { + _log('callback duplicate — this code was already exchanged'); + return; + } + + final pending = _pendingPkce ?? await _store.readPendingPkce(); + if (pending == null) { + _warn('callback with no pending PKCE pair — ignoring'); + return; + } + if (DateTime.now().difference(pending.createdAt) > PortalApi.pkceTtl) { + _warn('callback pending pair expired — discarding'); + _pendingPkce = null; + await _store.deletePendingPkce(); + return; + } + if (pending.state != state) { + // Do NOT burn the pair: the genuine callback may still be coming. + _warn('callback state mismatch — ignoring'); + return; + } + + // Burn the pair BEFORE the exchange: one attempt per authorize. + _consumedCodes.add(code); + _pendingPkce = null; + await _store.deletePendingPkce(); + + _lastCode = code; + try { + await _exchangeCode(code: code, verifier: pending.verifier); + } finally { + _lastCode = null; + } + } + + Future _exchangeCode({ + required String code, + required String verifier, + }) async { + final label = deviceLabelProvider?.call(); + final response = await _post( + 'token', + { + 'code': code, + 'code_verifier': verifier, + if (label != null && label.isNotEmpty) 'device_label': label, + }, + authenticated: false, + ); + + if (!response.ok) { + _warn('token exchange failed (status=${response.status}, ' + 'error=${response.error ?? 'none'})'); + onSignInComplete?.call(false, response.error ?? 'network'); + return; + } + + final token = response.json['token']; + final account = + PortalAccount.fromJson(response.json['user'] as Map?); + if (token is! String || token.isEmpty || account == null) { + _warn('token exchange returned an unusable body'); + onSignInComplete?.call(false, 'bad_response'); + return; + } + + _token = token; + _account = account; + await _store.writeToken(token); + _log('signed in as ${account.username} (id=${account.id})'); + onAccountChanged?.call(); + onSignInComplete?.call(true, null); + } + + Future<_PortalResponse> _post( + String action, + Map body, { + bool authenticated = true, + }) => + _postWithToken(action, body, authenticated ? _token : null); + + Future<_PortalResponse> _postWithToken( + String action, + Map body, + String? token, + ) async { + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'application/json', + 'User-Agent': AppConstants.userAgent, + }; + if (token != null) { + // Dual-send: Apache/FPM drops Authorization without CGIPassAuth, and the + // custom header is the proven-to-arrive fallback on this stack. + headers['Authorization'] = 'Bearer $token'; + headers['X-MM-App-Token'] = token; + } + + try { + final response = await _client + .post(PortalApi.action(action), headers: headers, body: body) + .timeout(PortalApi.requestTimeout); + + var json = const {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) json = decoded; + } catch (_) { + // Captive portal HTML / WAF block page — opaque, not an API answer. + } + final error = json['error'] is String ? json['error'] as String : null; + return _PortalResponse(response.statusCode, json, error); + } on TimeoutException { + _warn('$action timed out after ${PortalApi.requestTimeout.inSeconds}s'); + return const _PortalResponse(0, {}, 'timeout'); + } catch (e) { + _warn('$action network failure: ${e.runtimeType}'); + return const _PortalResponse(0, {}, 'network'); + } + } + + /// Drop local credentials. Called for an explicit sign-out and for the one + /// server answer that means "this token is dead": 401 + `token_invalid`. + Future _forceSignOut(String reason) async { + _token = null; + _account = null; + await _store.deleteToken(); + await _store.deletePendingPkce(); + _pendingPkce = null; + _log('signed out ($reason)'); + onSignedOut?.call(reason); + onAccountChanged?.call(); + } + + /// 8-char uppercase prefix — full public keys never reach a log. + String _pk(String? pubkey) { + if (pubkey == null || pubkey.isEmpty) return 'none'; + final upper = pubkey.toUpperCase(); + return upper.length <= 8 ? upper : upper.substring(0, 8); + } + + void _log(String message) => debugLog('[ACCOUNT] ${_redact(message)}'); + void _warn(String message) => debugWarn('[ACCOUNT] ${_redact(message)}'); + void _err(String message) => debugError('[ACCOUNT] ${_redact(message)}'); + + static final RegExp _bearerPattern = + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]{8,}'); + + /// Mechanical backstop on top of "never interpolate a secret". Debug logging + /// is ON in release builds and log files are uploaded verbatim with bug + /// reports, so discipline alone WILL eventually leak. + String _redact(String message) { + var out = message; + for (final secret in _liveSecrets) { + if (secret.length >= 8 && out.contains(secret)) { + out = out.replaceAll(secret, ''); + } + } + return out.replaceAll(_bearerPattern, 'Bearer '); + } + + Iterable get _liveSecrets sync* { + final token = _token; + if (token != null) yield token; + final pending = _pendingPkce; + if (pending != null) { + yield pending.verifier; + yield pending.state; + } + final code = _lastCode; + if (code != null) yield code; + } + + void dispose() { + _linkSub?.cancel(); + _linkSub = null; + _client.close(); + } +} diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart new file mode 100644 index 00000000..9beea743 --- /dev/null +++ b/test/services/portal_account_service_test.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/portal_account_service.dart'; +import 'package:mesh_mapper/services/portal_token_store.dart'; +import 'package:mesh_mapper/utils/debug_logger_io.dart'; + +void main() { + late InMemoryTokenStore store; + late List requests; + + setUp(() { + store = InMemoryTokenStore(); + requests = []; + }); + + /// A MockClient that records every request and answers from [responder]. + MockClient recordingClient( + http.Response Function(http.Request request) responder) { + return MockClient((request) async { + requests.add(request); + return responder(request); + }); + } + + PortalAccountService buildService(MockClient client) => PortalAccountService( + client: client, + store: store, + // appLinks: null keeps the platform deep-link plugin out of unit tests; + // callbacks are driven through handleAuthCallback() directly. + launcher: (uri) async => true, + ); + + /// Seeds a pending PKCE pair as though beginSignIn() had run. + Future seedPending(String state, {Duration age = Duration.zero}) async { + await store.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: state, + createdAt: DateTime.now().subtract(age), + )); + } + + String tokenBody() => jsonEncode({ + 'ok': true, + 'token': 'f' * 64, + 'user': {'id': 7, 'username': 'sparkgap', 'display_name': 'Spark Gap'}, + }); + + group('authorize URL', () { + test('carries the locked PKCE and redirect parameters', () { + final uri = PortalApi.authorizeUrl( + codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + state: 'st4te'); + expect(uri.host, 'portal.meshmapper.net'); + expect(uri.path, '/portal.php'); + expect(uri.queryParameters['app_authorize'], '1'); + expect(uri.queryParameters['code_challenge_method'], 'S256'); + expect(uri.queryParameters['redirect_uri'], 'meshmapper-auth://callback'); + expect(uri.queryParameters['state'], 'st4te'); + // The encoded form the portal byte-compares against. + expect(uri.toString(), + contains('redirect_uri=meshmapper-auth%3A%2F%2Fcallback')); + }); + + test('action() builds the machine-lane route', () { + expect(PortalApi.action('nonce').toString(), + 'https://portal.meshmapper.net/portal_app_api.php?action=nonce'); + }); + }); + + group('beginSignIn', () { + test('persists a pending pair and opens the browser', () async { + Uri? opened; + final service = PortalAccountService( + client: recordingClient((_) => http.Response('{}', 200)), + store: store, + launcher: (uri) async { + opened = uri; + return true; + }, + ); + + expect(await service.beginSignIn(), isTrue); + + final pending = await store.readPendingPkce(); + expect(pending, isNotNull); + expect(pending!.verifier.length, 43); + expect(opened, isNotNull); + expect(opened!.queryParameters['state'], pending.state); + expect(opened!.queryParameters['code_challenge']!.length, 43); + expect(requests, isEmpty, reason: 'sign-in must not hit the API'); + }); + }); + + group('handleAuthCallback', () { + test('exchanges the code and persists the token', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + var changed = 0; + service.onAccountChanged = () => changed++; + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests.length, 1); + final request = requests.single; + expect(request.url.queryParameters['action'], 'token'); + expect(request.headers['Content-Type'], + contains('application/x-www-form-urlencoded')); + expect(request.bodyFields['code'], 'a' * 64); + expect(request.bodyFields['code_verifier'], + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + // The token exchange is unauthenticated. + expect(request.headers.containsKey('Authorization'), isFalse); + + expect(await store.readToken(), 'f' * 64); + expect(await store.readPendingPkce(), isNull); + expect(service.isSignedIn, isTrue); + expect(service.account!.username, 'sparkgap'); + expect(service.account!.displayName, 'Spark Gap'); + expect(changed, 1); + }); + + test('ignores a state mismatch and makes zero HTTP calls', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=wrong')); + + expect(requests, isEmpty); + expect(service.isSignedIn, isFalse); + expect(await store.readPendingPkce(), isNotNull, + reason: 'a genuine callback may still arrive'); + }); + + test('ignores a callback with no pending pair', () async { + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests, isEmpty); + expect(service.isSignedIn, isFalse); + }); + + test('ignores an expired pending pair', () async { + await seedPending('st4te', age: const Duration(minutes: 11)); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests, isEmpty); + expect(await store.readPendingPkce(), isNull); + }); + + test('exchanges a given code only once', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + final uri = + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te'); + + // app_links 6.x delivers the cold-start URI on BOTH getInitialLink() + // and uriLinkStream; a second exchange would burn an already-used code. + await service.handleAuthCallback(uri); + await service.handleAuthCallback(uri); + + expect(requests.length, 1); + }); + + test('access_denied clears the pending pair without an exchange', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + bool? reported; + String? code; + service.onSignInComplete = (success, errorCode) { + reported = success; + code = errorCode; + }; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te')); + + expect(requests, isEmpty); + expect(await store.readPendingPkce(), isNull); + expect(reported, isFalse); + expect(code, 'access_denied'); + }); + + test('ignores a URI with a foreign scheme', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service + .handleAuthCallback(Uri.parse('meshmapper://custom-api?url=x&key=y')); + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://elsewhere?code=${'a' * 64}&state=st4te')); + + expect(requests, isEmpty); + }); + + test('a failed exchange leaves the user signed out', () async { + await seedPending('st4te'); + final service = buildService(recordingClient( + (_) => http.Response('{"error":"code_invalid"}', 400))); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(service.isSignedIn, isFalse); + expect(await store.readToken(), isNull); + expect(errorCode, 'code_invalid'); + }); + }); + + group('logging never leaks a secret', () { + test('token, code, verifier and state appear in no log line', () async { + final logs = []; + final originalPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + DebugLogger.setEnabled(true); + addTearDown(() { + debugPrint = originalPrint; + DebugLogger.setEnabled(false); + }); + + await seedPending('st4testate'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?code=${'a' * 64}&state=st4testate')); + + final joined = logs.join('\n'); + expect(joined, isNot(contains('f' * 64)), reason: 'token leaked'); + expect(joined, isNot(contains('a' * 64)), reason: 'code leaked'); + expect(joined, + isNot(contains('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')), + reason: 'verifier leaked'); + expect(joined, isNot(contains('st4testate')), reason: 'state leaked'); + expect(joined, contains('[ACCOUNT]'), + reason: 'the flow should log something'); + }); + }); +} From 06abfee7f05821f3964fee6899accc9310121174 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 07:32:19 -0400 Subject: [PATCH 286/360] Stop a duplicate callback from exchanging the code twice --- lib/services/portal_account_service.dart | 23 ++++-- .../services/portal_account_service_test.dart | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index 0173eba7..8ef017bc 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -264,32 +264,43 @@ class PortalAccountService { } // app_links 6.x can deliver the same cold-start URI twice (getInitialLink - // AND uriLinkStream). The portal burns a code on first use, so a second - // exchange fails with code_invalid and looks like a broken sign-in. - if (_consumedCodes.contains(code)) { + // AND uriLinkStream) and can deliver both CONCURRENTLY — init() runs the + // stream listener unawaited while getInitialLink() is still in flight. The + // portal burns a code on first use, so a second exchange fails with + // code_invalid and looks like a broken sign-in. + // + // `Set.add` is the atomic test-and-set, and it has to happen HERE: every + // later checkpoint sits behind `await _store.readPendingPkce()`, and an + // await between the test and the insert lets both deliveries through. The + // claim is released again on each path below that decides not to exchange. + if (!_consumedCodes.add(code)) { _log('callback duplicate — this code was already exchanged'); return; } final pending = _pendingPkce ?? await _store.readPendingPkce(); if (pending == null) { + _consumedCodes.remove(code); _warn('callback with no pending PKCE pair — ignoring'); return; } if (DateTime.now().difference(pending.createdAt) > PortalApi.pkceTtl) { + _consumedCodes.remove(code); _warn('callback pending pair expired — discarding'); _pendingPkce = null; await _store.deletePendingPkce(); return; } if (pending.state != state) { - // Do NOT burn the pair: the genuine callback may still be coming. + // Do NOT burn the pair, and release the code: the genuine callback may + // still be coming and may legitimately carry this very code. + _consumedCodes.remove(code); _warn('callback state mismatch — ignoring'); return; } - // Burn the pair BEFORE the exchange: one attempt per authorize. - _consumedCodes.add(code); + // Burn the pair BEFORE the exchange: one attempt per authorize. The code + // itself was already claimed by the atomic add above. _pendingPkce = null; await _store.deletePendingPkce(); diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 9beea743..001234c6 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -177,6 +178,57 @@ void main() { expect(requests.length, 1); }); + test('a concurrent duplicate delivery still exchanges only once', () async { + // The init() race: the uriLinkStream listener runs unawaited while + // getInitialLink() is still in flight, so both deliveries can be inside + // handleAuthCallback at the same time. The gate parks them both on the + // store read, which is where the guard used to leak. + final gated = _GatedStore(); + await gated.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: gated, + launcher: (uri) async => true, + ); + final uri = + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te'); + + final first = service.handleAuthCallback(uri); + final second = service.handleAuthCallback(uri); + gated.openGate(); + await Future.wait([first, second]); + + expect(requests.length, 1, + reason: 'the portal burns a code on first use'); + expect(service.isSignedIn, isTrue); + expect(gated.pendingReads, 1, + reason: 'the duplicate must bounce before it reaches the store'); + }); + + test('a state mismatch does not poison the code for the genuine callback', + () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + // A stale or hostile callback carrying this code but the wrong state. + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=wrong')); + expect(requests, isEmpty); + + // The genuine callback arrives afterwards with the SAME code and must + // still be exchangeable. + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests.length, 1); + expect(service.isSignedIn, isTrue); + }); + test('access_denied clears the pending pair without an exchange', () async { await seedPending('st4te'); final service = @@ -257,3 +309,21 @@ void main() { }); }); } + +/// A store whose `readPendingPkce` parks until [openGate] is called, so a test +/// can hold two `handleAuthCallback` calls inside the duplicate guard at once. +class _GatedStore extends InMemoryTokenStore { + final Completer _gate = Completer(); + + /// How many times the callback flow got as far as reading the pending pair. + int pendingReads = 0; + + void openGate() => _gate.complete(); + + @override + Future readPendingPkce() async { + pendingReads++; + await _gate.future; + return super.readPendingPkce(); + } +} From 0073688a0f0b2eee03633d6513702f3fa6a354d6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 07:44:32 -0400 Subject: [PATCH 287/360] Only trust a callback error that answers a live sign-in --- lib/services/portal_account_service.dart | 83 ++++++++-- .../services/portal_account_service_test.dart | 143 +++++++++++++++++- 2 files changed, 214 insertions(+), 12 deletions(-) diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index 8ef017bc..031a1fd4 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -248,18 +248,24 @@ class PortalAccountService { _log('callback received: ${uri.scheme}://${uri.host}${uri.path}'); final error = uri.queryParameters['error']; - if (error != null && error.isNotEmpty) { - _log('callback carried error=$error — clearing the pending pair'); - _pendingPkce = null; - await _store.deletePendingPkce(); - onSignInComplete?.call(false, error); + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + final hasError = error != null && error.isNotEmpty; + + // `state` is what ties any callback — success OR failure — to an attempt + // this app actually started. + if (state == null || state.isEmpty) { + _warn('callback missing state — ignoring'); return; } - final code = uri.queryParameters['code']; - final state = uri.queryParameters['state']; - if (code == null || code.isEmpty || state == null || state.isEmpty) { - _warn('callback missing code or state — ignoring'); + if (hasError) { + await _handleErrorCallback(error, state); + return; + } + + if (code == null || code.isEmpty) { + _warn('callback missing code — ignoring'); return; } @@ -284,11 +290,19 @@ class PortalAccountService { _warn('callback with no pending PKCE pair — ignoring'); return; } + // Arm the redactor. On a cold start the pair comes off disk and + // `_pendingPkce` would otherwise stay null, leaving `_liveSecrets` empty — + // the backstop would be inert for the rest of this flow, which is exactly + // the stretch that handles the verifier and the token. + _pendingPkce = pending; + if (DateTime.now().difference(pending.createdAt) > PortalApi.pkceTtl) { _consumedCodes.remove(code); _warn('callback pending pair expired — discarding'); _pendingPkce = null; await _store.deletePendingPkce(); + // A real attempt was live and just died; the UI must stop waiting. + onSignInComplete?.call(false, 'expired'); return; } if (pending.state != state) { @@ -307,11 +321,54 @@ class PortalAccountService { _lastCode = code; try { await _exchangeCode(code: code, verifier: pending.verifier); + } catch (e) { + // handleAuthCallback is driven unawaited from the uriLinkStream listener, + // so anything escaping here is an unhandled zone error: the attempt would + // die silently with the code already claimed and the UI still spinning. + _err('token exchange crashed: ${e.runtimeType}'); + onSignInComplete?.call(false, 'bad_response'); } finally { _lastCode = null; } } + /// Handle a callback that reported `error=`. + /// + /// A deep link is unauthenticated — ANY app on the device can fire + /// `meshmapper-auth://callback?error=x`. Honouring that blindly let a foreign + /// app destroy a legitimate in-flight pair (a sign-in DoS) and push an + /// arbitrary string into the completion callback. So an error is believed + /// only when it answers a sign-in this app actually started: a live pending + /// pair whose state matches. Everything else is dropped without a trace of + /// the attacker's string. + Future _handleErrorCallback(String error, String state) async { + final pending = _pendingPkce ?? await _store.readPendingPkce(); + if (pending == null) { + _warn('callback reported an error with no sign-in in flight — ignoring'); + return; + } + _pendingPkce = pending; + if (pending.state != state) { + // Leave the pair alone: the genuine callback may still be coming. + _warn('callback reported an error for a different attempt — ignoring'); + return; + } + + _pendingPkce = null; + await _store.deletePendingPkce(); + final reason = _sanitizeErrorCode(error); + _log('the portal declined the sign-in (reason=$reason)'); + onSignInComplete?.call(false, reason); + } + + /// The portal's error codes are lowercase snake_case (`access_denied`). + /// Anything else came from something that is not the portal, so it is + /// collapsed to a fixed token rather than forwarded to the UI or a log. + static String _sanitizeErrorCode(String raw) => + _errorCodePattern.hasMatch(raw) ? raw : 'denied'; + + static final RegExp _errorCodePattern = RegExp(r'^[a-z_]{1,32}$'); + Future _exchangeCode({ required String code, required String verifier, @@ -335,8 +392,12 @@ class PortalAccountService { } final token = response.json['token']; - final account = - PortalAccount.fromJson(response.json['user'] as Map?); + // Type CHECK, not a cast: `"user": []` or `"user": "bob"` would throw a + // TypeError out of an unawaited future. fromJson's null path already + // reports this as an unusable body. + final rawUser = response.json['user']; + final account = PortalAccount.fromJson( + rawUser is Map ? rawUser : null); if (token is! String || token.isEmpty || account == null) { _warn('token exchange returned an unusable body'); onSignInComplete?.call(false, 'bad_response'); diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 001234c6..d20eed01 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -151,16 +151,25 @@ void main() { expect(service.isSignedIn, isFalse); }); - test('ignores an expired pending pair', () async { + test('ignores an expired pending pair and reports it', () async { await seedPending('st4te', age: const Duration(minutes: 11)); final service = buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + bool? reported; + String? errorCode; + service.onSignInComplete = (success, code) { + reported = success; + errorCode = code; + }; await service.handleAuthCallback( Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); expect(requests, isEmpty); expect(await store.readPendingPkce(), isNull); + // A real attempt was live and just died — the UI must stop spinning. + expect(reported, isFalse); + expect(errorCode, 'expired'); }); test('exchanges a given code only once', () async { @@ -276,6 +285,130 @@ void main() { expect(await store.readToken(), isNull); expect(errorCode, 'code_invalid'); }); + + // A hostile or broken portal answering `"user": []` / `"user": "bob"` used + // to throw a TypeError on the cast. Via the uriLinkStream listener that is + // an unawaited future, so it became an unhandled zone error and the whole + // attempt died silently with the code still claimed. + for (final shape in [[], 'bob', 42]) { + test('a non-object user (${shape.runtimeType}) is rejected, not thrown', + () async { + final localStore = InMemoryTokenStore(); + await localStore.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response( + jsonEncode({'ok': true, 'token': 'f' * 64, 'user': shape}), 200)), + store: localStore, + launcher: (uri) async => true, + ); + bool? reported; + String? errorCode; + service.onSignInComplete = (success, code) { + reported = success; + errorCode = code; + }; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(service.isSignedIn, isFalse); + expect(await localStore.readToken(), isNull); + expect(reported, isFalse); + expect(errorCode, 'bad_response'); + }); + } + + test('a throw inside the exchange cannot escape as a zone error', () async { + final throwing = _ThrowingWriteStore(); + await throwing.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: throwing, + launcher: (uri) async => true, + ); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + + // Must complete normally rather than propagating the keystore failure. + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(errorCode, 'bad_response'); + }); + }); + + // A callback is unauthenticated: ANY app on the device can fire + // meshmapper-auth://callback?error=... An error is therefore only believed + // when it answers a sign-in we actually started. + group('error callbacks are only honoured when they answer a live attempt', + () { + test('an error with no pending pair is ignored', () async { + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + var completions = 0; + service.onSignInComplete = (_, __) => completions++; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te')); + + expect(completions, 0); + expect(requests, isEmpty); + }); + + test('an error with a mismatched state leaves the pair intact', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + var completions = 0; + service.onSignInComplete = (_, __) => completions++; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=wrong')); + + expect(completions, 0, + reason: 'a foreign app must not fail a live sign-in'); + expect(await store.readPendingPkce(), isNotNull, + reason: 'the genuine callback may still arrive'); + }); + + test('a matching error is reported exactly once', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + final reported = []; + service.onSignInComplete = (success, code) => reported.add(code); + final uri = Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te'); + + await service.handleAuthCallback(uri); + await service.handleAuthCallback(uri); + + expect(reported, ['access_denied']); + expect(await store.readPendingPkce(), isNull); + expect(requests, isEmpty); + }); + + test('a hostile error value is sanitized before it is reported', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + final hostile = Uri.encodeQueryComponent(''); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?error=$hostile&state=st4te')); + + expect(errorCode, 'denied'); + }); }); group('logging never leaks a secret', () { @@ -327,3 +460,11 @@ class _GatedStore extends InMemoryTokenStore { return super.readPendingPkce(); } } + +/// A store whose token write blows up, the way a wedged keystore does. Stands +/// in for any throw raised inside the exchange after validation has passed. +class _ThrowingWriteStore extends InMemoryTokenStore { + @override + Future writeToken(String value) async => + throw StateError('keystore unavailable'); +} From c52bfa25eb26d9bfadca2ab3033d2643157359e1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 07:58:40 -0400 Subject: [PATCH 288/360] Bind a radio to a portal account, and keep the logs clean --- lib/services/debug_file_logger.dart | 23 +- lib/services/portal_account_service.dart | 307 +++++++++++++++++- .../debug_file_logger_scrub_test.dart | 44 +++ .../services/portal_account_service_test.dart | 263 +++++++++++++++ 4 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 test/services/debug_file_logger_scrub_test.dart diff --git a/lib/services/debug_file_logger.dart b/lib/services/debug_file_logger.dart index 5b43551e..9a3d7397 100644 --- a/lib/services/debug_file_logger.dart +++ b/lib/services/debug_file_logger.dart @@ -88,6 +88,27 @@ class DebugFileLogger { } } + /// Credential shapes stripped from every line written to a log FILE. + static final RegExp _bearerPattern = + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]{8,}'); + static final RegExp _tokenFieldPattern = + RegExp(r'"token"\s*:\s*"[0-9a-zA-Z._~+/=-]{8,}"'); + static final RegExp _codeParamPattern = RegExp( + r'\b(code|code_verifier|code_challenge|token)=[A-Za-z0-9._~%+/=-]{20,}'); + + /// Strip credential shapes out of a log line. + /// + /// Log files are uploaded verbatim with bug reports and debug logging stays + /// on in release builds, so this is the last line of defence behind + /// "never log a secret". Public so it can be unit-tested. + static String scrubSecrets(String message) { + var out = message.replaceAll(_bearerPattern, 'Bearer '); + out = out.replaceAll(_tokenFieldPattern, '"token":""'); + out = out.replaceAllMapped( + _codeParamPattern, (match) => '${match.group(1)}='); + return out; + } + /// Write a log entry to the current file /// /// Called by debug_logger_stub.dart for each log message @@ -99,7 +120,7 @@ class DebugFileLogger { if (!_enabled) return; final timestamp = DateTime.now().toIso8601String(); - final line = '[$timestamp] $level: $message'; + final line = '[$timestamp] $level: ${scrubSecrets(message)}'; if (_logSink == null) { // Buffer logs until sink is ready (race condition during initialization) diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index 031a1fd4..91a677f2 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -105,6 +105,97 @@ class PortalAccount { } } +/// One companion radio bound to the account (`me` → `pubkeys[]`). +class LinkedPubkey { + final String pubkey; // UPPER 64-hex + final String label; + final String name; + final int points; + + const LinkedPubkey({ + required this.pubkey, + required this.label, + required this.name, + required this.points, + }); + + static LinkedPubkey? fromJson(Map json) { + final pubkey = json['pubkey']; + if (pubkey is! String || pubkey.isEmpty) return null; + return LinkedPubkey( + pubkey: pubkey.toUpperCase(), + label: json['label'] is String ? json['label'] as String : '', + name: json['name'] is String ? json['name'] as String : '', + points: (json['points'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Outcome of an `action=link` POST. +sealed class LinkResult { + const LinkResult(); +} + +class LinkSuccess extends LinkResult { + final String pubkey; + + /// True when the server answered `already:true` (idempotent relink). + final bool already; + + const LinkSuccess({required this.pubkey, required this.already}); +} + +/// The pubkey belongs to a different account. `UNIQUE(pubkey)` means this is +/// permanent until the other owner unlinks — never retry it silently. +class LinkAlreadyLinkedOtherAccount extends LinkResult { + const LinkAlreadyLinkedOtherAccount(); +} + +/// The account still holds placeholder devices. The app lane NEVER adopts — +/// adoption is irreversible and belongs in the browser portal. +class LinkAdoptionRequired extends LinkResult { + final int devices; + const LinkAdoptionRequired(this.devices); +} + +class LinkUnauthorized extends LinkResult { + const LinkUnauthorized(); +} + +class LinkNetworkError extends LinkResult { + final String detail; + const LinkNetworkError(this.detail); +} + +class LinkServerError extends LinkResult { + final String code; + final int statusCode; + const LinkServerError(this.code, this.statusCode); +} + +/// How a link attempt should be presented. Returned by `AppStateProvider`, not +/// by the service — the provider folds persistence and backoff into it. +enum PortalLinkStatus { + skipped, + linked, + adoptionRequired, + alreadyLinkedOtherAccount, + unauthorized, + failed, +} + +class PortalLinkOutcome { + final PortalLinkStatus status; + final int adoptionDeviceCount; + final String? accountName; + + const PortalLinkOutcome( + this.status, { + this.adoptionDeviceCount = 0, + this.accountName, + }); +} + /// One HTTP answer from the portal's machine lane. class _PortalResponse { /// 0 means no HTTP response at all (timeout, DNS, socket). @@ -133,6 +224,8 @@ class PortalAccountService { String? _token; PortalAccount? _account; + List _linkedPubkeys = const []; + DateTime? _lastMeAt; PendingPkce? _pendingPkce; String? _lastCode; StreamSubscription? _linkSub; @@ -140,6 +233,10 @@ class PortalAccountService { /// Codes already exchanged this app session (dedupe — see handleAuthCallback). final Set _consumedCodes = {}; + /// States whose `error=` callback has already been honoured this session + /// (dedupe — see _handleErrorCallback). + final Set _consumedErrorStates = {}; + /// Optional friendly label sent with `token` and `link` (device name). String? Function()? deviceLabelProvider; @@ -169,6 +266,7 @@ class PortalAccountService { bool get isSignedIn => _token != null; PortalAccount? get account => _account; + List get linkedPubkeys => List.unmodifiable(_linkedPubkeys); /// Restore the identity cached in Hive so the UI has a name before any /// network call happens. @@ -342,20 +440,47 @@ class PortalAccountService { /// pair whose state matches. Everything else is dropped without a trace of /// the attacker's string. Future _handleErrorCallback(String error, String state) async { - final pending = _pendingPkce ?? await _store.readPendingPkce(); + // Same atomic test-and-set as the code path, keyed on the state. Sequential + // dedupe falls out of the pair being burned, but two CONCURRENT deliveries + // both park on the store read below and would both fire onSignInComplete. + // The claim is released again on every path that decides not to honour it. + if (!_consumedErrorStates.add(state)) { + _log('callback duplicate — this attempt was already declined'); + return; + } + + final PendingPkce? pending; + try { + pending = _pendingPkce ?? await _store.readPendingPkce(); + } catch (e) { + _consumedErrorStates.remove(state); + _err('reading the pending pair for a declined sign-in failed: ' + '${e.runtimeType}'); + return; + } if (pending == null) { + _consumedErrorStates.remove(state); _warn('callback reported an error with no sign-in in flight — ignoring'); return; } _pendingPkce = pending; if (pending.state != state) { // Leave the pair alone: the genuine callback may still be coming. + _consumedErrorStates.remove(state); _warn('callback reported an error for a different attempt — ignoring'); return; } _pendingPkce = null; - await _store.deletePendingPkce(); + try { + await _store.deletePendingPkce(); + } catch (e) { + // Not fatal — the pair is dead in memory and the stored copy carries a + // TTL. What must NOT happen is this escaping as a zone error on the + // unawaited uriLinkStream path, leaving the UI spinning on a sign-in + // that has already been refused. + _warn('clearing the declined pending pair failed: ${e.runtimeType}'); + } final reason = _sanitizeErrorCode(error); _log('the portal declined the sign-in (reason=$reason)'); onSignInComplete?.call(false, reason); @@ -412,6 +537,182 @@ class PortalAccountService { onSignInComplete?.call(true, null); } + /// True only for the one answer that means "this token is dead". A bare 401 + /// (captive portal, WAF) must NOT sign the user out. + bool _isTokenInvalid(_PortalResponse response) => + response.status == 401 && response.error == 'token_invalid'; + + /// Ask the portal for a fresh 32-byte challenge bound to [pubkeyHex]. + /// Returns the 64-hex nonce, or null on any failure (never throws). + Future requestNonce(String pubkeyHex) async { + final response = await _post('nonce', {'pubkey': pubkeyHex.toUpperCase()}); + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return null; + } + // The nonce route answers {nonce, expires_at} with no `ok` flag. + final nonce = response.json['nonce']; + if (response.status >= 200 && + response.status < 300 && + nonce is String && + _noncePattern.hasMatch(nonce)) { + _log('nonce issued for ${_pk(pubkeyHex)}'); + return nonce; + } + _warn('nonce request failed for ${_pk(pubkeyHex)} ' + '(status=${response.status}, error=${response.error ?? 'none'})'); + return null; + } + + static final RegExp _noncePattern = RegExp(r'^[0-9a-fA-F]{64}$'); + + /// Post the radio's Ed25519 signature over the raw nonce bytes. + Future linkDevice({ + required String pubkey, + required String nonce, + required String signature, + String? label, + }) async { + final upper = pubkey.toUpperCase(); + final response = await _post('link', { + 'pubkey': upper, + 'nonce': nonce, + 'signature': signature, + if (label != null && label.isNotEmpty) 'label': label, + }); + + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return const LinkUnauthorized(); + } + if (response.status == 401) { + return const LinkNetworkError('bare 401 (captive portal?)'); + } + if (response.networkFailure) { + return LinkNetworkError(response.error ?? 'network'); + } + if (response.ok) { + final already = response.json['already'] == true; + _log('link ok for ${_pk(upper)} (already=$already)'); + if (!_linkedPubkeys.any((p) => p.pubkey == upper)) { + _linkedPubkeys = [ + ..._linkedPubkeys, + LinkedPubkey( + pubkey: upper, + label: label ?? '', + name: response.json['name'] is String + ? response.json['name'] as String + : '', + points: (response.json['points'] as num?)?.toInt() ?? 0, + ), + ]; + onAccountChanged?.call(); + } + return LinkSuccess(pubkey: upper, already: already); + } + + switch (response.error) { + case 'already_linked': + _log('link refused for ${_pk(upper)}: owned by another account'); + return const LinkAlreadyLinkedOtherAccount(); + case 'adoption_required': + final devices = (response.json['devices'] as num?)?.toInt() ?? 0; + _log('link needs browser adoption first ' + '($devices placeholder device(s))'); + return LinkAdoptionRequired(devices); + default: + _warn('link failed for ${_pk(upper)} ' + '(status=${response.status}, error=${response.error ?? 'none'})'); + return LinkServerError(response.error ?? 'unknown', response.status); + } + } + + /// In-app recovery for a mislink — without it a phone mistake needs a laptop. + Future unlinkDevice(String pubkey) async { + final upper = pubkey.toUpperCase(); + final response = await _post('unlink', {'pubkey': upper}); + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return false; + } + if (!response.ok) { + _warn('unlink failed for ${_pk(upper)} (status=${response.status})'); + return false; + } + _linkedPubkeys = + _linkedPubkeys.where((p) => p.pubkey != upper).toList(growable: false); + _log('unlinked ${_pk(upper)}'); + onAccountChanged?.call(); + return true; + } + + /// Refresh the identity and the linked-device list. + /// + /// Throttled to once an hour: the bearer gate touches the SITE-WIDE auth DB + /// on every call, so a chatty client writes it on every reconnect. + Future refreshMe({bool force = false}) async { + if (_token == null) return false; + final last = _lastMeAt; + if (!force && + last != null && + DateTime.now().difference(last) < PortalApi.meThrottle) { + _log('me: throttled'); + return false; + } + _lastMeAt = DateTime.now(); + + final response = await _post('me', const {}); + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return false; + } + if (!response.ok) { + _warn('me failed (status=${response.status}, ' + 'error=${response.error ?? 'none'})'); + return false; + } + + // Type CHECK, not a cast — same hostile shape the token exchange guards + // against (`"user": []` would throw a TypeError out of a plain cast). + final rawUser = response.json['user']; + final account = PortalAccount.fromJson( + rawUser is Map ? rawUser : null); + if (account != null) _account = account; + + final raw = response.json['pubkeys']; + if (raw is List) { + _linkedPubkeys = raw + .whereType() + .map((entry) => LinkedPubkey.fromJson(entry.cast())) + .whereType() + .toList(growable: false); + } + _log('me ok: ${_linkedPubkeys.length} linked device(s)'); + onAccountChanged?.call(); + return true; + } + + /// Sign out. The LOCAL clear always happens; the server revoke is best-effort + /// with a single retry, because a live server-side token with no local copy + /// is an orphan the user cannot revoke from the app. + Future logout() async { + final token = _token; + await _forceSignOut('user'); + if (token == null) return; + + final first = await _postWithToken('logout', const {}, token); + if (first.ok) { + _log('logout: server token revoked'); + return; + } + _warn('logout revoke failed (status=${first.status}) — retrying once'); + final retry = await _postWithToken('logout', const {}, token); + if (!retry.ok) { + _warn('logout revoke retry failed (status=${retry.status}); ' + 'the server token stays until it expires'); + } + } + Future<_PortalResponse> _post( String action, Map body, { @@ -464,6 +765,8 @@ class PortalAccountService { Future _forceSignOut(String reason) async { _token = null; _account = null; + _linkedPubkeys = const []; + _lastMeAt = null; await _store.deleteToken(); await _store.deletePendingPkce(); _pendingPkce = null; diff --git a/test/services/debug_file_logger_scrub_test.dart b/test/services/debug_file_logger_scrub_test.dart new file mode 100644 index 00000000..6870c084 --- /dev/null +++ b/test/services/debug_file_logger_scrub_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/debug_file_logger.dart'; + +/// Debug log FILES are uploaded verbatim with bug reports and debug logging is +/// on in release builds, so the file writer scrubs credential shapes even when +/// a caller was careless. +void main() { + test('redacts a bearer token', () { + expect( + DebugFileLogger.scrubSecrets( + 'POST with Authorization: Bearer ${'a' * 64}'), + 'POST with Authorization: Bearer ', + ); + }); + + test('redacts a token JSON field', () { + expect( + DebugFileLogger.scrubSecrets('body {"ok":true,"token":"${'b' * 64}"}'), + contains('"token":""'), + ); + expect( + DebugFileLogger.scrubSecrets('body {"ok":true,"token":"${'b' * 64}"}'), + isNot(contains('b' * 64)), + ); + }); + + test('redacts code, code_verifier and code_challenge query parameters', () { + final scrubbed = DebugFileLogger.scrubSecrets( + 'callback?code=${'c' * 64}&code_verifier=${'d' * 43}&state=xyz'); + expect(scrubbed, contains('code=')); + expect(scrubbed, contains('code_verifier=')); + expect(scrubbed, isNot(contains('c' * 64))); + expect(scrubbed, isNot(contains('d' * 43))); + }); + + test('leaves ordinary log lines untouched', () { + const line = '[CONN] Frame received (7 bytes): 13 00 80 00 00 00'; + expect(DebugFileLogger.scrubSecrets(line), line); + }); + + test('does not eat short values that merely look similar', () { + expect(DebugFileLogger.scrubSecrets('mode=1&code=7'), 'mode=1&code=7'); + }); +} diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index d20eed01..0e77c210 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -409,6 +409,62 @@ void main() { expect(errorCode, 'denied'); }); + + test('a concurrent duplicate error is reported only once', () async { + // Same init() race as the code path: getInitialLink() and the + // uriLinkStream listener can both be inside handleAuthCallback at once. + // Sequential dedupe falls out of the pair being burned, but two + // deliveries parked on the store read would BOTH fire onSignInComplete. + final gated = _GatedStore(); + await gated.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: gated, + launcher: (uri) async => true, + ); + final reported = []; + service.onSignInComplete = (success, code) => reported.add(code); + final uri = Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te'); + + final first = service.handleAuthCallback(uri); + final second = service.handleAuthCallback(uri); + gated.openGate(); + await Future.wait([first, second]); + + expect(reported, ['access_denied']); + expect(gated.pendingReads, 1, + reason: 'the duplicate must bounce before it reaches the store'); + expect(requests, isEmpty); + }); + + test('a wedged keystore still lets the decline reach the UI', () async { + // handleAuthCallback runs unawaited off the uriLinkStream, so a throwing + // deletePendingPkce would escape as a zone error and leave the UI + // spinning on a sign-in that has already been refused. + final wedged = _ThrowingDeletePendingStore(); + await wedged.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: wedged, + launcher: (uri) async => true, + ); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te')); + + expect(errorCode, 'access_denied'); + }); }); group('logging never leaks a secret', () { @@ -441,6 +497,205 @@ void main() { reason: 'the flow should log something'); }); }); + + group('authenticated calls', () { + Future signedIn(MockClient client) async { + store.token = 'f' * 64; + final service = buildService(client); + await service.init(); + return service; + } + + test('send both auth headers and a form-encoded body', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"nonce":"${'b' * 64}"}', 200))); + + // Mixed case in, UPPER out. + final lowerPubkey = 'abc123${'d' * 58}'; + await service.requestNonce(lowerPubkey); + + final request = requests.single; + expect(request.headers['Authorization'], 'Bearer ${'f' * 64}'); + expect(request.headers['X-MM-App-Token'], 'f' * 64); + expect(request.headers['Content-Type'], + contains('application/x-www-form-urlencoded')); + expect(request.url.queryParameters['action'], 'nonce'); + // pubkey is normalised to UPPER hex. + expect(request.bodyFields['pubkey'], lowerPubkey.toUpperCase()); + // Form, never JSON. + expect(request.body, isNot(startsWith('{'))); + }); + + test('nonce is accepted without an ok flag', () async { + final service = await signedIn(recordingClient((_) => + http.Response('{"nonce":"${'b' * 64}","expires_at":123}', 200))); + expect(await service.requestNonce('A' * 64), 'b' * 64); + }); + + test('a nonce that is not 64 hex is rejected', () async { + final service = await signedIn( + recordingClient((_) => http.Response('{"nonce":"short"}', 200))); + expect(await service.requestNonce('A' * 64), isNull); + }); + + test('link success maps to LinkSuccess and carries the nonce', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"ok":true,"pubkey":"${'A' * 64}"}', 200))); + + final result = await service.linkDevice( + pubkey: 'a' * 64, + nonce: 'b' * 64, + signature: 'c' * 128, + label: 'Ikoka Stick', + ); + + expect(result, isA()); + expect((result as LinkSuccess).already, isFalse); + final body = requests.single.bodyFields; + expect(body['pubkey'], 'A' * 64); + expect(body['nonce'], 'b' * 64); + expect(body['signature'], 'c' * 128); + expect(body['label'], 'Ikoka Stick'); + }); + + test('an idempotent relink reports already=true', () async { + final service = await signedIn(recordingClient((_) => http.Response( + '{"ok":true,"already":true,"pubkey":"${'A' * 64}"}', 200))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect((result as LinkSuccess).already, isTrue); + }); + + test('already_linked maps to LinkAlreadyLinkedOtherAccount', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"error":"already_linked"}', 409))); + expect( + await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128), + isA(), + ); + }); + + test('adoption_required carries the device count', () async { + final service = await signedIn(recordingClient((_) => + http.Response('{"error":"adoption_required","devices":4}', 409))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect(result, isA()); + expect((result as LinkAdoptionRequired).devices, 4); + }); + + test('bad_signature maps to LinkServerError with its code', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"error":"bad_signature"}', 400))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect((result as LinkServerError).code, 'bad_signature'); + expect(result.statusCode, 400); + }); + + test('401 token_invalid signs the user out and deletes the token', + () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"error":"token_invalid"}', 401))); + var reason = ''; + service.onSignedOut = (value) => reason = value; + + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + + expect(result, isA()); + expect(service.isSignedIn, isFalse); + expect(await store.readToken(), isNull); + expect(reason, 'token_invalid'); + }); + + test('a bare 401 is a network error and keeps the session', () async { + // Captive portals and WAFs emit body-less 401s. Treating those as + // "signed out" would log users out on hotel wifi. + final service = + await signedIn(recordingClient((_) => http.Response('', 401))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + + expect(result, isA()); + expect(service.isSignedIn, isTrue); + expect(await store.readToken(), 'f' * 64); + }); + + test('me populates the linked-device list', () async { + final service = await signedIn(recordingClient((_) => http.Response( + jsonEncode({ + 'ok': true, + 'user': {'id': 7, 'username': 'sparkgap'}, + 'pubkeys': [ + { + 'pubkey': ('e' * 64), + 'label': 'Stick', + 'name': 'WX7RAW', + 'points': 12, + } + ], + }), + 200))); + + expect(await service.refreshMe(), isTrue); + expect(service.linkedPubkeys.length, 1); + expect(service.linkedPubkeys.single.pubkey, ('e' * 64).toUpperCase()); + expect(service.linkedPubkeys.single.points, 12); + expect(service.account!.displayName, 'sparkgap'); + }); + + test('me is throttled to once an hour unless forced', () async { + final service = await signedIn(recordingClient((_) => http.Response( + '{"ok":true,"user":{"id":7,"username":"a"},"pubkeys":[]}', 200))); + + expect(await service.refreshMe(), isTrue); + expect(await service.refreshMe(), isFalse); + expect(requests.length, 1); + expect(await service.refreshMe(force: true), isTrue); + expect(requests.length, 2); + }); + + test('a non-object user in me is rejected, not thrown', () async { + // Same hostile shape the token exchange already defends against; me is + // awaited by the provider, so a TypeError here would surface as a + // failed reconnect rather than a skipped refresh. + final service = await signedIn(recordingClient( + (_) => http.Response('{"ok":true,"user":[],"pubkeys":[]}', 200))); + expect(await service.refreshMe(), isTrue); + expect(service.account, isNull); + }); + + test('unlink removes the pubkey from the cache', () async { + final service = await signedIn( + recordingClient((_) => http.Response('{"ok":true}', 200))); + expect(await service.unlinkDevice('a' * 64), isTrue); + expect(service.linkedPubkeys, isEmpty); + }); + + test('logout clears locally even when the revoke POST fails', () async { + final service = + await signedIn(recordingClient((_) => http.Response('boom', 500))); + + await service.logout(); + + expect(service.isSignedIn, isFalse); + expect(service.account, isNull); + expect(await store.readToken(), isNull); + // One attempt plus exactly one retry. + expect(requests.length, 2); + expect(requests.every((r) => r.url.queryParameters['action'] == 'logout'), + isTrue); + }); + + test('logout still sends the token it just deleted', () async { + final service = await signedIn( + recordingClient((_) => http.Response('{"ok":true}', 200))); + await service.logout(); + expect(requests.single.headers['X-MM-App-Token'], 'f' * 64); + }); + }); } /// A store whose `readPendingPkce` parks until [openGate] is called, so a test @@ -468,3 +723,11 @@ class _ThrowingWriteStore extends InMemoryTokenStore { Future writeToken(String value) async => throw StateError('keystore unavailable'); } + +/// A store that cannot burn the pending pair. `SecureTokenStore` swallows its +/// own delete failures, but the service must not depend on that. +class _ThrowingDeletePendingStore extends InMemoryTokenStore { + @override + Future deletePendingPkce() async => + throw StateError('keystore unavailable'); +} From af976f0e6646e0b5252cfa41564abcd115b84506 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:09:35 -0400 Subject: [PATCH 289/360] Widen the token scrub and make the link cache tests earn their keep --- lib/services/debug_file_logger.dart | 14 +++ .../debug_file_logger_scrub_test.dart | 26 ++++ .../services/portal_account_service_test.dart | 115 +++++++++++++++++- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/lib/services/debug_file_logger.dart b/lib/services/debug_file_logger.dart index 9a3d7397..940504cb 100644 --- a/lib/services/debug_file_logger.dart +++ b/lib/services/debug_file_logger.dart @@ -96,6 +96,16 @@ class DebugFileLogger { static final RegExp _codeParamPattern = RegExp( r'\b(code|code_verifier|code_challenge|token)=[A-Za-z0-9._~%+/=-]{20,}'); + /// The loose shapes the two patterns above miss: an UNQUOTED value, a `:` + /// separator, or a header name. `Map.toString()` renders a decoded body as + /// `{ok: true, token: fff…}` — no quotes, no `=` — and that is exactly what + /// `debug_submit_service.dart` writes on a failed upload. Case-insensitive so + /// the `X-MM-App-Token:` header is covered too. The 20-char floor keeps + /// `token: abc` and `code=7` intact. + static final RegExp _looseTokenPattern = RegExp( + r'''(\btoken['"]?\s*[:=]\s*)['"]?[A-Za-z0-9._~%+/=-]{20,}''', + caseSensitive: false); + /// Strip credential shapes out of a log line. /// /// Log files are uploaded verbatim with bug reports and debug logging stays @@ -103,9 +113,13 @@ class DebugFileLogger { /// "never log a secret". Public so it can be unit-tested. static String scrubSecrets(String message) { var out = message.replaceAll(_bearerPattern, 'Bearer '); + // Quoted JSON first, so the canonical `"token":""` shape is + // preserved rather than being half-eaten by the loose pattern. out = out.replaceAll(_tokenFieldPattern, '"token":""'); out = out.replaceAllMapped( _codeParamPattern, (match) => '${match.group(1)}='); + out = out.replaceAllMapped( + _looseTokenPattern, (match) => '${match.group(1)}'); return out; } diff --git a/test/services/debug_file_logger_scrub_test.dart b/test/services/debug_file_logger_scrub_test.dart index 6870c084..33fcf347 100644 --- a/test/services/debug_file_logger_scrub_test.dart +++ b/test/services/debug_file_logger_scrub_test.dart @@ -33,6 +33,32 @@ void main() { expect(scrubbed, isNot(contains('d' * 43))); }); + test('redacts an unquoted token in a rendered map', () { + // Dart's Map.toString() has neither quotes nor `=`, and + // debug_submit_service.dart logs a decoded response body exactly this way + // ("Full response: $data"). The quoted-JSON pattern never sees that shape. + final scrubbed = DebugFileLogger.scrubSecrets( + 'Full response: {ok: true, token: ${'f' * 64}}'); + expect(scrubbed, contains('token: ')); + expect(scrubbed, isNot(contains('f' * 64))); + }); + + test('redacts the X-MM-App-Token header shape', () { + final scrubbed = + DebugFileLogger.scrubSecrets('X-MM-App-Token: ${'a1b2c3d4' * 8}'); + expect(scrubbed, 'X-MM-App-Token: '); + }); + + test('leaves a short token value alone', () { + expect(DebugFileLogger.scrubSecrets('token: abc'), 'token: abc'); + }); + + test('leaves a wire tag untouched', () { + // MM:<10 base64url> is not a secret and shows up constantly in TX logs. + const line = '[PING] on-air body MM:YVNPAr5OIw'; + expect(DebugFileLogger.scrubSecrets(line), line); + }); + test('leaves ordinary log lines untouched', () { const line = '[CONN] Frame received (7 bytes): 13 00 80 00 00 00'; expect(DebugFileLogger.scrubSecrets(line), line); diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 0e77c210..4f3365ba 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -506,6 +506,19 @@ void main() { return service; } + /// Answers per `?action=`, so one test can seed the cache with `me` and + /// then exercise `link`/`unlink` against a cache that is not empty. + MockClient routedClient(Map byAction) => + recordingClient((request) => + byAction[request.url.queryParameters['action']] ?? + http.Response('{"error":"unexpected_action"}', 500)); + + String meBody(List> pubkeys) => jsonEncode({ + 'ok': true, + 'user': {'id': 7, 'username': 'sparkgap'}, + 'pubkeys': pubkeys, + }); + test('send both auth headers and a form-encoded body', () async { final service = await signedIn(recordingClient( (_) => http.Response('{"nonce":"${'b' * 64}"}', 200))); @@ -667,11 +680,105 @@ void main() { expect(service.account, isNull); }); - test('unlink removes the pubkey from the cache', () async { - final service = await signedIn( - recordingClient((_) => http.Response('{"ok":true}', 200))); + test('a successful link lands in the cache exactly once', () async { + final service = await signedIn(routedClient({ + 'link': http.Response( + '{"ok":true,"pubkey":"${'A' * 64}","name":"WX7RAW","points":5}', + 200), + })); + var changed = 0; + service.onAccountChanged = () => changed++; + + expect( + await service.linkDevice( + pubkey: 'a' * 64, + nonce: 'b' * 64, + signature: 'c' * 128, + label: 'Ikoka Stick'), + isA(), + ); + + final entry = service.linkedPubkeys.single; + expect(entry.pubkey, 'A' * 64); + expect(entry.label, 'Ikoka Stick'); + expect(entry.name, 'WX7RAW'); + expect(entry.points, 5); + expect(changed, 1); + + // A relink of the SAME radio must not duplicate the entry, and must not + // re-notify: the UI would rebuild the device list for nothing. + expect( + await service.linkDevice( + pubkey: 'a' * 64, nonce: 'b' * 64, signature: 'c' * 128), + isA(), + ); + expect(service.linkedPubkeys.length, 1); + expect(changed, 1, reason: 'nothing was added the second time'); + }); + + test('a link with no label caches an empty label', () async { + final service = await signedIn(routedClient({ + 'link': http.Response('{"ok":true,"pubkey":"${'A' * 64}"}', 200), + })); + await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect(service.linkedPubkeys.single.label, ''); + expect(service.linkedPubkeys.single.name, ''); + expect(service.linkedPubkeys.single.points, 0); + }); + + test('unlink removes only the named pubkey and keeps the rest', () async { + final service = await signedIn(routedClient({ + 'me': http.Response( + meBody([ + { + 'pubkey': 'a' * 64, + 'label': 'Stick', + 'name': 'WX7RAW', + 'points': 12 + }, + { + 'pubkey': 'e' * 64, + 'label': 'Nano', + 'name': 'WX7NAN', + 'points': 3 + }, + ]), + 200), + 'unlink': http.Response('{"ok":true}', 200), + })); + + // Seed a NON-EMPTY cache first — unlinking out of an empty list passes + // even when the removal never happens. + expect(await service.refreshMe(), isTrue); + expect(service.linkedPubkeys.length, 2); + + var changed = 0; + service.onAccountChanged = () => changed++; + expect(await service.unlinkDevice('a' * 64), isTrue); - expect(service.linkedPubkeys, isEmpty); + + expect(service.linkedPubkeys.map((p) => p.pubkey).toList(), + [('e' * 64).toUpperCase()]); + expect(changed, 1); + }); + + test('a refused unlink leaves the cache alone', () async { + final service = await signedIn(routedClient({ + 'me': http.Response( + meBody([ + {'pubkey': 'a' * 64, 'label': '', 'name': '', 'points': 0} + ]), + 200), + 'unlink': http.Response('{"error":"not_found"}', 404), + })); + expect(await service.refreshMe(), isTrue); + var changed = 0; + service.onAccountChanged = () => changed++; + + expect(await service.unlinkDevice('a' * 64), isFalse); + expect(service.linkedPubkeys.length, 1); + expect(changed, 0); }); test('logout clears locally even when the revoke POST fails', () async { From 517b2075870e5d592ce4f3a1ffb8f28b4b100596 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:16:07 -0400 Subject: [PATCH 290/360] Decide once whether a radio should be offered a link --- lib/services/link_decision.dart | 50 +++++++++++++++++++ test/services/link_decision_test.dart | 70 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 lib/services/link_decision.dart create mode 100644 test/services/link_decision_test.dart diff --git a/lib/services/link_decision.dart b/lib/services/link_decision.dart new file mode 100644 index 00000000..d7ecb97f --- /dev/null +++ b/lib/services/link_decision.dart @@ -0,0 +1,50 @@ +/// What to do about offering an account link for the freshly connected radio. +enum LinkFlowDecision { skip, prompt } + +/// Pure decision for the post-connection account-link offer. +/// +/// Kept free of clocks, I/O and provider state so the whole truth table is +/// testable. The time-dependent parts (retry backoff, per-pubkey attempt cap) +/// stay in `AppStateProvider`, which checks them immediately before calling +/// this. +/// +/// Linking is strictly non-fatal: EVERY uncertainty resolves to +/// [LinkFlowDecision.skip]. +/// +/// * [loggedIn] — a portal token is held. +/// * [offlineMode] / [anonymousMode] — user has opted out of identifying the +/// session; do not attach it to an account. +/// * [autoPingActive] — a wardrive is running (or is about to be restored after +/// a reconnect); never interrupt a drive with a modal. +/// * [autoReconnecting] — a BLE flap mid-drive must not re-prompt. +/// * [pubkey] — the radio's public key; null/empty means nothing to link. +/// * [declined] — the user said no for this pubkey and it was persisted. +/// * [linked] — the cache already has this pubkey bound to the account. +/// * [signUnsupported] — the firmware answered ERR to CMD_SIGN_START, or gave +/// repeated bad signatures. Re-prompting burns rate-limited nonces. +/// * [promptedThisAppSession] — asked once already since app launch. This is +/// per app session, NOT per connection, so a reconnect cannot re-ask. +LinkFlowDecision decideLinkFlow({ + required bool loggedIn, + required bool offlineMode, + required bool anonymousMode, + required bool autoPingActive, + required bool autoReconnecting, + required String? pubkey, + required bool declined, + required bool linked, + required bool signUnsupported, + required bool promptedThisAppSession, +}) { + if (!loggedIn) return LinkFlowDecision.skip; + if (offlineMode) return LinkFlowDecision.skip; + if (anonymousMode) return LinkFlowDecision.skip; + if (autoPingActive) return LinkFlowDecision.skip; + if (autoReconnecting) return LinkFlowDecision.skip; + if (pubkey == null || pubkey.isEmpty) return LinkFlowDecision.skip; + if (declined) return LinkFlowDecision.skip; + if (linked) return LinkFlowDecision.skip; + if (signUnsupported) return LinkFlowDecision.skip; + if (promptedThisAppSession) return LinkFlowDecision.skip; + return LinkFlowDecision.prompt; +} diff --git a/test/services/link_decision_test.dart b/test/services/link_decision_test.dart new file mode 100644 index 00000000..93042354 --- /dev/null +++ b/test/services/link_decision_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/link_decision.dart'; + +/// The "strictly non-fatal" contract in table form. Exactly ONE combination +/// prompts; every other row is a silent skip. +void main() { + const goodPubkey = + 'AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA'; + + LinkFlowDecision call({ + bool loggedIn = true, + bool offlineMode = false, + bool anonymousMode = false, + bool autoPingActive = false, + bool autoReconnecting = false, + String? pubkey = goodPubkey, + bool declined = false, + bool linked = false, + bool signUnsupported = false, + bool promptedThisAppSession = false, + }) => + decideLinkFlow( + loggedIn: loggedIn, + offlineMode: offlineMode, + anonymousMode: anonymousMode, + autoPingActive: autoPingActive, + autoReconnecting: autoReconnecting, + pubkey: pubkey, + declined: declined, + linked: linked, + signUnsupported: signUnsupported, + promptedThisAppSession: promptedThisAppSession, + ); + + test('the all-clear row prompts', () { + expect(call(), LinkFlowDecision.prompt); + }); + + final skipRows = { + 'not signed in': () => call(loggedIn: false), + 'offline mode': () => call(offlineMode: true), + 'anonymous mode': () => call(anonymousMode: true), + 'auto-ping running': () => call(autoPingActive: true), + 'auto-reconnecting': () => call(autoReconnecting: true), + 'no public key': () => call(pubkey: null), + 'empty public key': () => call(pubkey: ''), + 'declined before': () => call(declined: true), + 'already linked': () => call(linked: true), + 'firmware cannot sign': () => call(signUnsupported: true), + 'already prompted this app session': () => + call(promptedThisAppSession: true), + }; + + skipRows.forEach((name, invoke) { + test('skips when $name', () { + expect(invoke(), LinkFlowDecision.skip); + }); + }); + + test('several blockers at once still skip', () { + expect( + call(loggedIn: false, offlineMode: true, linked: true), + LinkFlowDecision.skip, + ); + }); + + test('a lowercase pubkey is still acceptable input', () { + expect(call(pubkey: goodPubkey.toLowerCase()), LinkFlowDecision.prompt); + }); +} From 45ed5f983adaaf80771ba0c511d87e9ab04e92ae Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:24:04 -0400 Subject: [PATCH 291/360] Remember which account the app is signed in to --- lib/providers/app_state_provider.dart | 202 ++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7c514e04..ffc6dad6 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:app_links/app_links.dart'; import 'package:uuid/uuid.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart' show SharePlus, ShareParams, XFile; @@ -51,6 +52,7 @@ 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 '../services/portal_account_service.dart'; import '../utils/constants.dart'; import '../utils/geo_validation.dart'; import '../utils/ping_colors.dart'; @@ -407,6 +409,39 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Auto-reconnect state bool _userRequestedDisconnect = false; bool _isAutoReconnecting = false; + + // ============================================ + // MyMeshMapper account + companion linking + // ============================================ + late final PortalAccountService _portalAccountService; + + /// Cached portal identity (mirrors Hive `portal_account_info`). + PortalAccount? _portalAccount; + + /// UPPER 64-hex pubkeys this account owns (mirrors `portal_linked_pubkeys`). + List _portalLinkedPubkeys = []; + + /// UPPER pubkey -> declined. Persisted; survives sign-out (device pref). + Map _portalLinkDeclinedDevices = {}; + + /// UPPER pubkey -> firmware cannot sign. Persisted; survives sign-out. + Map _portalSignUnsupportedDevices = {}; + + /// Prompted once per pubkey per APP SESSION (not per connection) — a BLE flap + /// plus auto-reconnect must never re-ask mid-drive. + final Set _portalPromptedThisSession = {}; + + // Backing field for [portalLinkPromptPending]. Not final: the link flow + // sets it when a radio needs the one-tap prompt. + // ignore: prefer_final_fields + bool _portalLinkPromptPending = false; + + static const String _portalAccountKey = 'portal_account_info'; + static const String _portalLinkedPubkeysKey = 'portal_linked_pubkeys'; + static const String _portalDeclinedKey = 'portal_link_declined_devices'; + static const String _portalSignUnsupportedKey = + 'portal_sign_unsupported_devices'; + int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; @@ -509,6 +544,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Diagnostic: timers can be suspended while backgrounded; on resume, log // any countdown timer stuck past its deadline (intermittent ping lockout). _logStuckTimers('resume'); + // iOS can deliver the portal callback while the isolate is suspended. + unawaited(_portalAccountService.checkLinkOnResume()); } else if (state == AppLifecycleState.paused) { debugLog('[APP] App paused (backgrounded)'); // Save offline pings immediately on pause to prevent data loss if OS kills app @@ -1056,6 +1093,31 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; + + /// True when a MyMeshMapper portal token is held. + bool get isPortalLoggedIn => _portalAccountService.isSignedIn; + + /// Cached portal identity, or null when signed out. + PortalAccount? get portalAccount => _portalAccount; + + /// How many radios this account owns (from the last `me` refresh). + int get portalLinkedDeviceCount => _portalLinkedPubkeys.length; + + /// True when the user has declined linking for at least one radio. + bool get hasPortalLinkDeclines => _portalLinkDeclinedDevices.isNotEmpty; + + /// True when the currently connected radio is already bound to the account. + bool get isCurrentDeviceLinked { + final pubkey = _devicePublicKey?.toUpperCase(); + return pubkey != null && _portalLinkedPubkeys.contains(pubkey); + } + + /// The one-tap link prompt is waiting to be shown. + bool get portalLinkPromptPending => _portalLinkPromptPending; + + /// Device name for the link prompt copy (anonymity-aware). + String? get portalLinkPromptDeviceName => displayDeviceName; + int get reconnectAttempt => _reconnectAttempt; // Zone grace period getters @@ -2338,6 +2400,36 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; + // MyMeshMapper portal account. Mobile only — the sign-in flow needs an + // OS-registered URL scheme, which the web build cannot have. + _portalAccountService = PortalAccountService( + appLinks: kIsWeb ? null : AppLinks(), + ); + _portalAccountService.deviceLabelProvider = _portalDeviceLabel; + _portalAccountService.onAccountChanged = () { + _portalAccount = _portalAccountService.account; + _portalLinkedPubkeys = _portalAccountService.linkedPubkeys + .map((entry) => entry.pubkey.toUpperCase()) + .toList(); + unawaited(_savePortalAccountState()); + // Account state is NOT map state — plain notify only (Critical Rule 9). + notifyListeners(); + }; + _portalAccountService.onSignedOut = (reason) { + debugLog('[ACCOUNT] Signed out ($reason) — clearing the cached identity'); + _portalAccount = null; + _portalLinkedPubkeys = []; + // Declines and sign-unsupported are DEVICE preferences, not account + // data: they deliberately survive a sign-out. + unawaited(_savePortalAccountState()); + notifyListeners(); + }; + _portalAccountService.onSignInComplete = (success, errorCode) { + debugLog('[ACCOUNT] Sign-in complete: success=$success, ' + 'error=${errorCode ?? 'none'}'); + notifyListeners(); + }; + // Set up session error callback for auto-disconnect _apiService.onSessionError = (reason, message) async { debugError('[APP] Session error from API: $reason - $message'); @@ -2470,6 +2562,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _loadDeviceAntennaPreferences(); await _loadDevicePowerOverrides(); await _loadDeviceRealNames(); + await _loadPortalAccountState(); + await _portalAccountService.init(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -8803,6 +8897,113 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // MyMeshMapper Account Persistence + // ============================================ + + Future _loadPortalAccountState() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final rawAccount = box.get(_portalAccountKey); + if (rawAccount is Map) { + final cached = PortalAccount.fromCache(rawAccount); + if (cached != null) { + _portalAccount = cached; + _portalAccountService.hydrateAccount(cached); + } + } + + final rawLinked = box.get(_portalLinkedPubkeysKey); + if (rawLinked is List) { + _portalLinkedPubkeys = + rawLinked.map((entry) => entry.toString().toUpperCase()).toList(); + } + + final rawDeclined = box.get(_portalDeclinedKey); + if (rawDeclined is Map) { + _portalLinkDeclinedDevices = rawDeclined.map( + (key, value) => MapEntry(key.toString().toUpperCase(), value == true), + ); + } + + final rawUnsupported = box.get(_portalSignUnsupportedKey); + if (rawUnsupported is Map) { + _portalSignUnsupportedDevices = rawUnsupported.map( + (key, value) => MapEntry(key.toString().toUpperCase(), value == true), + ); + } + + debugLog('[ACCOUNT] Loaded state: ' + 'account=${_portalAccount?.username ?? 'none'}, ' + 'linked=${_portalLinkedPubkeys.length}, ' + 'declined=${_portalLinkDeclinedDevices.length}, ' + 'signUnsupported=${_portalSignUnsupportedDevices.length}'); + } catch (e) { + debugWarn('[ACCOUNT] Failed to load portal account state: $e'); + } + } + + Future _savePortalAccountState() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final account = _portalAccount; + if (account == null) { + await box.delete(_portalAccountKey); + } else { + await box.put(_portalAccountKey, account.toCache()); + } + await box.put(_portalLinkedPubkeysKey, _portalLinkedPubkeys); + await box.put(_portalDeclinedKey, _portalLinkDeclinedDevices); + await box.put(_portalSignUnsupportedKey, _portalSignUnsupportedDevices); + await box.flush(); + } catch (e) { + debugWarn('[ACCOUNT] Failed to save portal account state: $e'); + } + } + + /// Friendly label sent with `token` and `link` (server caps it at 60). + String? _portalDeviceLabel() { + final name = displayDeviceName; + if (name == null || name.isEmpty) return null; + return name.length <= 60 ? name : name.substring(0, 60); + } + + // ============================================ + // MyMeshMapper Account API (UI-facing) + // ============================================ + + /// Open the portal consent page in the system browser. + Future beginPortalSignIn() async { + if (kIsWeb) return false; + debugLog('[ACCOUNT] Sign-in requested'); + return _portalAccountService.beginSignIn(); + } + + /// Sign out locally and revoke the token server-side (best effort). + Future portalSignOut() async { + debugLog('[ACCOUNT] Sign-out requested'); + await _portalAccountService.logout(); + } + + /// Refresh the identity and linked-device list (throttled to 1/hour). + Future refreshPortalAccount({bool force = false}) async { + await _portalAccountService.refreshMe(force: force); + } + + /// Clear every persisted decline so declined radios are offered again. + Future resetLinkPromptDeclines() async { + debugLog('[ACCOUNT] Clearing ' + '${_portalLinkDeclinedDevices.length} link decline(s)'); + _portalLinkDeclinedDevices.clear(); + _portalPromptedThisSession.clear(); + await _savePortalAccountState(); + notifyListeners(); + } + // ============================================ // Device Real Name Persistence (Anonymous Mode Recovery) // ============================================ @@ -9253,6 +9454,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _gpsService.dispose(); _apiQueueService.dispose(); _customApiService.dispose(); + _portalAccountService.dispose(); _offlineSessionService.dispose(); _apiService.dispose(); _bluetoothService.dispose(); From 30e8a1461a3e28eaa561adbc4d80522585f55aa0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:36:16 -0400 Subject: [PATCH 292/360] Offer to link the radio once a connection has settled --- lib/providers/app_state_provider.dart | 292 +++++++++++++++++++++++++- 1 file changed, 291 insertions(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ffc6dad6..9d941990 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -36,6 +36,7 @@ import '../services/transport/tcp_service.dart'; import '../services/device_model_service.dart'; import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; +import '../services/link_decision.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; @@ -433,9 +434,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Backing field for [portalLinkPromptPending]. Not final: the link flow // sets it when a radio needs the one-tap prompt. - // ignore: prefer_final_fields bool _portalLinkPromptPending = false; + /// The pubkey the pending prompt belongs to. Captured when the prompt is + /// raised so a mid-prompt reconnect cannot link the wrong radio. + String? _portalLinkPromptPubkey; + + /// UPPER pubkey -> consecutive link failures. Drives the retry backoff and + /// the attempt cap. In-memory only — a restart is allowed a clean slate. + final Map _portalLinkAttempts = {}; + + /// UPPER pubkey -> earliest time the next link attempt may run. + final Map _portalLinkRetryAfter = {}; + + /// Give up offering after this many failed attempts for one pubkey. + static const int _portalLinkMaxAttempts = 5; + + /// UPPER pubkey -> how many times CMD_SIGN_START answered ERR. In-memory: + /// two strikes are required before the verdict is persisted (see + /// [_performDeviceLink]). + final Map _portalUnsupportedStrikes = {}; + static const String _portalAccountKey = 'portal_account_info'; static const String _portalLinkedPubkeysKey = 'portal_linked_pubkeys'; static const String _portalDeclinedKey = 'portal_link_declined_devices'; @@ -3706,6 +3725,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? tcpPort, String? serialPortPath, }) async { + // Per-connection account-link flags. Reset HERE, not in a teardown path: + // this is the ONE point all four transports funnel through, whereas + // disconnect(), _fullDisconnectCleanup() and _startAutoReconnect() each + // miss at least one of the others. + _portalLinkPromptPending = false; + _portalLinkPromptPubkey = null; + if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { final matchedDevice = connectionResult.deviceModel!; @@ -4395,6 +4421,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (validation != PingValidation.valid) { debugLog('[CONN] Ping validation after connect: $validation'); } + + // MyMeshMapper account link offer. Strictly non-fatal: it must never + // block, slow, or fail a connection, so it is fire-and-forget inside its + // own guard. + try { + unawaited(_maybeOfferDeviceLink()); + } catch (e) { + debugWarn('[ACCOUNT] Link offer failed to start: $e'); + } } /// Create and wire up unified RX handler @@ -5214,6 +5249,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnecting; notifyListeners(); + // Release the sign gate before any teardown write (advert-name restore, + // path-hash restore, flood scope, channel deletion) is parked behind it. + _meshCoreConnection?.abortPendingSign(); + // Cancel idle disconnect timer _cancelIdleDisconnectTimer(); @@ -8965,6 +9004,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// 8-char uppercase prefix — full public keys never reach a log. Duplicated + /// from `PortalAccountService._pk` on purpose: that one is private and this + /// is the only redaction the provider is allowed to log a pubkey through. + String _pkPrefix(String? pubkey) { + if (pubkey == null || pubkey.isEmpty) return 'none'; + final upper = pubkey.toUpperCase(); + return upper.length <= 8 ? upper : upper.substring(0, 8); + } + /// Friendly label sent with `token` and `link` (server caps it at 60). String? _portalDeviceLabel() { final name = displayDeviceName; @@ -9004,6 +9052,248 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Decide whether to surface the one-tap link prompt for the radio that just + /// connected. Never throws, never blocks anything, never shows an error. + Future _maybeOfferDeviceLink() async { + if (kIsWeb) return; + final pubkey = _devicePublicKey?.toUpperCase(); + + // Time-dependent guards live here so decideLinkFlow() stays pure. + if (pubkey != null) { + final retryAfter = _portalLinkRetryAfter[pubkey]; + if (retryAfter != null && DateTime.now().isBefore(retryAfter)) { + debugLog('[ACCOUNT] Link offer skipped for ${_pkPrefix(pubkey)}: ' + 'backing off after a failure'); + return; + } + if ((_portalLinkAttempts[pubkey] ?? 0) >= _portalLinkMaxAttempts) { + debugLog('[ACCOUNT] Link offer skipped for ${_pkPrefix(pubkey)}: ' + 'attempt cap reached'); + return; + } + } + + final decision = decideLinkFlow( + loggedIn: isPortalLoggedIn, + offlineMode: _preferences.offlineMode, + anonymousMode: _preferences.anonymousMode, + // _autoPingWasEnabled covers the reconnect path, where auto-ping is + // restored 500ms after the connection settles. + autoPingActive: _autoPingEnabled || _autoPingWasEnabled, + autoReconnecting: _isAutoReconnecting, + pubkey: pubkey, + declined: pubkey != null && _portalLinkDeclinedDevices[pubkey] == true, + linked: pubkey != null && _portalLinkedPubkeys.contains(pubkey), + signUnsupported: + pubkey != null && _portalSignUnsupportedDevices[pubkey] == true, + promptedThisAppSession: + pubkey != null && _portalPromptedThisSession.contains(pubkey), + ); + + if (decision == LinkFlowDecision.skip) { + debugLog('[ACCOUNT] Link offer skipped for ${_pkPrefix(pubkey)}'); + return; + } + + _portalPromptedThisSession.add(pubkey!); + _portalLinkPromptPubkey = pubkey; + _portalLinkPromptPending = true; + debugLog('[ACCOUNT] Offering a device link for ${_pkPrefix(pubkey)}'); + notifyListeners(); + } + + /// The user answered the link prompt. + Future respondToLinkPrompt(bool accepted) async { + final pubkey = _portalLinkPromptPubkey; + _portalLinkPromptPending = false; + _portalLinkPromptPubkey = null; + notifyListeners(); + + if (pubkey == null) { + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + if (!accepted) { + debugLog('[ACCOUNT] Link declined for ${_pkPrefix(pubkey)} — persisting'); + _portalLinkDeclinedDevices[pubkey] = true; + await _savePortalAccountState(); + notifyListeners(); + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + return _performDeviceLink(pubkey); + } + + /// "Link now" from Settings. Bypasses the decline list and the backoff cap — + /// the user asked for this explicitly. + Future startManualDeviceLink() async { + final pubkey = _devicePublicKey?.toUpperCase(); + if (kIsWeb || pubkey == null || !isPortalLoggedIn) { + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + debugLog('[ACCOUNT] Manual link requested for ${_pkPrefix(pubkey)}'); + _portalLinkDeclinedDevices.remove(pubkey); + _portalLinkAttempts.remove(pubkey); + _portalLinkRetryAfter.remove(pubkey); + await _savePortalAccountState(); + return _performDeviceLink(pubkey); + } + + /// Unbind the connected radio from the account. + Future unlinkCurrentDevice() async { + final pubkey = _devicePublicKey?.toUpperCase(); + if (pubkey == null) return false; + final ok = await _portalAccountService.unlinkDevice(pubkey); + if (ok) { + _portalLinkedPubkeys.remove(pubkey); + await _savePortalAccountState(); + notifyListeners(); + } + return ok; + } + + /// nonce -> radio signature -> link. Every failure is silent to the user. + Future _performDeviceLink(String pubkey) async { + final connection = _meshCoreConnection; + if (connection == null) { + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + + final nonceHex = await _portalAccountService.requestNonce(pubkey); + if (nonceHex == null) return _recordLinkFailure(pubkey, 'nonce'); + + // Never hand the radio server-supplied bytes without checking their shape. + // The portal lane is specified as EXACTLY 32 random bytes (64 hex); the + // radio is a signing oracle, so anything else is refused. + if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(nonceHex)) { + debugWarn('[ACCOUNT] Nonce refused: not 64 hex characters'); + return _recordLinkFailure(pubkey, 'bad_nonce'); + } + final nonceBytes = _hexToBytes(nonceHex); + if (nonceBytes.length != 32) { + debugWarn('[ACCOUNT] Nonce refused: ${nonceBytes.length} bytes, ' + 'expected 32'); + return _recordLinkFailure(pubkey, 'bad_nonce'); + } + + Uint8List signature; + try { + // The radio signs the RAW 32 bytes, never their hex text. + signature = await connection.sign(nonceBytes); + } on SignException catch (e) { + if (e.code == 'unsupported') { + // Two strikes before this is written down. A stats/battery ERR that + // was already in flight when the sign started is misattributed as + // 'unsupported' (see the ERR branch in connection.dart) — one stray + // ERR must never permanently disable linking for a radio. + final strikes = (_portalUnsupportedStrikes[pubkey] ?? 0) + 1; + _portalUnsupportedStrikes[pubkey] = strikes; + if (strikes >= 2) { + debugLog('[ACCOUNT] Radio has no CMD_SIGN — link disabled for ' + '${_pkPrefix(pubkey)}'); + _portalSignUnsupportedDevices[pubkey] = true; + await _savePortalAccountState(); + } else { + debugLog('[ACCOUNT] CMD_SIGN_START answered ERR for ' + '${_pkPrefix(pubkey)} (strike $strikes of 2) — could be a ' + 'stray ERR, not persisting yet'); + } + } else { + debugWarn('[ACCOUNT] Sign failed (${e.code}) — staying silent'); + } + return const PortalLinkOutcome(PortalLinkStatus.failed); + } catch (e) { + debugWarn('[ACCOUNT] Sign failed (${e.runtimeType}) — staying silent'); + return const PortalLinkOutcome(PortalLinkStatus.failed); + } + + final result = await _portalAccountService.linkDevice( + pubkey: pubkey, + nonce: nonceHex, + signature: _bytesToHex(signature), + label: _portalDeviceLabel(), + ); + + switch (result) { + case LinkSuccess(:final already): + debugLog('[ACCOUNT] Device linked (already=$already)'); + if (!_portalLinkedPubkeys.contains(pubkey)) { + _portalLinkedPubkeys.add(pubkey); + } + _portalLinkAttempts.remove(pubkey); + _portalLinkRetryAfter.remove(pubkey); + await _savePortalAccountState(); + notifyListeners(); + return PortalLinkOutcome( + PortalLinkStatus.linked, + accountName: _portalAccount?.displayName, + ); + + case LinkAdoptionRequired(:final devices): + debugLog('[ACCOUNT] Link needs browser adoption first ' + '($devices placeholder device(s))'); + return PortalLinkOutcome( + PortalLinkStatus.adoptionRequired, + adoptionDeviceCount: devices, + ); + + case LinkAlreadyLinkedOtherAccount(): + debugLog('[ACCOUNT] Radio belongs to another account — ' + 'persisting a decline so we stop asking'); + _portalLinkDeclinedDevices[pubkey] = true; + await _savePortalAccountState(); + notifyListeners(); + return const PortalLinkOutcome( + PortalLinkStatus.alreadyLinkedOtherAccount); + + case LinkUnauthorized(): + debugLog('[ACCOUNT] Link unauthorized — already signed out, silent'); + return const PortalLinkOutcome(PortalLinkStatus.unauthorized); + + case LinkNetworkError(:final detail): + debugWarn('[ACCOUNT] Link network error: $detail'); + return _recordLinkFailure(pubkey, 'network'); + + case LinkServerError(:final code, :final statusCode): + debugWarn('[ACCOUNT] Link server error: $code (HTTP $statusCode)'); + final outcome = _recordLinkFailure(pubkey, code); + if (code == 'bad_signature' && + (_portalLinkAttempts[pubkey] ?? 0) >= 2) { + // Repeated bad signatures mean this firmware signs something else. + // Stop burning rate-limited nonces on it. + debugLog('[ACCOUNT] Repeated bad_signature — marking sign ' + 'unsupported for ${_pkPrefix(pubkey)}'); + _portalSignUnsupportedDevices[pubkey] = true; + await _savePortalAccountState(); + } + return outcome; + } + } + + /// Count a failure and schedule the next allowed attempt (30s, 1m, 2m, 4m, + /// 8m, capped at 30m). Nothing is shown to the user — the next connection + /// simply tries again. + PortalLinkOutcome _recordLinkFailure(String pubkey, String reason) { + final attempts = (_portalLinkAttempts[pubkey] ?? 0) + 1; + _portalLinkAttempts[pubkey] = attempts; + final backoffSeconds = math.min(30 * (1 << (attempts - 1)), 1800); + _portalLinkRetryAfter[pubkey] = + DateTime.now().add(Duration(seconds: backoffSeconds)); + debugLog('[ACCOUNT] Link attempt $attempts failed ($reason) for ' + '${_pkPrefix(pubkey)} — next try in ${backoffSeconds}s'); + return const PortalLinkOutcome(PortalLinkStatus.failed); + } + + Uint8List _hexToBytes(String hex) { + final length = hex.length ~/ 2; + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return bytes; + } + + String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + // ============================================ // Device Real Name Persistence (Anonymous Mode Recovery) // ============================================ From 01fe3104fe3e7b5c7523cddeb8a88413cad76096 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:46:28 -0400 Subject: [PATCH 293/360] Let a working radio clear its own sign verdict --- lib/providers/app_state_provider.dart | 51 ++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 9d941990..7353a45e 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -452,9 +452,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// UPPER pubkey -> how many times CMD_SIGN_START answered ERR. In-memory: /// two strikes are required before the verdict is persisted (see - /// [_performDeviceLink]). + /// [_performDeviceLink]). Cleared the moment the radio signs successfully. final Map _portalUnsupportedStrikes = {}; + /// UPPER pubkey -> how many times the server rejected our signature. Kept + /// separate from [_portalLinkAttempts] on purpose: that counter also holds + /// nonce and network failures, which say nothing about what the radio signs. + final Map _portalBadSignatureStrikes = {}; + static const String _portalAccountKey = 'portal_account_info'; static const String _portalLinkedPubkeysKey = 'portal_linked_pubkeys'; static const String _portalDeclinedKey = 'portal_link_declined_devices'; @@ -9205,6 +9210,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return const PortalLinkOutcome(PortalLinkStatus.failed); } + // The radio just produced a signature, which disproves "this firmware has + // no CMD_SIGN". Drop the strike so an earlier stray ERR cannot combine + // with a later one to condemn a radio that demonstrably signs. + _portalUnsupportedStrikes.remove(pubkey); + final result = await _portalAccountService.linkDevice( pubkey: pubkey, nonce: nonceHex, @@ -9220,6 +9230,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _portalLinkAttempts.remove(pubkey); _portalLinkRetryAfter.remove(pubkey); + // A signature the server accepted retires every "this radio cannot + // sign" verdict, including one already written to disk — otherwise a + // single later stray ERR would re-condemn a radio that just linked. + _portalUnsupportedStrikes.remove(pubkey); + _portalBadSignatureStrikes.remove(pubkey); + _portalSignUnsupportedDevices.remove(pubkey); await _savePortalAccountState(); notifyListeners(); return PortalLinkOutcome( @@ -9255,22 +9271,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case LinkServerError(:final code, :final statusCode): debugWarn('[ACCOUNT] Link server error: $code (HTTP $statusCode)'); final outcome = _recordLinkFailure(pubkey, code); - if (code == 'bad_signature' && - (_portalLinkAttempts[pubkey] ?? 0) >= 2) { - // Repeated bad signatures mean this firmware signs something else. - // Stop burning rate-limited nonces on it. - debugLog('[ACCOUNT] Repeated bad_signature — marking sign ' - 'unsupported for ${_pkPrefix(pubkey)}'); - _portalSignUnsupportedDevices[pubkey] = true; - await _savePortalAccountState(); + if (code == 'bad_signature') { + // Count ONLY bad signatures here. _portalLinkAttempts also holds + // nonce and network failures, so reading it would let two unrelated + // network blips plus one genuine rejection condemn the radio. + final strikes = (_portalBadSignatureStrikes[pubkey] ?? 0) + 1; + _portalBadSignatureStrikes[pubkey] = strikes; + if (strikes >= 2) { + // Repeated bad signatures mean this firmware signs something else. + // Stop burning rate-limited nonces on it. + debugLog('[ACCOUNT] Repeated bad_signature — marking sign ' + 'unsupported for ${_pkPrefix(pubkey)}'); + _portalSignUnsupportedDevices[pubkey] = true; + await _savePortalAccountState(); + } else { + debugLog('[ACCOUNT] Server rejected the signature for ' + '${_pkPrefix(pubkey)} (strike $strikes of 2)'); + } } return outcome; } } - /// Count a failure and schedule the next allowed attempt (30s, 1m, 2m, 4m, - /// 8m, capped at 30m). Nothing is shown to the user — the next connection - /// simply tries again. + /// Count a failure and schedule the next allowed attempt: 30s, 1m, 2m, 4m, + /// 8m — the whole ladder, because [_portalLinkMaxAttempts] stops the offer + /// at 5. The 30m clamp below is a guard for a raised cap, not a step anyone + /// reaches today. Nothing is shown to the user — the next connection simply + /// tries again. PortalLinkOutcome _recordLinkFailure(String pubkey, String reason) { final attempts = (_portalLinkAttempts[pubkey] ?? 0) + 1; _portalLinkAttempts[pubkey] = attempts; From 6d8eafde4a78029b12a397cc8f4eeb9ea1bf19d0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:52:45 -0400 Subject: [PATCH 294/360] Let the browser hand the sign-in back to the app --- android/app/src/main/AndroidManifest.xml | 13 ++++++++++++- android/app/src/main/res/xml/backup_rules.xml | 9 +++++++++ .../app/src/main/res/xml/data_extraction_rules.xml | 10 ++++++++++ ios/Runner/Info.plist | 13 +++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ab81f274..cba07486 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,7 +33,9 @@ + android:icon="@mipmap/ic_launcher" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..f7ceff18 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..c2c24978 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8722daf1..8aae773a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -158,5 +158,18 @@ NSSupportsLiveActivities + CFBundleURLTypes + + + CFBundleURLName + net.meshmapper.app.auth + CFBundleTypeRole + Editor + CFBundleURLSchemes + + meshmapper-auth + + + From e807b64b5b283417a534220c890c129116e4fc9b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 08:59:05 -0400 Subject: [PATCH 295/360] Ask before linking a radio to an account --- lib/screens/main_scaffold.dart | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index e2fb9c5b..eb320a96 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -9,7 +9,9 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../services/permission_disclosure_service.dart'; +import '../services/portal_account_service.dart'; import '../utils/debug_logger_io.dart'; +import '../widgets/app_toast.dart'; import 'home_screen.dart'; import 'log_screen.dart'; import 'connection_screen.dart'; @@ -29,6 +31,7 @@ class _MainScaffoldState extends State { bool _hasCheckedDisclosure = false; bool _hasShownLocationSettingsPrompt = false; bool _floodDisabledDialogOpen = false; + bool _linkPromptDialogOpen = false; final List _screens = [ const HomeScreen(), @@ -160,6 +163,99 @@ class _MainScaffoldState extends State { _floodDisabledDialogOpen = false; } + /// One-tap offer to bind the connected radio to the signed-in account. + /// + /// Mirrors the flood-disabled alert: a postFrameCallback plus a local + /// open-guard, because build() runs on every provider notify. + Future _showLinkPrompt() async { + final appState = context.read(); + final deviceName = appState.portalLinkPromptDeviceName ?? 'this device'; + final accountName = appState.portalAccount?.displayName ?? 'your account'; + debugLog('[ACCOUNT] Showing the device link prompt'); + + final accepted = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Link This Device?'), + content: Text( + "Link '$deviceName' to your MeshMapper account ($accountName)? " + 'Wardriving data from this device will count toward your account.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('No thanks'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Link Device'), + ), + ], + ), + ); + + final outcome = await appState.respondToLinkPrompt(accepted ?? false); + _linkPromptDialogOpen = false; + if (!mounted) return; + await showLinkOutcome(context, outcome); + } + + /// Present a link result. Success is a toast; the two cases that need the + /// user to go somewhere else get a dialog. Every other outcome — network, + /// server, unsupported firmware, unauthorized — is deliberately SILENT: + /// linking must never look like a wardriving failure. + static Future showLinkOutcome( + BuildContext context, PortalLinkOutcome outcome) async { + switch (outcome.status) { + case PortalLinkStatus.linked: + AppToast.success( + context, + 'Linked to ${outcome.accountName ?? 'your MeshMapper account'}', + ); + case PortalLinkStatus.adoptionRequired: + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Finish Setup in the Portal'), + content: Text( + 'Your account still has ${outcome.adoptionDeviceCount} ' + 'placeholder device(s). Claiming them moves every one of your ' + 'radios at once, so it has to be done deliberately at ' + 'portal.meshmapper.net. Once that is finished, come back and ' + 'link this device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + case PortalLinkStatus.alreadyLinkedOtherAccount: + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Already Linked Elsewhere'), + content: const Text( + 'This radio is already linked to a different MeshMapper ' + 'account. Unlink it there first, then link it here.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + case PortalLinkStatus.skipped: + case PortalLinkStatus.unauthorized: + case PortalLinkStatus.failed: + break; + } + } + void _showLocationSettingsPrompt() { if (!mounted || _hasShownLocationSettingsPrompt) return; _hasShownLocationSettingsPrompt = true; @@ -213,6 +309,20 @@ class _MainScaffoldState extends State { }); } + // MyMeshMapper account link offer (post-connection, non-fatal). + // A pending prompt survives disconnect by design (the provider no-ops + // safely if it is answered afterwards), but never RAISE the dialog for a + // dead connection — the link handshake needs the radio to sign a nonce. + if (appState.isConnected && + appState.portalLinkPromptPending && + !_linkPromptDialogOpen) { + _linkPromptDialogOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showLinkPrompt(); + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; From 3ee7e4340e71ad41fd2482f2b07fd8a9b00175cb Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 09:10:42 -0400 Subject: [PATCH 296/360] Put the account controls where the other settings live --- lib/screens/settings_screen.dart | 178 +++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 686201e8..c88142b1 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -25,6 +25,7 @@ import '../services/debug_file_logger.dart'; import '../services/gps_simulator_service.dart'; import '../services/offline_session_service.dart'; import '../services/permission_disclosure_service.dart'; +import '../services/portal_account_service.dart'; import '../utils/constants.dart'; import '../widgets/bug_report_dialog.dart'; import '../widgets/upload_logs_dialog.dart'; @@ -710,6 +711,89 @@ class _SettingsScreenState extends State { )), ]), + // MeshMapper Account — mobile only. The sign-in flow needs an + // OS-registered URL scheme, which the web build cannot have. + if (!kIsWeb) + _buildSection(context, 'MeshMapper Account', [ + if (!appState.isPortalLoggedIn) + ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: const Text('Sign in to MyMeshMapper'), + subtitle: const Text( + 'Link your radios so wardriving counts toward your account'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => _startPortalSignIn(context, appState), + ) + else ...[ + ListTile( + leading: + const Icon(Icons.account_circle, color: Colors.green), + title: + Text(appState.portalAccount?.displayName ?? 'Signed in'), + subtitle: Text('@${appState.portalAccount?.username ?? ''}'), + ), + ListTile( + leading: Icon( + appState.isCurrentDeviceLinked + ? Icons.link + : Icons.link_off, + color: appState.isCurrentDeviceLinked + ? Colors.green + : Colors.grey, + ), + title: const Text('This Device'), + subtitle: Text( + !appState.isConnected + ? 'Connect a device to link it' + : appState.isCurrentDeviceLinked + ? 'Linked to your account' + : 'Not linked', + ), + trailing: !appState.isConnected + ? null + : appState.isCurrentDeviceLinked + ? TextButton( + onPressed: isAutoMode + ? null + : () => + _unlinkPortalDevice(context, appState), + child: const Text('Unlink'), + ) + : TextButton( + // Linking drives the radio (CMD_SIGN), so it + // follows this screen's auto-ping lock. + onPressed: isAutoMode + ? null + : () => _linkPortalDevice(context, appState), + child: const Text('Link now'), + ), + ), + ListTile( + leading: const Icon(Icons.devices_other), + title: const Text('Linked Devices'), + subtitle: Text( + '${appState.portalLinkedDeviceCount} device(s) on this account'), + trailing: const Icon(Icons.refresh), + onTap: () => _refreshPortalAccount(context, appState), + ), + if (appState.hasPortalLinkDeclines) + ListTile( + leading: const Icon(Icons.replay), + title: const Text('Re-enable Link Prompts'), + subtitle: const Text('Ask again for devices you declined'), + onTap: () => _resetPortalDeclines(context, appState), + ), + ListTile( + leading: const Icon(Icons.logout, color: Colors.orange), + title: const Text('Sign Out'), + subtitle: + const Text('Keeps your linked devices on the server'), + onTap: () => + _showPortalSignOutConfirmation(context, appState), + ), + ], + ]), + // API Endpoints _buildSection(context, 'API Endpoints', [ const ListTile( @@ -2695,6 +2779,100 @@ class _SettingsScreenState extends State { } } + Future _startPortalSignIn( + BuildContext context, AppStateProvider appState) async { + debugLog('[ACCOUNT] Opening the portal sign-in page'); + final launched = await appState.beginPortalSignIn(); + if (!context.mounted) return; + if (launched) { + AppToast.info(context, 'Finish signing in, then return to the app'); + } else { + AppToast.error(context, 'Could not open the browser'); + } + } + + Future _linkPortalDevice( + BuildContext context, AppStateProvider appState) async { + AppToast.info(context, 'Linking this device...'); + final outcome = await appState.startManualDeviceLink(); + if (!context.mounted) return; + switch (outcome.status) { + case PortalLinkStatus.linked: + AppToast.success(context, + 'Linked to ${outcome.accountName ?? 'your MeshMapper account'}'); + case PortalLinkStatus.adoptionRequired: + AppToast.warning( + context, + 'Claim your ${outcome.adoptionDeviceCount} placeholder device(s) at ' + 'portal.meshmapper.net first', + duration: const Duration(seconds: 6), + ); + case PortalLinkStatus.alreadyLinkedOtherAccount: + AppToast.error(context, 'This radio is linked to a different account'); + case PortalLinkStatus.unauthorized: + AppToast.error(context, 'Signed out — please sign in again'); + case PortalLinkStatus.skipped: + case PortalLinkStatus.failed: + // A manual tap deserves feedback even though the automatic path is + // deliberately silent. + AppToast.error(context, 'Could not link right now — try again later'); + } + } + + Future _unlinkPortalDevice( + BuildContext context, AppStateProvider appState) async { + final ok = await appState.unlinkCurrentDevice(); + if (!context.mounted) return; + if (ok) { + AppToast.success(context, 'Device unlinked'); + } else { + AppToast.error(context, 'Could not unlink right now'); + } + } + + Future _refreshPortalAccount( + BuildContext context, AppStateProvider appState) async { + await appState.refreshPortalAccount(force: true); + if (!context.mounted) return; + AppToast.simple( + context, '${appState.portalLinkedDeviceCount} linked device(s)'); + } + + Future _resetPortalDeclines( + BuildContext context, AppStateProvider appState) async { + await appState.resetLinkPromptDeclines(); + if (!context.mounted) return; + AppToast.success(context, 'Link prompts re-enabled'); + } + + void _showPortalSignOutConfirmation( + BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Sign Out'), + content: const Text( + 'Sign out of MyMeshMapper on this phone? Your radios stay linked to ' + 'your account, and wardriving keeps working.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + appState.portalSignOut(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('Sign Out'), + ), + ], + ), + ); + } + void _showCloseAppConfirmation( BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; From eac24f4a865e594cbfaba5b8bb9147ada248eb8d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 09:19:50 -0400 Subject: [PATCH 297/360] Document the account lane and fix the stale platform floors --- DEVELOPMENT.md | 103 +++++++++++++++++++++++++++++++++++- docs/CUSTOM_API_ENDPOINT.md | 7 +++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 66aeadc8..5d28c135 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -358,6 +358,95 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s - **Platform**: Android and iOS only (Web N/A — always requires active tab) - **File**: `lib/services/wakelock_service.dart` +### MyMeshMapper Account + Companion Linking + +Signs the user in to their portal account and offers to bind each connected +radio's Ed25519 pubkey to it, so their wardriving counts toward that account. +**Linking is strictly non-fatal — no failure may surface as an error or affect +a connection.** Mobile only (`!kIsWeb`). Server contract: +`MeshMapper_Server/docs/SPEC-app-portal-link.md`. + +- **Sign-in**: system-browser PKCE (S256 only). `PkcePair` (`lib/utils/pkce.dart`) + mints a 43-char verifier + challenge + independent `state`; the app opens + `portal.php?app_authorize=1&…` with `LaunchMode.externalApplication` (an in-app + WebView would see the user's password) and the portal deep-links back + `meshmapper-auth://callback?code=…&state=…`. +- **Scheme**: `meshmapper-auth` (host `callback`), registered in + `ios/Runner/Info.plist` `CFBundleURLTypes` and the `MainActivity` + VIEW/BROWSABLE intent-filter. Deliberately NOT the bare `meshmapper` scheme — + that is a paste-only clipboard format (`docs/CUSTOM_API_ENDPOINT.md`, + `meshmapper://custom-api?…`) and registering it would hijack those links. +- **Token**: 64-hex bearer in `flutter_secure_storage` + (`SecureTokenStore`, keys `portal_app_token` / `portal_pending_pkce`; + iOS `first_unlock_this_device`, Android EncryptedSharedPreferences in + `MeshMapperSecure`). Reads NEVER throw — a restored Android backup carries + ciphertext without the Keystore key, so the store wipes itself and reports + signed-out. The secure-prefs file is excluded from backup in + `res/xml/backup_rules.xml` and `res/xml/data_extraction_rules.xml`. +- **The PKCE pair is PERSISTED**, not held in memory: iOS routinely kills the + backgrounded app while the user types their password in Safari. 10-minute TTL, + burned after one exchange attempt, and the `code` is deduped because + `app_links` 6.x delivers the cold-start URI on both `getInitialLink()` and + `uriLinkStream`. That dedupe is a **claim/release pair**: `Set.add` is the + atomic test-and-set at the guard (it must sit ahead of the first `await`), and + the claim is released again on every path that declines to exchange — no + pending pair, expired pair, state mismatch — so the genuine callback carrying + that same code is never locked out. +- **An `error=` callback is honoured only when it answers a live attempt**: a + pending pair must exist and its `state` must match, and the code is allowlisted + (`^[a-z_]{1,32}$`, anything else collapses to `denied`). A deep link is + unauthenticated — any app on the device can fire + `meshmapper-auth://callback?error=x`, which would otherwise destroy a + legitimate in-flight pair and push an arbitrary string into the UI. +- **Linking**: `requestNonce(pubkey)` → the app validates the answer is exactly + 64 hex / 32 bytes → `MeshCoreConnection.sign()` has the radio Ed25519-sign the + **raw 32 bytes** → `linkDevice(pubkey, nonce, signature, label)` binds it. The + app lane NEVER auto-adopts placeholders: `adoption_required` shows a dialog + pointing at the browser portal. +- **The sign write gate**: `CMD_SIGN_DATA` is acked by a bare `OK (0x00)`, and so + are `setFloodScope`, `setChannel`, `setPathHashMode`, `setAdvertName` and + `setTxPower`. Every outbound frame in `connection.dart` therefore funnels + through one private `_write(bytes, {isSignFrame})`, which queues non-sign + frames behind `_signGate` for the few hundred ms a sign takes. `getChannel()` + used to bypass `_sendToRadio` with a direct `_transport.write` — it now goes + through the gate too. `disconnect()`, `deleteWardrivingChannelEarly()` and + `dispose()` all call `_abortPendingSign()`, and the provider's user-initiated + `disconnect()` calls the public `abortPendingSign()` **first**, before any + teardown write, so a live sign can never park the advert-name/path-hash + restore or the channel deletion behind its timeout. +- **When the prompt appears**: pure `decideLinkFlow()` + (`lib/services/link_decision.dart`) — signed in, not offline, not anonymous, + not auto-pinging, not auto-reconnecting, a pubkey exists, not declined, not + already linked, firmware can sign, and not already asked **this app session** + (per pubkey, so a BLE flap mid-drive cannot re-ask). Time-dependent backoff + and the 5-attempt cap live in `AppStateProvider` just before that call; the + dialog additionally gates on `isConnected`, since a pending prompt survives a + disconnect but the handshake needs a radio to sign. Control flow reads + `isPortalLoggedIn` (the live token), never `portalAccount != null` — the + cached account is a display name that outlives the token. +- **Two strikes before a radio is written off**: an `unsupported` SignException + bumps an in-memory strike and is persisted to `portal_sign_unsupported_devices` + only on the SECOND one, because a stats/battery poller ERR already in flight + when the sign starts is misattributed as `unsupported`. A server + `bad_signature` has its own dedicated 2-strike counter (the generic attempt + counter also holds nonce and network failures, so it cannot stand in). A local + sign that succeeds clears the unsupported strike; a `LinkSuccess` clears BOTH + counters and the persisted verdict. +- **Persistence** (Hive `user_preferences`): `portal_account_info`, + `portal_linked_pubkeys` (UPPER hex), `portal_link_declined_devices`, + `portal_sign_unsupported_devices`. Sign-out clears the first two and keeps the + last two — those are device preferences, not account data. +- **Logging**: everything is `[ACCOUNT]`, routed through a redactor that strips + any live token / verifier / state / code, and `DebugFileLogger.scrubSecrets()` + strips credential shapes from every line written to a log FILE (debug logging + is on in release builds and those files ship with bug reports). Public keys + appear as an 8-char prefix only. +- **Files**: `lib/utils/pkce.dart`, `lib/services/portal_token_store.dart`, + `lib/services/portal_account_service.dart`, `lib/services/link_decision.dart`, + `lib/services/meshcore/connection.dart` (`sign()`, `_write()`), + `lib/providers/app_state_provider.dart`, `lib/screens/main_scaffold.dart`, + `lib/screens/settings_screen.dart`. + ### Coverage Overlay (vector tiles) The MeshMapper coverage layer is rendered from the region server's vector tiles @@ -642,15 +731,18 @@ than running a guaranteed-fail retry loop all session. ### Android - Requires permissions: Bluetooth, Location (for BLE scanning) -- minSdkVersion: 21 (Android 5.0+) +- minSdkVersion: 24 (Flutter's `flutter.minSdkVersion` default; MapLibre GL needs 23+) - Background location permission for continuous tracking - Uses `flutter_blue_plus` package +- URL scheme `meshmapper-auth` (host `callback`) registered on MainActivity via a VIEW/DEFAULT/BROWSABLE intent-filter — the portal sign-in return. The bare `meshmapper://` scheme is deliberately NOT registered: it is a paste-only clipboard format (`docs/CUSTOM_API_ENDPOINT.md`). +- `android:fullBackupContent` / `android:dataExtractionRules` exclude the `MeshMapperSecure` secure-prefs file from backup ### iOS - Requires Info.plist entries: NSBluetoothAlwaysUsageDescription, NSLocationWhenInUseUsageDescription -- Deployment target: 12.0+ +- Deployment target: 13.0 - Background modes: bluetooth-central, location - Uses `flutter_blue_plus` package +- `CFBundleURLTypes` registers the `meshmapper-auth` scheme (name `net.meshmapper.app.auth`) for the portal sign-in return ## Dependencies @@ -665,6 +757,8 @@ Key packages used in this project: - `http`: API requests - `pointycastle`: Encryption (AES-ECB, SHA-256) - `usb_serial`: USB Serial communication on Android (USB OTG) +- `app_links`: Custom-scheme deep links (`meshmapper-auth://callback`) for the portal sign-in return +- `flutter_secure_storage`: Keychain / Android Keystore storage for the portal app token ### Vendored `maplibre_gl` (`third_party/maplibre_gl`) @@ -750,6 +844,7 @@ debugError('[API] Failed to post batch: $error'); | `[WAKELOCK]` | Wake lock acquisition/release | | `[WATCH]` | WatchConnectivity bridge: availability, snapshot delivery, wrist commands | | `[LIVE ACTIVITY]` | ActivityKit bridge: sync, end, and authorization failures | +| `[ACCOUNT]` | MyMeshMapper portal sign-in and companion device linking | Never log without a tag. @@ -875,6 +970,10 @@ All API endpoints may return maintenance mode: - `lib/services/debug_submit_service.dart` - Bug report submission (4-step workflow) - `lib/services/gps_simulator_service.dart` - GPS simulation for testing - `lib/services/wakelock_service.dart` - Screen wake lock during auto-ping +- `lib/services/portal_account_service.dart` - MyMeshMapper portal lane (PKCE sign-in, nonce/link/unlink/me/logout) +- `lib/services/portal_token_store.dart` - Keychain/Keystore storage for the portal token and pending PKCE pair +- `lib/services/link_decision.dart` - Pure decision for whether to offer a device link +- `lib/utils/pkce.dart` - RFC 7636 S256 PKCE pair generation - `lib/services/watch/watch_bridge_service.dart` - WatchConnectivity transport: throttle, dedupe, movement gate, map-geo lease, command admission - `lib/services/watch/watch_models.dart` - Watch wire contract and shared start-admission resolver - `lib/services/watch/watch_geo_builder.dart` - Ping/repeater/heard geography for the wrist, with wire caps diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md index 48bb4043..b4c919a8 100644 --- a/docs/CUSTOM_API_ENDPOINT.md +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -241,3 +241,10 @@ The user copies this link, opens MeshMapper Settings > API Endpoints, and taps " - **Batch size**: 1-50 ping objects per request (typically 1-10). - **Session duration**: Wardriving sessions commonly last 30 minutes to several hours. - **Concurrent users**: Plan for multiple users if distributing your endpoint URL. Each user sends independently. + +## Why `meshmapper://` links are paste-only + +The `meshmapper://custom-api?...` format is deliberately **not** registered as +an OS URL scheme. Tapping one does nothing; it has to be copied and imported +from Settings. The app registers `meshmapper-auth://callback` instead (portal +sign-in) precisely so that claiming a scheme never hijacks these config links. From b81e091e1ea0dd7c1f7f2414cbd930bd60dbda69 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 21 Aug 2026 09:50:25 -0400 Subject: [PATCH 298/360] Stop a new device link from wiping the cached ones Restore the linked-pubkey cache into the account service, not just the provider: link/unlink mutate the service list and the provider mirrors it back wholesale, so an unhydrated service published a one-entry list. Also: run the throttled account refresh on connect, stop awaiting the deep-link token exchange during startup, refuse signed-out POSTs locally, let the reset control clear sign-unsupported verdicts too, and guarantee the link prompt guard is released. --- lib/providers/app_state_provider.dart | 50 +++++++++++-- lib/screens/main_scaffold.dart | 56 ++++++++------ lib/screens/settings_screen.dart | 6 +- lib/services/portal_account_service.dart | 46 ++++++++++-- .../services/portal_account_service_test.dart | 73 +++++++++++++++++++ 5 files changed, 195 insertions(+), 36 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7353a45e..f422b3f7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1127,8 +1127,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// How many radios this account owns (from the last `me` refresh). int get portalLinkedDeviceCount => _portalLinkedPubkeys.length; - /// True when the user has declined linking for at least one radio. - bool get hasPortalLinkDeclines => _portalLinkDeclinedDevices.isNotEmpty; + /// True when at least one radio is suppressed from the link prompt — either + /// the user declined it or it was judged unable to sign. Both are cleared by + /// `resetLinkPromptDeclines()`, so this is what gates the reset control: + /// a sign-unsupported verdict with no visible way back is a dead end. + bool get hasPortalLinkResets => + _portalLinkDeclinedDevices.isNotEmpty || + _portalSignUnsupportedDevices.isNotEmpty; /// True when the currently connected radio is already bound to the account. bool get isCurrentDeviceLinked { @@ -2587,7 +2592,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _loadDevicePowerOverrides(); await _loadDeviceRealNames(); await _loadPortalAccountState(); - await _portalAccountService.init(); + // Account init is never allowed to abort the rest of startup — everything + // after this point (GPS restore, BLE listeners, auto-connect) matters more + // than the portal lane. + try { + await _portalAccountService.init(); + } catch (e) { + debugWarn('[ACCOUNT] Init failed: ${e.runtimeType}'); + } // Load last known GPS position for map centering await _loadLastPosition(); @@ -4435,6 +4447,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { debugWarn('[ACCOUNT] Link offer failed to start: $e'); } + + // Heal a stale cache: a portal-side unlink or a revoked token is discovered + // within the hour instead of never. Non-forced, so the service's own 1/hour + // throttle bounds the writes this makes to the site-wide auth DB, and it + // returns immediately when signed out. + if (!kIsWeb) { + try { + unawaited(_portalAccountService.refreshMe()); + } catch (e) { + debugWarn('[ACCOUNT] Account refresh failed to start: $e'); + } + } } /// Create and wire up unified RX handler @@ -8963,6 +8987,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (rawLinked is List) { _portalLinkedPubkeys = rawLinked.map((entry) => entry.toString().toUpperCase()).toList(); + // The service is the single source of truth for this list — + // onAccountChanged mirrors it back WHOLESALE. Seed it too, or the first + // link/unlink would publish a list built from an empty cache and wipe + // every radio linked before this launch. + _portalAccountService.hydrateLinkedPubkeys(_portalLinkedPubkeys); } final rawDeclined = box.get(_portalDeclinedKey); @@ -9047,12 +9076,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _portalAccountService.refreshMe(force: force); } - /// Clear every persisted decline so declined radios are offered again. + /// Clear every persisted decline AND every sign-unsupported verdict, so both + /// kinds of suppressed radio are offered again. + /// + /// The sign-unsupported verdict is sticky by design (a radio without + /// CMD_SIGN must not be re-asked every connect), but it is a firmware fact, + /// not a permanent one: a firmware update adds the command. This is the only + /// user-facing way back, so the in-memory strike counters that feed the + /// verdict are reset with it — otherwise one more strike would re-latch it. Future resetLinkPromptDeclines() async { debugLog('[ACCOUNT] Clearing ' - '${_portalLinkDeclinedDevices.length} link decline(s)'); + '${_portalLinkDeclinedDevices.length} link decline(s) and ' + '${_portalSignUnsupportedDevices.length} sign-unsupported verdict(s)'); _portalLinkDeclinedDevices.clear(); _portalPromptedThisSession.clear(); + _portalSignUnsupportedDevices.clear(); + _portalUnsupportedStrikes.clear(); + _portalBadSignatureStrikes.clear(); await _savePortalAccountState(); notifyListeners(); } diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index eb320a96..adf16324 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -173,31 +173,41 @@ class _MainScaffoldState extends State { final accountName = appState.portalAccount?.displayName ?? 'your account'; debugLog('[ACCOUNT] Showing the device link prompt'); - final accepted = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Link This Device?'), - content: Text( - "Link '$deviceName' to your MeshMapper account ($accountName)? " - 'Wardriving data from this device will count toward your account.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('No thanks'), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Link Device'), + // try/finally, not a plain assignment: anything that throws between here + // and the reset (a dialog torn down mid-route, a respondToLinkPrompt + // failure) would leave the guard latched TRUE for the life of the screen, + // and the prompt could never be shown again this session. + try { + final accepted = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Link This Device?'), + content: Text( + "Link '$deviceName' to your MeshMapper account ($accountName)? " + 'Wardriving data from this device will count toward your account.', ), - ], - ), - ); + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('No thanks'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Link Device'), + ), + ], + ), + ); - final outcome = await appState.respondToLinkPrompt(accepted ?? false); - _linkPromptDialogOpen = false; - if (!mounted) return; - await showLinkOutcome(context, outcome); + final outcome = await appState.respondToLinkPrompt(accepted ?? false); + // Released BEFORE the outcome is presented, as before: the outcome + // dialog is its own modal and must not hold the prompt guard. + _linkPromptDialogOpen = false; + if (!mounted) return; + await showLinkOutcome(context, outcome); + } finally { + _linkPromptDialogOpen = false; + } } /// Present a link result. Success is a toast; the two cases that need the diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c88142b1..761c5e04 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -776,11 +776,13 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.refresh), onTap: () => _refreshPortalAccount(context, appState), ), - if (appState.hasPortalLinkDeclines) + if (appState.hasPortalLinkResets) ListTile( leading: const Icon(Icons.replay), title: const Text('Re-enable Link Prompts'), - subtitle: const Text('Ask again for devices you declined'), + subtitle: + const Text('Ask again for declined or unsupported ' + 'devices'), onTap: () => _resetPortalDeclines(context, appState), ), ListTile( diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index 91a677f2..2e24a52a 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -272,6 +272,26 @@ class PortalAccountService { /// network call happens. void hydrateAccount(PortalAccount account) => _account = account; + /// Restore the cached linked-device list from Hive. + /// + /// Load-bearing: `link`/`unlink` mutate THIS list and the provider mirrors it + /// back wholesale on `onAccountChanged`. Without the hydrate, linking a new + /// radio would publish a one-entry list and wipe every previously cached + /// pubkey. Only membership is restored — label/name/points are `me` data and + /// are refilled by the next `refreshMe`. Deliberately does NOT fire + /// `onAccountChanged`: this runs during load, before any listener cares. + void hydrateLinkedPubkeys(List pubkeys) { + _linkedPubkeys = pubkeys + .where((pubkey) => pubkey.isNotEmpty) + .map((pubkey) => LinkedPubkey( + pubkey: pubkey.toUpperCase(), + label: '', + name: '', + points: 0, + )) + .toList(growable: false); + } + Future init() async { _token = await _store.readToken(); _log('init: signedIn=${_token != null}'); @@ -289,12 +309,16 @@ class PortalAccountService { onError: (Object e) => _warn('link stream error: ${e.runtimeType}'), ); - try { - final initial = await links.getInitialLink(); + // Fire-and-forget on purpose. The caller AWAITS init() during app startup, + // and handleAuthCallback runs a token exchange with a 10s timeout — that is + // the feature's own cold-start path, and awaiting it here would stall BLE + // setup and auto-connect behind it. The token read and the stream subscribe + // above are the only parts startup actually has to wait for. + unawaited(links.getInitialLink().then((initial) async { if (initial != null) await handleAuthCallback(initial); - } catch (e) { + }).catchError((Object e) { _warn('getInitialLink failed: ${e.runtimeType}'); - } + })); } /// Re-check the last deep link on app resume — iOS can deliver the callback @@ -717,8 +741,18 @@ class PortalAccountService { String action, Map body, { bool authenticated = true, - }) => - _postWithToken(action, body, authenticated ? _token : null); + }) { + // An authenticated call with no token can only ever be refused, and sending + // it header-less would draw a bare 401 the callers have to disambiguate — + // worse, a race that signs out mid-link would answer `token_invalid` and + // fire a SECOND sign-out event. Answer locally instead; `status == 0` makes + // this a network failure to every caller, which is the harmless verdict. + if (authenticated && _token == null) { + _warn('$action skipped: signed out'); + return Future.value(const _PortalResponse(0, {}, 'no_token')); + } + return _postWithToken(action, body, authenticated ? _token : null); + } Future<_PortalResponse> _postWithToken( String action, diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 4f3365ba..2f9ea32a 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -781,6 +781,79 @@ void main() { expect(changed, 0); }); + test('a link keeps the pubkeys restored from the cache', () async { + final service = await signedIn(routedClient({ + 'link': http.Response('{"ok":true,"pubkey":"${'C' * 64}"}', 200), + })); + + // Exactly what the provider does on startup: replay the Hive cache into + // the service before anything can mutate it. + service.hydrateLinkedPubkeys([('a' * 64), ('b' * 64).toUpperCase()]); + expect(service.linkedPubkeys.length, 2); + + expect( + await service.linkDevice( + pubkey: 'c' * 64, nonce: 'd' * 64, signature: 'e' * 128), + isA(), + ); + + // The provider mirrors this list back wholesale, so a dropped A/B here + // is a wiped cache on disk. + expect( + service.linkedPubkeys.map((p) => p.pubkey).toList(), + [('A' * 64), ('B' * 64), ('C' * 64)], + ); + }); + + test('an unlink removes only its own pubkey from a restored cache', + () async { + final service = await signedIn(routedClient({ + 'unlink': http.Response('{"ok":true}', 200), + })); + + service.hydrateLinkedPubkeys([('a' * 64), ('b' * 64)]); + + expect(await service.unlinkDevice('a' * 64), isTrue); + + expect(service.linkedPubkeys.map((p) => p.pubkey).toList(), [('B' * 64)]); + }); + + test('hydrating the cache does not notify', () async { + final service = await signedIn(routedClient(const {})); + var changed = 0; + service.onAccountChanged = () => changed++; + + service.hydrateLinkedPubkeys([('a' * 64)]); + + expect(service.linkedPubkeys.length, 1); + expect(changed, 0, reason: 'a load is not a change'); + expect(requests, isEmpty); + }); + + test('a signed-out link is refused without any HTTP call', () async { + final service = buildService( + recordingClient((_) => http.Response('{"ok":true}', 200))); + await service.init(); + expect(service.isSignedIn, isFalse); + + final result = await service.linkDevice( + pubkey: 'a' * 64, nonce: 'b' * 64, signature: 'c' * 128); + + expect(result, isA()); + expect((result as LinkNetworkError).detail, 'no_token'); + expect(requests, isEmpty, + reason: 'a header-less POST can only ever be refused'); + }); + + test('a signed-out nonce request makes no HTTP call', () async { + final service = buildService(recordingClient( + (_) => http.Response('{"nonce":"${'b' * 64}"}', 200))); + await service.init(); + + expect(await service.requestNonce('A' * 64), isNull); + expect(requests, isEmpty); + }); + test('logout clears locally even when the revoke POST fails', () async { final service = await signedIn(recordingClient((_) => http.Response('boom', 500))); From a8d3643bbfa65733181a7d64104f684b7eed8ca6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 13:29:57 -0400 Subject: [PATCH 299/360] Add scripted release flags and App Store upload script --- .gitignore | 3 + Build.sh | 250 +++++++++++++++++++++++++--------- DEVELOPMENT.md | 7 + ios/ExportOptionsUpload.plist | 21 +++ upload_ios.sh | 62 +++++++++ 5 files changed, 278 insertions(+), 65 deletions(-) create mode 100644 ios/ExportOptionsUpload.plist create mode 100755 upload_ios.sh diff --git a/.gitignore b/.gitignore index 48592770..ef05ec63 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,9 @@ meshmapper-debug-*.txt # Test/debug output drive_route_map*.html +# Build metadata written by Build.sh (machine-specific) +.last_build + # Claude Code .claude/ CLAUDE.md diff --git a/Build.sh b/Build.sh index a62e4eaa..c5af7df7 100755 --- a/Build.sh +++ b/Build.sh @@ -5,6 +5,37 @@ set -e # Exit on any error +# CLI flags for scripted use: ./Build.sh [--type dev|prod] [--version X.Y.Z] [--dry-run] +# With no flags the script behaves exactly as before (interactive prompts). +CLI_TYPE="" +CLI_VERSION="" +DRY_RUN=0 +while [ $# -gt 0 ]; do + case "$1" in + --type) CLI_TYPE="$2"; shift 2 ;; + --version) CLI_VERSION="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + *) + echo "Error: Unknown argument: $1" + echo "Usage: ./Build.sh [--type dev|prod] [--version X.Y.Z] [--dry-run]" + exit 1 + ;; + esac +done +if [ -n "$CLI_TYPE" ] && [ "$CLI_TYPE" != "dev" ] && [ "$CLI_TYPE" != "prod" ]; then + echo "Error: --type must be 'dev' or 'prod'" + exit 1 +fi +if [ "$DRY_RUN" = "1" ] && { [ -z "$CLI_TYPE" ] || [ -z "$CLI_VERSION" ]; }; then + echo "Error: --dry-run requires --type and --version" + exit 1 +fi +# Both flags given -> never prompt; missing secrets are an error instead +NONINTERACTIVE=0 +if [ -n "$CLI_TYPE" ] && [ -n "$CLI_VERSION" ]; then + NONINTERACTIVE=1 +fi + # maplibre_gl 0.25.0 plugin requires JDK 21 to compile. # Force the build to use Homebrew openjdk@21, regardless of the user's shell JAVA_HOME. JDK21_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" @@ -16,6 +47,12 @@ fi export JAVA_HOME="$JDK21_HOME" export PATH="$JAVA_HOME/bin:$PATH" +# Local release secrets (optional, never committed; lives outside the repo) +RELEASE_ENV="$HOME/.meshmapper_release.env" +if [ -f "$RELEASE_ENV" ]; then + source "$RELEASE_ENV" +fi + # Semver comparison: returns 0 (true) if $1 >= $2 version_gte() { local IFS=. @@ -27,31 +64,59 @@ version_gte() { return 0 } -# API key - prompt if not set via environment variable -if [ -z "$MESHMAPPER_API_KEY" ]; then - echo "Enter MeshMapper API key:" - read -s MESHMAPPER_API_KEY - if [ -z "$MESHMAPPER_API_KEY" ]; then - echo "Error: API key is required." +# Validate a --version value; exits instead of re-prompting (scripted use) +require_valid_version() { + if ! [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" exit 1 fi - echo "" -fi - -# Android signing - prompt for passwords if not set -if [ -z "$SIGNING_STORE_PASSWORD" ]; then - echo "Enter keystore password:" - read -s SIGNING_STORE_PASSWORD - export SIGNING_STORE_PASSWORD -fi + if ! version_gte "$1" "$LAST_VERSION"; then + echo "Error: Version must be >= $LAST_VERSION" + exit 1 + fi +} -if [ -z "$SIGNING_KEY_PASSWORD" ]; then - echo "Enter key password (or press Enter if same as keystore):" - read -s SIGNING_KEY_PASSWORD +if [ "$NONINTERACTIVE" = "1" ]; then + # Scripted run - secrets must already be set (env or $RELEASE_ENV), never prompt + MISSING="" + [ -z "$MESHMAPPER_API_KEY" ] && MISSING="$MISSING MESHMAPPER_API_KEY" + [ -z "$SIGNING_STORE_PASSWORD" ] && MISSING="$MISSING SIGNING_STORE_PASSWORD" + if [ -n "$MISSING" ]; then + echo "Error: Missing required secrets:$MISSING" + echo "Set them in $RELEASE_ENV or the environment." + exit 1 + fi if [ -z "$SIGNING_KEY_PASSWORD" ]; then SIGNING_KEY_PASSWORD="$SIGNING_STORE_PASSWORD" fi - export SIGNING_KEY_PASSWORD + export SIGNING_STORE_PASSWORD SIGNING_KEY_PASSWORD +else + # API key - prompt if not set via environment variable + if [ -z "$MESHMAPPER_API_KEY" ]; then + echo "Enter MeshMapper API key:" + read -s MESHMAPPER_API_KEY + if [ -z "$MESHMAPPER_API_KEY" ]; then + echo "Error: API key is required." + exit 1 + fi + echo "" + fi + + # Android signing - prompt for passwords if not set + if [ -z "$SIGNING_STORE_PASSWORD" ]; then + echo "Enter keystore password:" + read -s SIGNING_STORE_PASSWORD + export SIGNING_STORE_PASSWORD + fi + + if [ -z "$SIGNING_KEY_PASSWORD" ]; then + echo "Enter key password (or press Enter if same as keystore):" + read -s SIGNING_KEY_PASSWORD + if [ -z "$SIGNING_KEY_PASSWORD" ]; then + SIGNING_KEY_PASSWORD="$SIGNING_STORE_PASSWORD" + fi + export SIGNING_KEY_PASSWORD + fi fi # Read last version from .build_version @@ -69,64 +134,82 @@ fi # Generate single epoch for all builds (used as build-number regardless of release type) EPOCH=$(date +%s) -# Release type prompt -echo "" -echo "Release type?" -echo " 1) Dev (APP-)" -echo " 2) Production (APP-x.y.z)" -echo "" -read -p "Select [1]: " RELEASE_TYPE -RELEASE_TYPE=${RELEASE_TYPE:-1} +# Release type - from --type flag, or prompt +if [ -n "$CLI_TYPE" ]; then + if [ "$CLI_TYPE" = "prod" ]; then RELEASE_TYPE="2"; else RELEASE_TYPE="1"; fi +else + echo "" + echo "Release type?" + echo " 1) Dev (APP-)" + echo " 2) Production (APP-x.y.z)" + echo "" + read -p "Select [1]: " RELEASE_TYPE + RELEASE_TYPE=${RELEASE_TYPE:-1} +fi if [ "$RELEASE_TYPE" = "2" ]; then - # Production build - prompt for version - while true; do - read -p "Enter version [$LAST_VERSION]: " VERSION_NUMBER - VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - - # Validate semver format - if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" - continue - fi + # Production build - version from --version flag, or prompt + if [ -n "$CLI_VERSION" ]; then + VERSION_NUMBER="$CLI_VERSION" + require_valid_version "$VERSION_NUMBER" + else + while true; do + read -p "Enter version [$LAST_VERSION]: " VERSION_NUMBER + VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - # Validate >= last version - if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then - echo "Error: Version must be >= $LAST_VERSION" - continue - fi + # Validate semver format + if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" + continue + fi - break - done + # Validate >= last version + if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then + echo "Error: Version must be >= $LAST_VERSION" + continue + fi + + break + done + fi - # Update .build_version - echo "$VERSION_NUMBER" > "$VERSION_FILE" + # Update .build_version (skipped on dry run) + if [ "$DRY_RUN" != "1" ]; then + echo "$VERSION_NUMBER" > "$VERSION_FILE" + fi APP_VERSION="APP-$VERSION_NUMBER" FILE_TAG="$VERSION_NUMBER" else - # Dev build - prompt for target version - while true; do - read -p "Enter target version [$LAST_VERSION]: " VERSION_NUMBER - VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - - # Validate semver format - if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" - continue - fi + # Dev build - target version from --version flag, or prompt + if [ -n "$CLI_VERSION" ]; then + VERSION_NUMBER="$CLI_VERSION" + require_valid_version "$VERSION_NUMBER" + else + while true; do + read -p "Enter target version [$LAST_VERSION]: " VERSION_NUMBER + VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - # Validate >= last version - if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then - echo "Error: Version must be >= $LAST_VERSION" - continue - fi + # Validate semver format + if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" + continue + fi - break - done + # Validate >= last version + if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then + echo "Error: Version must be >= $LAST_VERSION" + continue + fi - # Update .build_version - echo "$VERSION_NUMBER" > "$VERSION_FILE" + break + done + fi + + # Update .build_version (skipped on dry run) + if [ "$DRY_RUN" != "1" ]; then + echo "$VERSION_NUMBER" > "$VERSION_FILE" + fi APP_VERSION="APP-$EPOCH" FILE_TAG="$EPOCH" @@ -136,6 +219,27 @@ fi ANDROID_DIR="$HOME/Documents/MeshMapper_Apps/Andriod" IOS_DIR="$HOME/Documents/MeshMapper_Apps/IOS" +# Dry run: everything is resolved and validated - print the plan and stop +if [ "$DRY_RUN" = "1" ]; then + echo "" + echo "============================================" + echo "DRY RUN - nothing will be built" + echo "Release type: $([ "$RELEASE_TYPE" = "2" ] && echo Production || echo Dev)" + echo "Version: $APP_VERSION" + echo "Build name: $VERSION_NUMBER" + echo "Build number: $EPOCH" + echo "Secrets: present (API key + signing passwords)" + echo "" + echo "Would produce:" + echo " APK: $ANDROID_DIR/MeshMapper-$FILE_TAG.apk" + echo " AAB: $ANDROID_DIR/MeshMapper-$FILE_TAG.aab" + echo " IPA: $IOS_DIR/MeshMapper-$FILE_TAG.ipa" + echo "" + echo ".build_version and .last_build not modified" + echo "============================================" + exit 0 +fi + echo "" echo "============================================" echo "MeshMapper Build Script" @@ -180,3 +284,19 @@ echo " APK: $ANDROID_DIR/MeshMapper-$FILE_TAG.apk" echo " AAB: $ANDROID_DIR/MeshMapper-$FILE_TAG.aab" echo " IPA: $IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "============================================" + +# Record build metadata for the release flow (gitignored; only written on full success) +LAST_BUILD_FILE="$(dirname "$0")/.last_build" +{ + echo "RELEASE_TYPE=$([ "$RELEASE_TYPE" = "2" ] && echo prod || echo dev)" + echo "VERSION_NUMBER=$VERSION_NUMBER" + echo "EPOCH=$EPOCH" + echo "APP_VERSION=$APP_VERSION" + echo "FILE_TAG=$FILE_TAG" + echo "APK_PATH=$ANDROID_DIR/MeshMapper-$FILE_TAG.apk" + echo "AAB_PATH=$ANDROID_DIR/MeshMapper-$FILE_TAG.aab" + echo "IPA_PATH=$IOS_DIR/MeshMapper-$FILE_TAG.ipa" + echo "ARCHIVE_PATH=$(pwd)/build/ios/archive/Runner.xcarchive" + echo "GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown)" + echo "BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$LAST_BUILD_FILE" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5d28c135..cd4d7b56 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -67,6 +67,13 @@ delivery races still need a wrist. # Or set API key via environment variable to skip prompt MESHMAPPER_API_KEY= ./Build.sh + +# Non-interactive (secrets from ~/.meshmapper_release.env or env vars) +./Build.sh --type prod --version 1.3.1 # or --type dev +./Build.sh --type prod --version 1.3.1 --dry-run # print resolved plan, build nothing + +# Upload the built iOS archive to App Store Connect (needs ASC API key, see upload_ios.sh) +./upload_ios.sh ``` ### Debug Logging diff --git a/ios/ExportOptionsUpload.plist b/ios/ExportOptionsUpload.plist new file mode 100644 index 00000000..247e54ee --- /dev/null +++ b/ios/ExportOptionsUpload.plist @@ -0,0 +1,21 @@ + + + + + method + app-store-connect + teamID + DQC6TNKG5P + signingStyle + automatic + stripSwiftSymbols + + uploadSymbols + + destination + upload + + manageAppVersionAndBuildNumber + + + diff --git a/upload_ios.sh b/upload_ios.sh new file mode 100755 index 00000000..d6045a82 --- /dev/null +++ b/upload_ios.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# MeshMapper iOS upload script +# Uploads the Xcode archive produced by Build.sh (flutter build ipa) to +# App Store Connect using an ASC API key, via xcodebuild -exportArchive +# with ios/ExportOptionsUpload.plist (destination: upload). +# +# Auth: expects ASC_KEY_ID and ASC_ISSUER_ID (optionally ASC_KEY_PATH) in the +# environment or ~/.meshmapper_release.env. The .p8 key lives at +# ~/.appstoreconnect/private_keys/AuthKey_.p8 by default. +# +# Fallback if a future Xcode breaks this path (altool is deprecated but still ships): +# xcrun altool --upload-app -f -t ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" +# +# Usage: ./upload_ios.sh [path/to/Runner.xcarchive] + +set -e + +cd "$(dirname "$0")" + +# Local release secrets (optional, never committed; lives outside the repo) +RELEASE_ENV="$HOME/.meshmapper_release.env" +if [ -f "$RELEASE_ENV" ]; then + source "$RELEASE_ENV" +fi + +if [ -z "$ASC_KEY_ID" ] || [ -z "$ASC_ISSUER_ID" ]; then + echo "Error: ASC_KEY_ID and ASC_ISSUER_ID must be set (env or $RELEASE_ENV)." + echo "Create an App Store Connect API key under Users and Access > Integrations." + exit 1 +fi + +ASC_KEY_PATH="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8}" +if [ ! -f "$ASC_KEY_PATH" ]; then + echo "Error: API key file not found: $ASC_KEY_PATH" + echo "Download the .p8 from App Store Connect and place it there (chmod 600)." + exit 1 +fi + +# Archive path: argument > .last_build metadata > default flutter output location +ARCHIVE_PATH="$1" +if [ -z "$ARCHIVE_PATH" ] && [ -f .last_build ]; then + ARCHIVE_PATH=$(grep '^ARCHIVE_PATH=' .last_build | cut -d= -f2-) +fi +ARCHIVE_PATH="${ARCHIVE_PATH:-build/ios/archive/Runner.xcarchive}" +if [ ! -d "$ARCHIVE_PATH" ]; then + echo "Error: No archive at $ARCHIVE_PATH - run ./Build.sh first." + exit 1 +fi + +echo "Uploading $ARCHIVE_PATH to App Store Connect..." +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportOptionsPlist ios/ExportOptionsUpload.plist \ + -exportPath build/ios/upload \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$ASC_KEY_PATH" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + +echo "" +echo "Upload accepted. The build appears in TestFlight after Apple-side processing (~5-15 min)." From b717d1b676299f479e85717023579b9abc303e8e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 13:52:39 -0400 Subject: [PATCH 300/360] Adopt the new Flutter Android build flags --- android/gradle.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8c..d5da7278 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false From 2e0376b6fd5081a531ffd8fdd5dcbc2bffa3a011 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 13:55:20 -0400 Subject: [PATCH 301/360] Move to Font Awesome 11 so release builds compile --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 41e08b26..7141fd25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -548,10 +548,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 + sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935" url: "https://pub.dev" source: hosted - version: "10.12.0" + version: "11.0.0" frontend_server_client: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 05cf8741..fbfe8339 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: just_audio: ^0.9.40 audio_session: ^0.1.21 dio: ^5.0.0 - font_awesome_flutter: ^10.7.0 + font_awesome_flutter: ^11.0.0 fl_chart: ^0.69.0 web: ^1.1.0 disk_space_plus: ^0.2.3 From 52cb94c9b4c510d09deefcfa75e822093f7c197a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 15:19:58 -0400 Subject: [PATCH 302/360] Bump to 1.4.0 and refresh the iOS build files --- .build_version | 2 +- ios/Flutter/AppFrameworkInfo.plist | 2 -- ios/Podfile.lock | 6 ++++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.build_version b/.build_version index f0bb29e7..88c5fb89 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.3.0 +1.4.0 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a367b48b..10431777 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,6 +6,8 @@ PODS: - Flutter - flutter_local_notifications (0.0.1): - Flutter + - flutter_secure_storage (6.0.0): + - Flutter - permission_handler_apple (9.3.0): - Flutter @@ -14,6 +16,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) EXTERNAL SOURCES: @@ -25,6 +28,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_background_service_ios/ios" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" @@ -33,6 +38,7 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 From 291b26bae9d6d831c745f1b3d66063c60e189cde Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 15:25:51 -0400 Subject: [PATCH 303/360] Sign App Store uploads with explicit profiles --- ios/ExportOptionsUpload.plist | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ios/ExportOptionsUpload.plist b/ios/ExportOptionsUpload.plist index 247e54ee..c9e0de24 100644 --- a/ios/ExportOptionsUpload.plist +++ b/ios/ExportOptionsUpload.plist @@ -6,8 +6,24 @@ app-store-connect teamID DQC6TNKG5P + signingStyle - automatic + manual + signingCertificate + Apple Distribution + provisioningProfiles + + net.meshmapper.app + MeshMapper AppStore + net.meshmapper.app.liveactivity + MeshMapper LiveActivity AppStore + net.meshmapper.app.watchkitapp + MeshMapper Watch AppStore + stripSwiftSymbols uploadSymbols From 6058a88d624624b76bb79db6f01b9834f66e5b86 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 17:25:42 -0400 Subject: [PATCH 304/360] Tell the user when a portal sign-in fails --- DEVELOPMENT.md | 8 +++++ lib/providers/app_state_provider.dart | 30 +++++++++++++++++++ lib/screens/main_scaffold.dart | 42 +++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cd4d7b56..27bb2a4a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -405,6 +405,14 @@ a connection.** Mobile only (`!kIsWeb`). Server contract: unauthenticated — any app on the device can fire `meshmapper-auth://callback?error=x`, which would otherwise destroy a legitimate in-flight pair and push an arbitrary string into the UI. +- **A failed sign-in is reported by `MainScaffold`, not by the call site**: the + browser round trip outlives the Settings tap that started it, so + `onSignInComplete` lands with no live caller left to answer. The provider + parks the sanitized code in `portalSignInError`; the scaffold drains it on the + next frame, maps it to user-facing copy and calls `clearPortalSignInError()`. + Only failures attributable to an attempt THIS app started get that far — an + unsolicited callback (no pending pair, state mismatch) still returns silently + by design, for the same reason the `error=` rule above exists. - **Linking**: `requestNonce(pubkey)` → the app validates the answer is exactly 64 hex / 32 bytes → `MeshCoreConnection.sign()` has the radio Ed25519-sign the **raw 32 bytes** → `linkDevice(pubkey, nonce, signature, label)` binds it. The diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f422b3f7..dc2dc0f6 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -436,6 +436,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // sets it when a radio needs the one-tap prompt. bool _portalLinkPromptPending = false; + /// Sanitized failure code from the last sign-in attempt, waiting to be shown. + /// + /// The browser round trip outlives the Settings tap that started it, so the + /// service's completion callback is the ONLY place a failure can surface — + /// `MainScaffold` drains this and clears it. Deliberately holds the CODE, not + /// the copy: the UI layer owns the user-facing wording, same as + /// `PortalLinkStatus`. + String? _portalSignInError; + /// The pubkey the pending prompt belongs to. Captured when the prompt is /// raised so a mid-prompt reconnect cannot link the wrong radio. String? _portalLinkPromptPubkey; @@ -1144,6 +1153,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The one-tap link prompt is waiting to be shown. bool get portalLinkPromptPending => _portalLinkPromptPending; + /// A sign-in attempt failed and the user has not been told yet. + /// + /// Only failures the app can ATTRIBUTE to an attempt it started reach this. + /// An unsolicited callback — no pending PKCE pair, or a state mismatch — is + /// dropped silently inside `PortalAccountService` on purpose: a deep link is + /// unauthenticated, so any app on the device could otherwise raise this. + String? get portalSignInError => _portalSignInError; + /// Device name for the link prompt copy (anonymity-aware). String? get portalLinkPromptDeviceName => displayDeviceName; @@ -2456,6 +2473,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _portalAccountService.onSignInComplete = (success, errorCode) { debugLog('[ACCOUNT] Sign-in complete: success=$success, ' 'error=${errorCode ?? 'none'}'); + // A success clears any stale code, so a late toast can never contradict + // a screen that already says "signed in". + _portalSignInError = success ? null : (errorCode ?? 'unknown'); notifyListeners(); }; @@ -9062,9 +9082,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future beginPortalSignIn() async { if (kIsWeb) return false; debugLog('[ACCOUNT] Sign-in requested'); + // A retry must not inherit the previous attempt's verdict. + _portalSignInError = null; + notifyListeners(); return _portalAccountService.beginSignIn(); } + /// Drop the pending sign-in failure once the user has been shown it. + void clearPortalSignInError() { + if (_portalSignInError == null) return; + _portalSignInError = null; + notifyListeners(); + } + /// Sign out locally and revoke the token server-side (best effort). Future portalSignOut() async { debugLog('[ACCOUNT] Sign-out requested'); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index adf16324..ad377b7d 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -32,6 +32,7 @@ class _MainScaffoldState extends State { bool _hasShownLocationSettingsPrompt = false; bool _floodDisabledDialogOpen = false; bool _linkPromptDialogOpen = false; + bool _signInErrorToastOpen = false; final List _screens = [ const HomeScreen(), @@ -281,6 +282,31 @@ class _MainScaffoldState extends State { ); } + /// Report a failed sign-in. + /// + /// The codes are the bounded set `PortalAccountService` emits — the portal's + /// own are already held to `^[a-z_]{1,32}$` by `_sanitizeErrorCode` — but the + /// copy is written here so an unrecognized code can never reach the user as + /// raw text. + void _showPortalSignInError(String code) { + debugLog('[ACCOUNT] Reporting a failed sign-in (reason=$code)'); + // A cancel is a decision, not a failure — it gets the neutral toast. + final isCancel = code == 'denied' || code == 'access_denied'; + final message = switch (code) { + 'denied' || 'access_denied' => 'Sign-in cancelled', + 'expired' => 'That sign-in expired — please try again', + 'timeout' || 'network' => + 'Could not reach MyMeshMapper — check your connection', + 'code_invalid' => 'That sign-in link was already used — please try again', + _ => 'Sign-in did not finish — please try again', + }; + if (isCancel) { + AppToast.info(context, message); + } else { + AppToast.error(context, message); + } + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -333,6 +359,22 @@ class _MainScaffoldState extends State { }); } + // A failed MyMeshMapper sign-in is reported HERE and nowhere else: the + // browser round trip outlives the Settings tap that started it, so by the + // time the callback lands there is no live call site left to answer. The + // scaffold survives the trip whichever tab the user comes back to. + if (appState.portalSignInError != null && !_signInErrorToastOpen) { + _signInErrorToastOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _signInErrorToastOpen = false; + if (!mounted) return; + final code = appState.portalSignInError; + if (code == null) return; + appState.clearPortalSignInError(); + _showPortalSignInError(code); + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; From 1848c1133046f2b81865bfdfd8d8680ce31c10f5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 17:37:10 -0400 Subject: [PATCH 305/360] Add a dog GPS marker --- lib/screens/settings_screen.dart | 3 + lib/widgets/map_widget.dart | 110 ++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 761c5e04..e10e557d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1295,6 +1295,8 @@ class _SettingsScreenState extends State { return 'Boat'; case 'walk': return 'Walk'; + case 'dog': + return 'Dog'; case 'chomper': return 'Chomper'; case 'arrow': @@ -1360,6 +1362,7 @@ class _SettingsScreenState extends State { ('bike', 'Bike', const Icon(Icons.directions_bike)), ('boat', 'Boat', const Icon(Icons.directions_boat)), ('walk', 'Walk', const Icon(Icons.directions_walk)), + ('dog', 'Dog', const FaIcon(FontAwesomeIcons.dog)), ('chomper', 'Chomper', const _ChomperIcon()), ]; showModalBottomSheet( diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 40e14933..633713ff 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3595,7 +3595,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // 2. Coverage markers — 8 variants for current style await _registerCoverageImages(appState.preferences.markerStyle); - // 3. GPS marker variants — 6 styles + // 3. GPS marker variants — 7 styles const gpsSize = Size(48, 48); final gpsPainters = { 'arrow': const _ArrowPainter(), @@ -3603,6 +3603,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { 'bike': const _BikeMarkerPainter(), 'boat': const _BoatMarkerPainter(), 'walk': const _WalkMarkerPainter(), + 'dog': const _DogMarkerPainter(), 'chomper': const _ChomperMarkerPainter(), }; for (final entry in gpsPainters.entries) { @@ -10138,6 +10139,113 @@ class _ChomperMarkerPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a dog silhouette (side profile, facing right) for GPS position marker +class _DogMarkerPainter extends CustomPainter { + const _DogMarkerPainter(); + + static const _blue = Color(0xFF2196F3); + static const _darkBlue = Color(0xFF1565C0); + + /// Body, neck, head and muzzle as one closed silhouette around [cx],[cy]. + ui.Path _bodyPath(double cx, double cy) { + return ui.Path() + ..moveTo(cx - 12, cy - 3) // rump + ..quadraticBezierTo(cx - 7, cy - 5.5, cx - 2, cy - 5) // back + ..quadraticBezierTo(cx + 1, cy - 5, cx + 2.5, cy - 7) // withers + ..lineTo(cx + 3.5, cy - 9.5) // nape + ..quadraticBezierTo(cx + 4, cy - 11.5, cx + 6.5, cy - 11) // skull + ..quadraticBezierTo(cx + 8.5, cy - 10.5, cx + 8.5, cy - 8.5) // stop + ..lineTo(cx + 11.5, cy - 7.5) // muzzle + ..quadraticBezierTo(cx + 12.5, cy - 7, cx + 12, cy - 5.5) // nose + ..lineTo(cx + 8, cy - 5) // under the muzzle + ..quadraticBezierTo(cx + 6.5, cy - 4.5, cx + 6, cy - 2.5) // throat + ..quadraticBezierTo(cx + 5.5, cy, cx + 4, cy + 1.5) // chest + ..quadraticBezierTo(cx - 2, cy + 3.5, cx - 8, cy + 1.5) // belly + ..quadraticBezierTo(cx - 12.5, cy + 0.5, cx - 12, cy - 3) // haunch + ..close(); + } + + /// Tail, sweeping up and back off the rump. + ui.Path _tailPath(double cx, double cy) { + return ui.Path() + ..moveTo(cx - 11.5, cy - 3) + ..quadraticBezierTo(cx - 14, cy - 6, cx - 12.5, cy - 10); + } + + /// Floppy ear, hanging down over the cheek. + ui.Path _earPath(double cx, double cy) { + return ui.Path() + ..moveTo(cx + 3.2, cy - 9.5) + ..quadraticBezierTo(cx + 2.2, cy - 6.5, cx + 3.5, cy - 4.5) + ..quadraticBezierTo(cx + 5.5, cy - 4, cx + 6, cy - 6) + ..quadraticBezierTo(cx + 6, cy - 8.5, cx + 5, cy - 10) + ..close(); + } + + /// Near pair drawn a little ahead of the far pair, for depth. + static const _legs = <(double, double, double, double)>[ + (4, 0, 4.5, 8), // front near + (1.5, 0, 1, 7.5), // front far + (-8, 0, -9, 8), // hind near + (-5.5, 0, -6, 7.5), // hind far + ]; + + void _drawLegs(Canvas canvas, double cx, double cy, Paint paint) { + for (final (x1, y1, x2, y2) in _legs) { + canvas.drawLine( + Offset(cx + x1, cy + y1), Offset(cx + x2, cy + y2), paint); + } + } + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final body = _bodyPath(cx, cy); + final tail = _tailPath(cx, cy); + + // Every white outline goes down before any blue, so the halo around one + // limb never paints over the blue of the one beside it. + final legOutline = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 5 + ..strokeCap = StrokeCap.round; + final bodyOutline = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + _drawLegs(canvas, cx, cy, legOutline); + canvas.drawPath(tail, legOutline); + canvas.drawPath(body, bodyOutline); + + final legPaint = Paint() + ..color = _blue + ..style = PaintingStyle.stroke + ..strokeWidth = 2.6 + ..strokeCap = StrokeCap.round; + + _drawLegs(canvas, cx, cy, legPaint); + canvas.drawPath(tail, legPaint); + canvas.drawPath(body, Paint()..color = _blue); + + // Ear and nose read as darker shading on top of the body, the way the car + // marker's windshield does — no white outline, so nothing separates limbs. + canvas.drawPath(_earPath(cx, cy), Paint()..color = _darkBlue); + canvas.drawCircle( + Offset(cx + 11.3, cy - 6.3), 0.9, Paint()..color = _darkBlue); + canvas.drawCircle( + Offset(cx + 7, cy - 8.5), 1, Paint()..color = Colors.white); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + /// Paints a teardrop/pin marker for coverage dots class _PinMarkerPainter extends CustomPainter { final Color color; From 9dadc1bfe8c28ca4b55e86eb86c50a7176674872 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 17:56:21 -0400 Subject: [PATCH 306/360] Show flood-disabled regions in blue, not red --- lib/screens/connection_screen.dart | 14 ++++++++++++-- lib/screens/home_screen.dart | 17 ++++++++++++++++- lib/screens/main_scaffold.dart | 10 +++++----- lib/screens/settings_screen.dart | 6 ++++-- lib/widgets/status_bar.dart | 17 ++++++++++++++++- 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 65f6978f..b65731c2 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1493,9 +1493,15 @@ class _ConnectionScreenState extends State final slotsMax = appState.zoneSlotsMax; final hasSlots = slotsAvailable != null && slotsMax != null; + // A flood-disabled region reports 0/0 slots — that is a regional setting, + // not a full zone, so it reads as info blue rather than red. + final floodDisabled = appState.floodDisabled; + // Slots color based on availability Color slotsColor; - if (!hasSlots) { + if (floodDisabled) { + slotsColor = Colors.blue; + } else if (!hasSlots) { slotsColor = Colors.grey; } else if (slotsAvailable == 0) { slotsColor = Colors.red; @@ -1543,7 +1549,11 @@ class _ConnectionScreenState extends State // Slots chip _buildChip( icon: Icons.people_outline, - text: hasSlots ? '$slotsAvailable/$slotsMax Open' : '--', + text: floodDisabled + ? 'Passive Only' + : hasSlots + ? '$slotsAvailable/$slotsMax Open' + : '--', color: slotsColor, ), ], diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e42d7ccc..1881e5a1 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -376,6 +376,17 @@ class _HomeScreenState extends State { Colors.grey ); } + // Region veto comes first: a flood-disabled region reports 0 TX slots, + // which would otherwise read as "zone full" (red) instead of a + // deliberate regional setting (blue). + if (appState.floodDisabled) { + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', + 'This region has flood traffic disabled, so you can only wardrive passively here. Passive and Trace modes still work. Contact your regional admin if you have questions.', + Icons.flight, + Colors.blue + ); + } if (!appState.txAllowed) { return ( '${appState.zoneName ?? appState.zoneCode} Zone', @@ -837,7 +848,11 @@ class _HomeScreenState extends State { if (appState.inZone == true && appState.zoneCode != null) { icon = Icons.flight; color = appState.isConnected - ? (appState.txAllowed ? Colors.green : Colors.red) + ? (appState.floodDisabled + ? Colors.blue // Region veto: passive only, not an error + : appState.txAllowed + ? Colors.green + : Colors.red) : Colors.grey; text = appState.zoneCode!; } else if (appState.inZone == false) { diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index ad377b7d..cc529f0e 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -145,12 +145,12 @@ class _MainScaffoldState extends State { await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Flood Traffic Unavailable'), + title: const Text('Passive Wardriving Only'), content: const Text( - 'Your regional MeshMapper admin has disabled flood traffic in this ' - 'area, so Active and Hybrid modes have been turned off for this ' - 'session. Passive Mode and Trace Mode remain available. Please ' - 'reach out to your regional admin if you have questions.', + 'This region has flood traffic disabled, so Active and Hybrid modes ' + 'have been turned off for this session. You can still wardrive ' + 'passively — Passive Mode and Trace Mode remain available. Contact ' + 'your regional admin if you have questions.', ), actions: [ TextButton( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e10e557d..e9e782ff 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -370,8 +370,10 @@ class _SettingsScreenState extends State { title: const Text('Flood Traffic'), subtitle: appState.floodDisabled ? const Text( - 'Set by Regional Admin — flood traffic is disabled in this region. Active and Hybrid modes are unavailable here.', - style: TextStyle(color: Colors.amber), + 'Set by Regional Admin — this region has flood traffic disabled, ' + 'so you can only wardrive passively here. Contact your regional ' + 'admin if you have questions.', + style: TextStyle(color: Colors.blue), ) : const Text( 'Show Active, Hybrid, and manual Send Ping controls'), diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 9b139b7a..70389db0 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -141,6 +141,17 @@ class _StatusBarState extends State { Colors.grey ); } + // Region veto comes first: a flood-disabled region reports 0 TX slots, + // which would otherwise read as "zone full" (red) instead of a + // deliberate regional setting (blue). + if (appState.floodDisabled) { + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', + 'This region has flood traffic disabled, so you can only wardrive passively here. Passive and Trace modes still work. Contact your regional admin if you have questions.', + Icons.flight, + Colors.blue + ); + } if (!appState.txAllowed) { return ( '${appState.zoneName ?? appState.zoneCode} Zone', @@ -246,7 +257,11 @@ class _StatusBarState extends State { if (appState.inZone == true && appState.zoneCode != null) { icon = Icons.flight; color = appState.isConnected - ? (appState.txAllowed ? Colors.green : Colors.red) + ? (appState.floodDisabled + ? Colors.blue // Region veto: passive only, not an error + : appState.txAllowed + ? Colors.green + : Colors.red) : Colors .grey; // Grey when not connected, red when zone is at TX capacity text = appState.zoneCode!; From 3291b4a845a50b8a46b9f3b8105314a098839056 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 18:12:25 -0400 Subject: [PATCH 307/360] Show a zone with no TX slots as passive, not full --- lib/screens/connection_screen.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index b65731c2..a18ee1d9 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1493,13 +1493,15 @@ class _ConnectionScreenState extends State final slotsMax = appState.zoneSlotsMax; final hasSlots = slotsAvailable != null && slotsMax != null; - // A flood-disabled region reports 0/0 slots — that is a regional setting, - // not a full zone, so it reads as info blue rather than red. - final floodDisabled = appState.floodDisabled; + // A flood-disabled region hands out no TX slots at all. Before connecting + // the auth flag isn't known yet, so fall back to the shape of the zone: a + // 0-max zone isn't FULL, it simply offers no TX slots. Either way it's a + // regional setting, so show info blue and drop the misleading 0/0 count. + final passiveOnly = appState.floodDisabled || slotsMax == 0; // Slots color based on availability Color slotsColor; - if (floodDisabled) { + if (passiveOnly) { slotsColor = Colors.blue; } else if (!hasSlots) { slotsColor = Colors.grey; @@ -1548,8 +1550,8 @@ class _ConnectionScreenState extends State // Slots chip _buildChip( - icon: Icons.people_outline, - text: floodDisabled + icon: passiveOnly ? Icons.hearing : Icons.people_outline, + text: passiveOnly ? 'Passive Only' : hasSlots ? '$slotsAvailable/$slotsMax Open' From 3bd3c82dd842ad45d242d8d7d2505b75bba26872 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 18:30:38 -0400 Subject: [PATCH 308/360] Keep the zone status bar from overflowing on narrow phones --- lib/screens/connection_screen.dart | 61 +++++++++++++++++++----------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index a18ee1d9..ab02ca0b 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1529,30 +1529,42 @@ class _ConnectionScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ - // Location chip (city name) - _buildChip( - icon: locationIcon, - text: locationText, - color: locationColor, - ), + // The leading group absorbs the leftover width (replacing a + // Spacer) so the slots chip stays pinned right while a long city + // name ellipsizes instead of overflowing on a narrow phone. + Expanded( + child: Row( + children: [ + // Location chip (city name) — the only variable-width chip, + // so it is the one that gives way when space runs out. + Flexible( + child: _buildChip( + icon: locationIcon, + text: locationText, + color: locationColor, + ), + ), - // IATA code chip (only when in zone) - if (iataCode != null) ...[ - const SizedBox(width: 8), - _buildChip( - icon: Icons.flight, - text: iataCode, - color: Colors.blue, + // IATA code chip (only when in zone) + if (iataCode != null) ...[ + const SizedBox(width: 8), + _buildChip( + icon: Icons.flight, + text: iataCode, + color: Colors.blue, + ), + ], + ], ), - ], + ), - const Spacer(), + const SizedBox(width: 8), // Slots chip _buildChip( icon: passiveOnly ? Icons.hearing : Icons.people_outline, text: passiveOnly - ? 'Passive Only' + ? 'Passive' : hasSlots ? '$slotsAvailable/$slotsMax Open' : '--', @@ -1582,12 +1594,17 @@ class _ConnectionScreenState extends State children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), - Text( - text, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color, + Flexible( + child: Text( + text, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), ), ), ], From 02ac778b6344e68e98aeceb9a0bae419aa4b599d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 19:33:17 -0400 Subject: [PATCH 309/360] Say it's the phone clock, not GPS, when a fix looks stale --- lib/providers/app_state_provider.dart | 9 +++++++-- lib/screens/connection_screen.dart | 28 ++++++++++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index dc2dc0f6..6fc4ecf2 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -7145,7 +7145,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case 'zone_full': return 'Zone is at TX capacity. You can still receive (RX-only mode).'; case 'gps_stale': - return 'GPS data is too old. Acquiring fresh position...'; + return "Your phone's clock is out of sync. Turn on automatic date and time in your phone settings."; case 'gps_inaccurate': return 'GPS accuracy insufficient (need <50m). Waiting for better signal...'; case 'bad_key': @@ -7561,7 +7561,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _scheduleZoneCheckRetry( seconds: 10, error: message, reason: 'gps_inaccurate'); } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message', autoSwitch: false); + // The server rejected the position age, which means the phone clock + // disagrees with real time. Name the clock, not the GPS. + logError( + 'Phone Clock Out of Sync\nTurn on automatic date and time in ' + 'your phone settings.\n($message)', + autoSwitch: false); _scheduleZoneCheckRetry( seconds: 10, error: message, reason: 'gps_stale'); } else if (reason == 'zone_disabled') { diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ab02ca0b..6dcc4ba8 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1461,9 +1461,13 @@ class _ConnectionScreenState extends State locationIcon = Icons.wifi_off; locationText = 'No Internet'; locationColor = Colors.red; + // Clock error: a stale position is a wrong phone clock, not bad GPS + } else if (appState.zoneCheckErrorReason == 'gps_stale') { + locationIcon = Icons.schedule; + locationText = 'Clock Out of Sync'; + locationColor = Colors.orange; // GPS error: show GPS issue indicator - } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || - appState.zoneCheckErrorReason == 'gps_stale') { + } else if (appState.zoneCheckErrorReason == 'gps_inaccurate') { locationIcon = Icons.gps_off; locationText = 'GPS Unavailable'; locationColor = Colors.orange; @@ -1880,18 +1884,24 @@ class _ConnectionScreenState extends State ); } - // GPS errors — no auto-retry, show manual retry button + // GPS errors — no auto-retry, show manual retry button. + // 'gps_stale' means the position timestamp doesn't line up with server + // time, which is almost always a wrong phone clock rather than a weak + // fix. Say so plainly, or people chase a GPS problem that isn't there. if (appState.zoneCheckErrorReason == 'gps_inaccurate' || appState.zoneCheckErrorReason == 'gps_stale') { + final isStale = appState.zoneCheckErrorReason == 'gps_stale'; return _buildMessageContent( context: context, - icon: Icons.gps_off, + icon: isStale ? Icons.schedule : Icons.gps_off, iconColor: Colors.orange.withValues(alpha: 0.7), - title: appState.zoneCheckErrorReason == 'gps_inaccurate' - ? 'GPS Accuracy Error' - : 'GPS Stale Error', - message: - '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + title: isStale ? 'Phone Clock Out of Sync' : 'GPS Accuracy Error', + message: isStale + ? "Your phone's clock is off, so your location looks out of date. " + 'This is a time problem, not a GPS problem.\n\n' + "Open your phone's Date & Time settings, turn on automatic " + 'date and time (and automatic time zone), then tap retry.' + : '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', action: FilledButton.icon( onPressed: () => appState.checkZoneStatus(), icon: const Icon(Icons.refresh), From 554fab2ceb52e30c51ad1edaca5905b6a1d8214c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 19:37:12 -0400 Subject: [PATCH 310/360] Say what a flood-disabled zone means, and that it isn't full --- lib/screens/connection_screen.dart | 151 +++++++++++++++++++++++------ lib/screens/home_screen.dart | 2 +- lib/screens/main_scaffold.dart | 10 +- lib/screens/settings_screen.dart | 6 +- lib/widgets/status_bar.dart | 2 +- 5 files changed, 134 insertions(+), 37 deletions(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 6dcc4ba8..2bdd44e4 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1539,8 +1539,8 @@ class _ConnectionScreenState extends State Expanded( child: Row( children: [ - // Location chip (city name) — the only variable-width chip, - // so it is the one that gives way when space runs out. + // Location chip (city name). This is the only variable-width + // chip, so it is the one that gives way when space runs out. Flexible( child: _buildChip( icon: locationIcon, @@ -1566,13 +1566,14 @@ class _ConnectionScreenState extends State // Slots chip _buildChip( - icon: passiveOnly ? Icons.hearing : Icons.people_outline, + icon: passiveOnly ? Icons.waves : Icons.people_outline, text: passiveOnly - ? 'Passive' + ? 'Flood Off' : hasSlots ? '$slotsAvailable/$slotsMax Open' : '--', color: slotsColor, + onTap: passiveOnly ? () => _showFloodDisabledInfo(context) : null, ), ], ), @@ -1585,33 +1586,129 @@ class _ConnectionScreenState extends State required IconData icon, required String text, required Color color, + VoidCallback? onTap, }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.4)), + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + text, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 4), - Flexible( - child: Text( - text, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color, + ); + } + + /// Explains a flood-disabled region. + /// + /// The chip has room for eight characters, which cannot say that Trace Mode + /// still works, so the detail lives here. Layout mirrors + /// [_showAuthMethodInfo]; wording matches the region-veto dialog in + /// MainScaffold so users see one consistent explanation. + void _showFloodDisabledInfo(BuildContext context) { + final bodyStyle = TextStyle( + fontSize: 14, + height: 1.4, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with close button + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), + ), + child: + const Icon(Icons.waves, color: Colors.blue, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Flood Traffic Disabled', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], ), ), - ), - ], + const Divider(height: 1), + // Content + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Your regional admin has turned off flood traffic in ' + 'this area, so Active and Hybrid modes are unavailable ' + 'here. This is a regional setting, not a full zone.', + style: bodyStyle, + ), + const SizedBox(height: 12), + Text( + 'You can still wardrive. Passive Mode and Trace Mode ' + 'work normally, and the app keeps logging everything ' + 'your radio hears.', + style: bodyStyle, + ), + const SizedBox(height: 12), + Text( + 'Contact your regional admin if you have questions.', + style: bodyStyle, + ), + ], + ), + ), + ), + ], + ), ), ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1881e5a1..a85230e4 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -382,7 +382,7 @@ class _HomeScreenState extends State { if (appState.floodDisabled) { return ( '${appState.zoneName ?? appState.zoneCode} Zone', - 'This region has flood traffic disabled, so you can only wardrive passively here. Passive and Trace modes still work. Contact your regional admin if you have questions.', + 'Your regional admin has turned off flood traffic here, so Active and Hybrid modes are unavailable. The zone is not full. Passive Mode and Trace Mode still work.', Icons.flight, Colors.blue ); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index cc529f0e..fcf64037 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -145,12 +145,12 @@ class _MainScaffoldState extends State { await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Passive Wardriving Only'), + title: const Text('Flood Traffic Disabled'), content: const Text( - 'This region has flood traffic disabled, so Active and Hybrid modes ' - 'have been turned off for this session. You can still wardrive ' - 'passively — Passive Mode and Trace Mode remain available. Contact ' - 'your regional admin if you have questions.', + 'Your regional admin has turned off flood traffic in this area, so ' + 'Active and Hybrid modes have been turned off for this session. You ' + 'can still wardrive. Passive Mode and Trace Mode work normally. ' + 'Contact your regional admin if you have questions.', ), actions: [ TextButton( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e9e782ff..646a774d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -370,9 +370,9 @@ class _SettingsScreenState extends State { title: const Text('Flood Traffic'), subtitle: appState.floodDisabled ? const Text( - 'Set by Regional Admin — this region has flood traffic disabled, ' - 'so you can only wardrive passively here. Contact your regional ' - 'admin if you have questions.', + 'Set by Regional Admin. Flood traffic is turned off here, so ' + 'Active and Hybrid modes are unavailable. Passive and Trace ' + 'still work.', style: TextStyle(color: Colors.blue), ) : const Text( diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 70389db0..41c6ca93 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -147,7 +147,7 @@ class _StatusBarState extends State { if (appState.floodDisabled) { return ( '${appState.zoneName ?? appState.zoneCode} Zone', - 'This region has flood traffic disabled, so you can only wardrive passively here. Passive and Trace modes still work. Contact your regional admin if you have questions.', + 'Your regional admin has turned off flood traffic here, so Active and Hybrid modes are unavailable. The zone is not full. Passive Mode and Trace Mode still work.', Icons.flight, Colors.blue ); From df49a7f4bc0dd6cf198c2e80aef111d1fe108785 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 19:45:11 -0400 Subject: [PATCH 311/360] Refuse to send a ping that can't carry a wire tag --- lib/services/ping_service.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index e4909f97..6d1e3b94 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,9 +597,9 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // With a session: a keyed wire tag "MM:" (privacy default), or - // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). - // No session yet: plaintext "MM:lat,lon" (no tag can be computed). + // A keyed wire tag "MM:" (privacy default), or "MM::lat,lon" when + // Broadcast My Coordinates is on (tag + plaintext coords). Those are the only + // two shapes: without a session there is no tag, and the ping is refused. final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -628,8 +628,13 @@ class PingService { // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; } else { - // No session yet → no tag can be computed; plaintext coords only (unchanged). - pingMessage = 'MM:$coordsStr'; + // Unreachable: tx_allowed and session_id arrive in the same /auth response, + // and every TX validator requires txAllowed. If we get here the session state + // is corrupt, so refuse to transmit rather than emit a tagless (untraceable) + // ping. Every ping the app sends carries a wire tag by construction. + debugError('[PING] TX attempted with no session, aborting ping'); + _pingInProgress = false; + return false; } // Capture noise floor at ping time From 8cf767989c2ca9efc19bb4ba836c0e94f6c82de3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 20:48:09 -0400 Subject: [PATCH 312/360] Finish saving pings offline before the failed batch is discarded --- lib/services/api_service.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index ded6d3fa..06b1f796 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -739,12 +739,12 @@ class ApiService { }; if (criticalErrors.contains(reason)) { _clearSession(); - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); } // outside_zone: notify listener but preserve session (backend auto-transfers on zone re-entry) if (reason == 'outside_zone') { - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); } return (isValid: false, reason: reason, message: message); @@ -858,10 +858,10 @@ class ApiService { if (criticalErrors.contains(reason)) { _clearSession(); - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); } else if (reason == 'outside_zone') { // Preserve session — backend auto-transfers on zone re-entry - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); } else { _onSessionExpiring?.call(); } @@ -988,8 +988,12 @@ class ApiService { final message = result['message'] as String?; // Clear session locally since it's invalid on server _clearSession(); - // Notify listener for auto-disconnect - onSessionError?.call(reason, message); + // Notify listener for auto-disconnect. MUST be awaited: the handler + // snapshots the queue to offline storage, and returning nonRetryable + // makes the caller DISCARD that same batch. Fired bare, the snapshot's + // first real suspension (a non-empty RX buffer flushing to Hive) let + // the discard land first, so the preserved pings came back short. + await onSessionError?.call(reason, message); return UploadResult.nonRetryable; } @@ -999,7 +1003,7 @@ class ApiService { debugWarn( '[API] Upload batch outside_zone — discarding batch, preserving session'); final message = result['message'] as String?; - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); return UploadResult.nonRetryable; } From 6c6306962dbf5bd4e3768977d65050cab6a7d47f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 20:48:40 -0400 Subject: [PATCH 313/360] Drop queued pings whose wire tag outlived its session --- lib/models/api_queue_item.dart | 12 +++ lib/providers/app_state_provider.dart | 8 ++ lib/services/api_queue_service.dart | 67 +++++++++++++- lib/services/api_service.dart | 18 ++++ test/services/api_queue_wire_tag_test.dart | 101 +++++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 test/services/api_queue_wire_tag_test.dart diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 472c4c8f..faa7fdf5 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -290,6 +290,18 @@ class ApiQueueItem extends HiveObject { }; } + /// Whether this item carries a TX wire-tag claim. + /// + /// A tag only re-derives under the session that minted it, so a tagged item + /// is uploadable ONLY under that session. Once it outlives it the item is + /// undeliverable and gets dropped rather than uploaded, because every + /// alternative is worse: keeping the tag makes the server skip the row while + /// reporting success (a silent loss plus a wire_tag_mismatch warn), and + /// stripping it sends the ping down the coords path where, with no status-4 + /// WAIT row left to join, it inserts as DEAD(3) and paints a GREY "dead" + /// cell on the map for a ping that actually got heard. + bool get hasWireTag => wireTag != null || pingCounter != null; + /// Calculate next retry delay using exponential backoff Duration get nextRetryDelay { // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, max 60s diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 6fc4ecf2..42f9821b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2485,6 +2485,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await handleSessionError(reason, message); }; + // A new session id makes every wire tag already queued under the old one + // undeliverable (auto-reconnect preserves the queue across re-auth). Drop + // those pings honestly instead of uploading claims the server will skip + // while reporting success. + _apiService.onSessionIdChanged = () { + unawaited(_apiQueueService.dropStaleTaggedItems()); + }; + // Set up maintenance mode callback (for connected state) _apiService.onMaintenanceMode = (message, url) { debugLog('[MAINTENANCE] Callback triggered: $message'); diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index ced5ea86..cd307084 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -747,8 +747,65 @@ class ApiQueueService { _offlinePings.clear(); } + /// Drop every queued item whose wire tag can no longer be validated. + /// + /// A wire tag only re-derives under the session that minted it: the server + /// recomputes it from the session_id the batch is POSTed under and skips the + /// entry entirely on a mismatch, while still returning success, so the app + /// prunes the item as uploaded and the TX ping is lost with no error + /// anywhere (`wardrive-api.php`, action=wire_tag_mismatch). + /// + /// Call this whenever the session id changes underneath a preserved queue. + /// Every item queued at that moment was necessarily minted under the old + /// session (anything minted under the new one is enqueued afterwards), so + /// dropping all tagged items is exact and needs no per-item bookkeeping. + /// + /// Auto-reconnect is the path that matters: it deliberately preserves the + /// queue, and /auth only reuses a session while it is status=1 and + /// unexpired. Otherwise a fresh session_id comes back and everything + /// already queued is stale. + /// + /// Dropping rather than un-tagging is deliberate. Re-minting under the new + /// session would claim a tag that never went out on the air. Stripping the + /// tag sends the ping down the server coords path, where hours (or just a + /// reconnect) later there is no status-4 WAIT row to join, so it inserts as + /// DEAD(3) and renders a GREY "dead" cell for a ping that was actually + /// heard. At roughly 0.2% of TX pings, an honest drop beats a misleading + /// map. + Future dropStaleTaggedItems() async { + final staleHive = _safeRead( + (box) => box.values.where((i) => i.hasWireTag).toList(), + [], + ); + for (final item in staleHive) { + try { + await item.delete(); + } catch (e) { + debugError('[API QUEUE] Failed to drop stale tagged item: $e'); + } + } + + final beforeMemory = _memoryQueue.length; + _memoryQueue.removeWhere((i) => i.hasWireTag); + final dropped = staleHive.length + (beforeMemory - _memoryQueue.length); + + if (dropped > 0) { + debugWarn( + '[API QUEUE] Session changed: dropped $dropped queued TX ping(s) whose wire tag ' + 'was minted under the old session (undeliverable)'); + onQueueUpdated?.call(queueSize); + } + } + /// Extract all queued items as API JSON without clearing the queue. /// Used to preserve data before session-expiry disconnect. + /// + /// Tagged TX pings are left OUT of the snapshot. It is bound for offline + /// storage and gets re-uploaded under a brand new `offline-YYYYMMDD-NNNN` + /// session, where the tag cannot re-derive: the server would skip the row + /// while still reporting success, so the ping is lost either way. Preserving + /// it only buys a wire_tag_mismatch warn. RX/DISC/TRACE carry no tag and are + /// preserved exactly as before. Future>> extractAllAsJson() async { // Flush RX buffer first so all items are in the main queue await _flushRxBuffer(); @@ -762,7 +819,15 @@ class ApiQueueService { if (allItems.isEmpty) return []; - return allItems.map((item) => item.toApiJson()).toList(); + final deliverable = allItems.where((i) => !i.hasWireTag).toList(); + final skipped = allItems.length - deliverable.length; + if (skipped > 0) { + debugWarn( + '[API QUEUE] Preserving offline: skipped $skipped tagged TX ping(s) that no ' + 'offline session could upload (kept ${deliverable.length} untagged item(s))'); + } + + return deliverable.map((item) => item.toApiJson()).toList(); } /// Dispose of resources diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 06b1f796..b9e5eb1b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -429,7 +429,21 @@ class ApiService { if ((reason == 'connect' || reason == 'register') && data['success'] == true) { if (!skipSessionStore) { + // A wire tag only re-derives under the session that minted it. When + // the server hands back a DIFFERENT session id (it reuses one only + // while status=1 and unexpired), anything still sitting in the queue + // was tagged under the old session and would be silently dropped on + // upload, so tell the listener to drop those pings. + final previousSessionId = _sessionId; _sessionId = data['session_id'] as String?; + if (previousSessionId != null && + _sessionId != null && + previousSessionId != _sessionId) { + debugLog( + '[SESSION] New session id issued (was $previousSessionId, now $_sessionId). ' + 'Queued wire tags are stale'); + onSessionIdChanged?.call(); + } _txAllowed = data['tx_allowed'] == true; _rxAllowed = data['rx_allowed'] == true; _sessionExpiresAt = data['expires_at'] as int?; @@ -901,6 +915,10 @@ class ApiService { /// Callback for maintenance mode detection (while connected) void Function(String message, String? url)? onMaintenanceMode; + /// Fired when /auth returns a session id different from the one we held. + /// Wired to ApiQueueService.dropStaleTaggedItems(). See that method for why. + void Function()? onSessionIdChanged; + /// Force-rebuild one vector coverage tile on the region server /// (`vector_tile.php?...&fresh=1`, see VECTOR_TILES.md). Used by the /// post-wardrive live refresh: it keeps the server cache hot AND hands the diff --git a/test/services/api_queue_wire_tag_test.dart b/test/services/api_queue_wire_tag_test.dart new file mode 100644 index 00000000..9e70541d --- /dev/null +++ b/test/services/api_queue_wire_tag_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +/// Regression tests for the silently-dropped TX pings behind server issues +/// #108 and #123. +/// +/// A TX wire tag is `wireTagEncode(session_id, ping_counter, key)`. The server +/// recomputes it from the session the batch is POSTed under and skips the entry +/// outright on a mismatch (`wardrive-api.php`, action=wire_tag_mismatch) while +/// still returning success, so the app prunes the item as uploaded and the +/// ping is gone with no error anywhere. +/// +/// That means a tag is only ever valid under the session that minted it. Two +/// app paths carried a tag across a session boundary; prod access.log for +/// 2026-08-23 shows 102 of 111 mismatches on `offline-*` sessions (the first +/// case) and 9 on live ones (the second). +/// +/// Such an item is now DROPPED, not un-tagged. It is ~0.2% of TX pings, and +/// every alternative is worse: keeping the tag is a silent loss plus a warn, +/// and stripping it sends the ping down the server coords path where, with no +/// status-4 WAIT row left to join, it inserts as DEAD(3) and paints a grey +/// "dead" cell on the map for a ping that was actually heard. +void main() { + ApiQueueService newQueue() => + // No init(), so Hive stays closed and _safeWrite falls back to the + // in-memory queue. That exercises the same enqueue/extract code paths + // without needing a Hive box in the test harness. + ApiQueueService(apiService: ApiService()); + + Future enqueueTaggedTx(ApiQueueService queue) => queue.enqueueTx( + latitude: 45.26974, + longitude: -75.77746, + heardRepeats: '4e(12.25)', + timestamp: 1750000000, + externalAntenna: false, + pingCounter: 13, + wireTag: 'MM:YVNPAr5OIw', + ); + + Future enqueueRx(ApiQueueService queue) => queue.enqueueRx( + latitude: 45.26974, + longitude: -75.77746, + heardRepeats: '4e(12.25)', + timestamp: 1750000000, + repeaterId: '4e', + externalAntenna: false, + ); + + group('tagged pings never outlive the session that minted them', () { + test('offline preservation drops them (#108, 92% of prod mismatches)', + () async { + final queue = newQueue(); + await enqueueTaggedTx(queue); + await enqueueRx(queue); + + // handleSessionError() snapshots the queue here and hands it to + // OfflineSessionService, which re-uploads it later under a brand new + // `offline-YYYYMMDD-NNNN` session id. + final preserved = await queue.extractAllAsJson(); + + expect(preserved, hasLength(1), + reason: 'the tagged TX ping is undeliverable under any offline ' + 'session, so it must not be preserved at all'); + expect(preserved.single['type'], 'RX', + reason: 'untagged observations still preserve exactly as before'); + expect(preserved.any((p) => p['wire_tag'] != null), isFalse); + expect(preserved.any((p) => p['ping_counter'] != null), isFalse); + }); + + test('a new session id drops what is already queued (#123, live sessions)', + () async { + final queue = newQueue(); + await enqueueTaggedTx(queue); + await enqueueRx(queue); + + // Auto-reconnect deliberately preserves the queue, but /auth only reuses + // a session while it is status=1 and unexpired. Otherwise a new + // session_id comes back and everything already queued is stale. + await queue.dropStaleTaggedItems(); + + final pending = await queue.extractAllAsJson(); + expect(pending, hasLength(1)); + expect(pending.single['type'], 'RX', + reason: 'RX carries no tag, so a new session does not invalidate it'); + }); + + test('an untagged queue is left completely alone', () async { + final queue = newQueue(); + await enqueueRx(queue); + + await queue.dropStaleTaggedItems(); + + final pending = await queue.extractAllAsJson(); + expect(pending, hasLength(1)); + expect(pending.single['type'], 'RX'); + expect(pending.single['heard_repeats'], '4e(12.25)'); + }); + }); +} From adbea238a067785dcf800bf37cb0fc7455e937f0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 21:29:06 -0400 Subject: [PATCH 314/360] Back off when the portal says we're asking too often --- DEVELOPMENT.md | 13 +++ lib/providers/app_state_provider.dart | 15 +++- lib/screens/settings_screen.dart | 17 ++++ lib/services/portal_account_service.dart | 90 ++++++++++++++++++- .../services/portal_account_service_test.dart | 89 ++++++++++++++++++ 5 files changed, 221 insertions(+), 3 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 27bb2a4a..a6d0eb13 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -447,6 +447,19 @@ a connection.** Mobile only (`!kIsWeb`). Server contract: counter also holds nonce and network failures, so it cannot stand in). A local sign that succeeds clears the unsupported strike; a `LinkSuccess` clears BOTH counters and the persisted verdict. +- **A 429 is terminal and always carries `Retry-After`**: the portal's buckets + slide and a blocked request does NOT reset the count, it re-arms a FRESH + penalty (`me` is 12/hour with a 600s penalty, so a user who keeps tapping + extends their own lockout). `_postWithToken` parses the header (delta-seconds, + clamped to 1 hour, `PortalApi.defaultRetryAfter` = 5 min when it is missing or + unparseable) into a per-route block, readable as `rateLimitBackoff(route)` and + `linkLaneBackoff` (the longer of nonce/link). Three consumers: + `refreshMe(force: true)` skips the LOCAL hourly throttle but NEVER a server + block, and the Settings refresh button says how long to wait instead of + reprinting a stale device count; `logout` does not retry into a 429 and accepts + the orphaned server token; `_recordLinkFailure` takes the longer of its own + 30s..8m ladder and the server's value. A 429 is never a sign-out: 401 + + `token_invalid` stays the only signed-out signal. - **Persistence** (Hive `user_preferences`): `portal_account_info`, `portal_linked_pubkeys` (UPPER hex), `portal_link_declined_devices`, `portal_sign_unsupported_devices`. Sign-out clears the first two and keeps the diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 42f9821b..7234df14 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -9119,6 +9119,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _portalAccountService.refreshMe(force: force); } + /// How long the portal has told us to stay off `me`, or null when a refresh + /// is free to run. The Settings refresh button reads this so a rate-limited + /// tap says so instead of re-printing a stale device count. + Duration? get portalRefreshBackoff => + _portalAccountService.rateLimitBackoff('me'); + /// Clear every persisted decline AND every sign-unsupported verdict, so both /// kinds of suppressed radio are offered again. /// @@ -9384,7 +9390,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { PortalLinkOutcome _recordLinkFailure(String pubkey, String reason) { final attempts = (_portalLinkAttempts[pubkey] ?? 0) + 1; _portalLinkAttempts[pubkey] = attempts; - final backoffSeconds = math.min(30 * (1 << (attempts - 1)), 1800); + var backoffSeconds = math.min(30 * (1 << (attempts - 1)), 1800); + // When the failure was a 429 the portal named its own wait, and its + // buckets re-arm on every request made during a block. Never come back + // sooner than it asked, even if the ladder says we could. + final serverBackoff = _portalAccountService.linkLaneBackoff; + if (serverBackoff != null && serverBackoff.inSeconds > backoffSeconds) { + backoffSeconds = serverBackoff.inSeconds; + } _portalLinkRetryAfter[pubkey] = DateTime.now().add(Duration(seconds: backoffSeconds)); debugLog('[ACCOUNT] Link attempt $attempts failed ($reason) for ' diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 646a774d..439991eb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2841,10 +2841,27 @@ class _SettingsScreenState extends State { BuildContext context, AppStateProvider appState) async { await appState.refreshPortalAccount(force: true); if (!context.mounted) return; + // A rate-limited refresh makes no request, so the count below would be the + // same stale number every tap, which reads as a dead button and invites + // more tapping. Every tap during a block extends it server-side. + final backoff = appState.portalRefreshBackoff; + if (backoff != null) { + AppToast.error(context, + 'Too many refreshes. Try again in ${_formatBackoff(backoff)}.'); + return; + } AppToast.simple( context, '${appState.portalLinkedDeviceCount} linked device(s)'); } + /// Rounded up: telling someone to wait "0 minutes" is worse than telling + /// them to wait a beat longer than they have to. + String _formatBackoff(Duration backoff) { + if (backoff.inSeconds < 60) return '${backoff.inSeconds + 1} seconds'; + final minutes = (backoff.inSeconds / 60).ceil(); + return minutes == 1 ? '1 minute' : '$minutes minutes'; + } + Future _resetPortalDeclines( BuildContext context, AppStateProvider appState) async { await appState.resetLinkPromptDeclines(); diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index 2e24a52a..d56f11a8 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -38,6 +38,15 @@ class PortalApi { /// `me` writes the site-wide auth DB, so it is refreshed at most hourly. static const Duration meThrottle = Duration(hours: 1); + /// Every 429 on this lane carries `Retry-After` in delta-seconds, but a + /// proxy can strip it or rewrite it as an HTTP-date. This is what an + /// unusable header costs. + static const Duration defaultRetryAfter = Duration(minutes: 5); + + /// Ceiling for a server-supplied backoff. The longest the portal sends is + /// 600s, so anything past an hour is a broken header, not an instruction. + static const Duration maxRetryAfter = Duration(hours: 1); + static Uri authorizeUrl({ required String codeChallenge, required String state, @@ -205,10 +214,18 @@ class _PortalResponse { /// The value of `{"error": "..."}` when the body carried one. final String? error; - const _PortalResponse(this.status, this.json, this.error); + /// The `Retry-After` a 429 carried, already parsed and clamped. Null on + /// every answer that was not rate limited. + final Duration? retryAfter; + + const _PortalResponse(this.status, this.json, this.error, {this.retryAfter}); bool get ok => status >= 200 && status < 300 && json['ok'] == true; bool get networkFailure => status == 0; + + /// Terminal on this lane: the portal never softens a 429 on a retry, it + /// re-arms the block (see [PortalAccountService.rateLimitBackoff]). + bool get rateLimited => status == 429; } /// The MyMeshMapper portal lane. @@ -237,6 +254,11 @@ class PortalAccountService { /// (dedupe — see _handleErrorCallback). final Set _consumedErrorStates = {}; + /// Route -> earliest time that route may be called again. Filled from a + /// 429's `Retry-After`. In-memory: a restart is a fresh start, and the + /// server re-answers 429 if the block is still live. + final Map _rateLimitedUntil = {}; + /// Optional friendly label sent with `token` and `link` (device name). String? Function()? deviceLabelProvider; @@ -566,6 +588,43 @@ class PortalAccountService { bool _isTokenInvalid(_PortalResponse response) => response.status == 401 && response.error == 'token_invalid'; + /// How long the portal has told us to stay off [action], or null when that + /// route is clear. + /// + /// Worth honouring precisely: the server's buckets slide, and a request made + /// while blocked does NOT reset the count, it re-arms a FRESH penalty. A + /// client that keeps knocking extends its own lockout indefinitely. + Duration? rateLimitBackoff(String action) { + final until = _rateLimitedUntil[action]; + if (until == null) return null; + final left = until.difference(DateTime.now()); + if (left > Duration.zero) return left; + _rateLimitedUntil.remove(action); + return null; + } + + /// The longest live block across the two routes one link attempt walks. + /// Either one blocked means the attempt cannot finish. + Duration? get linkLaneBackoff { + final nonce = rateLimitBackoff('nonce'); + final link = rateLimitBackoff('link'); + if (nonce == null) return link; + if (link == null) return nonce; + return nonce > link ? nonce : link; + } + + /// `Retry-After` is delta-seconds on this lane. An HTTP-date is legal HTTP + /// and useless here, so anything that is not a sane integer falls back to + /// [PortalApi.defaultRetryAfter] rather than to no backoff at all. + static Duration _parseRetryAfter(String? raw) { + final seconds = int.tryParse(raw?.trim() ?? ''); + if (seconds == null || seconds <= 0) return PortalApi.defaultRetryAfter; + final backoff = Duration(seconds: seconds); + return backoff > PortalApi.maxRetryAfter + ? PortalApi.maxRetryAfter + : backoff; + } + /// Ask the portal for a fresh 32-byte challenge bound to [pubkeyHex]. /// Returns the 64-hex nonce, or null on any failure (never throws). Future requestNonce(String pubkeyHex) async { @@ -676,6 +735,17 @@ class PortalAccountService { /// on every call, so a chatty client writes it on every reconnect. Future refreshMe({bool force = false}) async { if (_token == null) return false; + + // `force` skips the LOCAL throttle, never a backoff the server asked for. + // The `me` bucket is 12/hour with a 600s rolling penalty, so a user + // tapping Refresh past the cap would extend their own lockout with every + // tap. + final blocked = rateLimitBackoff('me'); + if (blocked != null) { + _log('me: rate limited, ${blocked.inSeconds}s left'); + return false; + } + final last = _lastMeAt; if (!force && last != null && @@ -729,6 +799,14 @@ class PortalAccountService { _log('logout: server token revoked'); return; } + if (first.rateLimited) { + // The retry would land inside the block and re-arm it. The orphaned + // server token is the accepted trade, same as a failed retry. + _warn('logout revoke rate limited (retry after ' + '${first.retryAfter?.inSeconds ?? 0}s); the server token stays ' + 'until it expires'); + return; + } _warn('logout revoke failed (status=${first.status}) — retrying once'); final retry = await _postWithToken('logout', const {}, token); if (!retry.ok) { @@ -784,7 +862,15 @@ class PortalAccountService { // Captive portal HTML / WAF block page — opaque, not an API answer. } final error = json['error'] is String ? json['error'] as String : null; - return _PortalResponse(response.statusCode, json, error); + + Duration? retryAfter; + if (response.statusCode == 429) { + retryAfter = _parseRetryAfter(response.headers['retry-after']); + _rateLimitedUntil[action] = DateTime.now().add(retryAfter); + _warn('$action rate limited, backing off ${retryAfter.inSeconds}s'); + } + return _PortalResponse(response.statusCode, json, error, + retryAfter: retryAfter); } on TimeoutException { _warn('$action timed out after ${PortalApi.requestTimeout.inSeconds}s'); return const _PortalResponse(0, {}, 'timeout'); diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 2f9ea32a..842d3ddc 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -875,6 +875,95 @@ void main() { await service.logout(); expect(requests.single.headers['X-MM-App-Token'], 'f' * 64); }); + + /// The backoff is what is LEFT of the block, so it lands a hair under the + /// granted duration. Anything that ran a real clock is close enough. + void expectBackoff(Duration? actual, Duration granted) { + expect(actual, isNotNull); + expect(actual!, lessThanOrEqualTo(granted)); + expect(actual, greaterThan(granted - const Duration(seconds: 5))); + } + + // Every 429 the portal sends is terminal and carries Retry-After. Its + // buckets slide with a rolling penalty, so a blocked call that gets + // retried re-arms a fresh block instead of letting the old one expire. + http.Response rateLimited({String? retryAfter}) => http.Response( + '{"error":"rate_limited"}', + 429, + headers: {if (retryAfter != null) 'retry-after': retryAfter}, + ); + + test('a 429 on me blocks the next refresh even when it is forced', + () async { + final service = + await signedIn(recordingClient((_) => rateLimited(retryAfter: '600'))); + + expect(await service.refreshMe(), isFalse); + expect(requests.length, 1); + + // force skips the local hourly throttle, never a server backoff. + expect(await service.refreshMe(force: true), isFalse); + expect(requests.length, 1, + reason: 'a blocked route must not be hit again'); + + expectBackoff( + service.rateLimitBackoff('me'), const Duration(seconds: 600)); + }); + + test('a 429 is not a sign-out', () async { + final service = + await signedIn(recordingClient((_) => rateLimited(retryAfter: '600'))); + + await service.refreshMe(); + + expect(service.isSignedIn, isTrue); + expect(await store.readToken(), 'f' * 64); + }); + + test('a 429 with no Retry-After still blocks for the default', () async { + final service = await signedIn(recordingClient((_) => rateLimited())); + + await service.refreshMe(); + + expectBackoff(service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); + }); + + test('an unparseable Retry-After falls back to the default', () async { + final service = await signedIn(recordingClient( + (_) => rateLimited(retryAfter: 'Sun, 23 Aug 2026 21:00:00 GMT'))); + + await service.refreshMe(); + + expectBackoff(service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); + }); + + test('an absurd Retry-After is clamped', () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '99999999'))); + + await service.refreshMe(); + + expectBackoff(service.rateLimitBackoff('me'), PortalApi.maxRetryAfter); + }); + + test('logout does not retry a 429', () async { + final service = + await signedIn(recordingClient((_) => rateLimited(retryAfter: '300'))); + + await service.logout(); + + expect(requests.length, 1, reason: 'the retry would land in the block'); + expect(service.isSignedIn, isFalse); + }); + + test('a 429 on nonce backs the whole link lane off', () async { + final service = + await signedIn(recordingClient((_) => rateLimited(retryAfter: '120'))); + + expect(await service.requestNonce('A' * 64), isNull); + + expectBackoff(service.linkLaneBackoff, const Duration(seconds: 120)); + }); }); } From cc053934533f1b64e86e6f4356bb5f6e3c396caf Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 21:29:34 -0400 Subject: [PATCH 315/360] Show linked devices right after signing in --- DEVELOPMENT.md | 6 +- lib/services/portal_account_service.dart | 7 +++ .../services/portal_account_service_test.dart | 62 +++++++++++++++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a6d0eb13..e08b53a2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -377,7 +377,11 @@ a connection.** Mobile only (`!kIsWeb`). Server contract: mints a 43-char verifier + challenge + independent `state`; the app opens `portal.php?app_authorize=1&…` with `LaunchMode.externalApplication` (an in-app WebView would see the user's password) and the portal deep-links back - `meshmapper-auth://callback?code=…&state=…`. + `meshmapper-auth://callback?code=…&state=…`. The exchange answers with the + identity but NOT the linked pubkeys, so it is followed by one `me` call: + without it the Settings account card reads "0 device(s) on this account" + until the next radio connect happens to refresh it. That call runs AFTER + `onSignInComplete`, so a device list that fails never colours the sign-in. - **Scheme**: `meshmapper-auth` (host `callback`), registered in `ios/Runner/Info.plist` `CFBundleURLTypes` and the `MainActivity` VIEW/BROWSABLE intent-filter. Deliberately NOT the bare `meshmapper` scheme — diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index d56f11a8..f34a7e2f 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -581,6 +581,13 @@ class PortalAccountService { _log('signed in as ${account.username} (id=${account.id})'); onAccountChanged?.call(); onSignInComplete?.call(true, null); + + // The exchange carries the identity but NOT the pubkeys, so the account + // card would sit at "0 device(s) on this account" until the next radio + // connect happened to run `me`. Pull the list now so a fresh sign-in shows + // the user it worked. Deliberately AFTER the callbacks above: the sign-in + // is already a success, and a failed list must not colour it. + await refreshMe(); } /// True only for the one answer that means "this token is dead". A bare 401 diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 842d3ddc..dd63bdab 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -27,6 +27,11 @@ void main() { }); } + /// Requests to one route. A successful sign-in runs `token` and then `me`, + /// so a bare request count no longer means "exchanged exactly once". + int callsTo(String action) => + requests.where((r) => r.url.queryParameters['action'] == action).length; + PortalAccountService buildService(MockClient client) => PortalAccountService( client: client, store: store, @@ -107,8 +112,8 @@ void main() { await service.handleAuthCallback( Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); - expect(requests.length, 1); - final request = requests.single; + expect(callsTo('token'), 1); + final request = requests.first; expect(request.url.queryParameters['action'], 'token'); expect(request.headers['Content-Type'], contains('application/x-www-form-urlencoded')); @@ -123,7 +128,8 @@ void main() { expect(service.isSignedIn, isTrue); expect(service.account!.username, 'sparkgap'); expect(service.account!.displayName, 'Spark Gap'); - expect(changed, 1); + // Twice: the identity lands first, the device list right behind it. + expect(changed, 2); }); test('ignores a state mismatch and makes zero HTTP calls', () async { @@ -172,6 +178,50 @@ void main() { expect(errorCode, 'expired'); }); + test('a fresh sign-in fills in the linked-device list', () async { + // The exchange answers with the identity but no pubkeys, so the account + // card would read "0 device(s)" until the next radio connect ran `me`. + await seedPending('st4te'); + final service = buildService(recordingClient((request) => + request.url.queryParameters['action'] == 'me' + ? http.Response( + jsonEncode({ + 'ok': true, + 'user': {'id': 7, 'username': 'sparkgap'}, + 'pubkeys': [ + {'pubkey': 'a' * 64}, + {'pubkey': 'b' * 64}, + ], + }), + 200) + : http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests.map((r) => r.url.queryParameters['action']).toList(), + ['token', 'me']); + expect(service.linkedPubkeys.length, 2); + }); + + test('a device list that fails still leaves the user signed in', () async { + await seedPending('st4te'); + final completions = []; + final service = buildService(recordingClient((request) => + request.url.queryParameters['action'] == 'me' + ? http.Response('boom', 500) + : http.Response(tokenBody(), 200))); + service.onSignInComplete = (success, _) => completions.add(success); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(completions, [true], reason: 'the sign-in itself succeeded'); + expect(service.isSignedIn, isTrue); + expect(service.account!.username, 'sparkgap'); + expect(service.linkedPubkeys, isEmpty); + }); + test('exchanges a given code only once', () async { await seedPending('st4te'); final service = @@ -184,7 +234,7 @@ void main() { await service.handleAuthCallback(uri); await service.handleAuthCallback(uri); - expect(requests.length, 1); + expect(callsTo('token'), 1); }); test('a concurrent duplicate delivery still exchanges only once', () async { @@ -211,7 +261,7 @@ void main() { gated.openGate(); await Future.wait([first, second]); - expect(requests.length, 1, + expect(callsTo('token'), 1, reason: 'the portal burns a code on first use'); expect(service.isSignedIn, isTrue); expect(gated.pendingReads, 1, @@ -234,7 +284,7 @@ void main() { await service.handleAuthCallback( Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); - expect(requests.length, 1); + expect(callsTo('token'), 1); expect(service.isSignedIn, isTrue); }); From c92ad07f62c8be315ba6c779be06955ad5c01f11 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 21:47:57 -0400 Subject: [PATCH 316/360] Keep Anonymous Mode anonymous when offline sessions upload later Offline sessions were recorded under the real device name, so turning on Anonymous Mode, wardriving offline and syncing afterwards leaked the name anyway. They now record as "Anonymous", same as a live session does. The enable dialog also claimed sessions and pings aren't linked to your public key. They are. It now says the name is hidden, not the device. --- lib/providers/app_state_provider.dart | 27 ++++++++++++++------------- lib/screens/settings_screen.dart | 8 +++++--- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7234df14..f240bed1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -6347,6 +6347,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return _switchToOnlineMode(); } + /// Device name recorded on an offline session. + /// + /// Mirrors the live auth path (`who:`): a session captured while Anonymous + /// Mode is on uploads as "Anonymous" too. Recording the real name here + /// leaked it on a later sync, since the mode may well be off by then. + String? get _offlineDeviceName => _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + /// Save accumulated offline pings to a session file Future _saveOfflineSession() async { final pings = _apiQueueService.getAndClearOfflinePings(); @@ -6359,12 +6369,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } - // Include device info for auth during upload (use real name, not "Anonymous" — sessions upload later) + // Include device info for auth during upload. // Note: Connection already validates device name exists, so this should never be null - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); + final offlineDeviceName = _offlineDeviceName; // Finalize the in-progress session (created by periodic auto-save) in place // rather than creating a new one — otherwise the auto-saved session and this // final save become two identical sessions at the same time. updateCurrentSession @@ -6397,10 +6404,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); + final offlineDeviceName = _offlineDeviceName; _offlineSessionService.updateCurrentSession( pings, @@ -7268,10 +7272,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final queuedPings = await _apiQueueService.extractAllAsJson(); if (queuedPings.isNotEmpty) { - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); + final offlineDeviceName = _offlineDeviceName; await _offlineSessionService.saveSession( queuedPings, devicePublicKey: _devicePublicKey, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 439991eb..30396554 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1701,9 +1701,11 @@ class _SettingsScreenState extends State { title: const Text('Enable Anonymous Mode?'), content: Text( 'Your device will be renamed to "Anonymous" for all mesh pings. ' - 'Other mesh users will not see your companion name.\n\n' - 'Your public key is still used to authenticate your session, but ' - 'neither your sessions nor your pings are linked to it on the server.\n\n' + 'Other mesh users will not see your companion name, and you will ' + 'not appear on the public leaderboard.\n\n' + 'Your public key is still sent to authenticate your session, and ' + 'your sessions and pings are still recorded against it. Anonymous ' + 'Mode hides your name. It does not hide your device.\n\n' '${isConnected ? 'Your device will disconnect and reconnect automatically.\n\n' : ''}' 'If the app crashes or BLE disconnects unexpectedly, your device ' 'may remain named "Anonymous" until you reconnect and properly disconnect. ' From 23b22887cef5d579343033ea64b37489cf964058 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 22:22:38 -0400 Subject: [PATCH 317/360] Stop wardriving when we've been sitting still The server now revokes a session that hasn't moved 150m in 35 minutes. We show its message instead of our own generic "session was revoked" text, and drop the queued pings rather than saving them offline. Preserving them just re-uploads points earned while parked, which is the thing being stopped. The 30 minute idle stop was defeated by GPS jitter, since any ping that cleared the 25m filter rearmed the whole timer. It now walks an anchor the same way the server does: 150m radius, three fixes in a row outside it to re-anchor. Someone walking re-anchors every few minutes, so this only catches a session that genuinely stays put. --- lib/providers/app_state_provider.dart | 142 ++++++++++++++++++++------ lib/services/api_service.dart | 13 ++- 2 files changed, 123 insertions(+), 32 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f240bed1..c988bfe4 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -253,6 +253,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { AutoMode _autoMode = AutoMode.active; DateTime? _idleAutoStopReference; static const Duration _autoStopIdleTimeout = Duration(minutes: 30); + // Anchor walk backing the idle auto-stop, mirroring the server's stationary + // guard (stationary_guard.php sg_walk, SG_RADIUS_M / SG_STREAK_N) so a polite + // client stops itself before the server revokes the session. + double? _idleAnchorLat; + double? _idleAnchorLon; + int _idleAnchorEscapeStreak = 0; + static const double _idleAnchorRadiusMeters = 150; + static const int _idleAnchorStreakRequired = 3; bool _isPingSending = false; // True immediately when ping button clicked bool _autoPingStarting = false; // True while an auto mode is starting (before the first notify) @@ -2480,9 +2488,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; // Set up session error callback for auto-disconnect - _apiService.onSessionError = (reason, message) async { - debugError('[APP] Session error from API: $reason - $message'); - await handleSessionError(reason, message); + _apiService.onSessionError = (reason, message, {subReason}) async { + debugError('[APP] Session error from API: $reason' + '${subReason != null ? ' ($subReason)' : ''} - $message'); + await handleSessionError(reason, message, subReason: subReason); }; // A new session id makes every wire tag already queued under the old one @@ -4123,16 +4132,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _liveActivityOperation = null; _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - if (skipReason != null) { - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { - final elapsed = DateTime.now().difference(_idleAutoStopReference!); - if (elapsed >= _autoStopIdleTimeout) { - _triggerIdleAutoStop(); - } - } - } else { - _idleAutoStopReference = DateTime.now(); - } + _updateIdleAutoStop(); }; _pingService!.onDiscPing = (entry) { @@ -4357,7 +4357,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); _finishLiveActivitySession(); debugLog('[APP] Pending disable cleanup complete, cooldown running'); @@ -4963,7 +4963,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _playDisconnectAlert(); } _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); debugLog('[AUTO] Auto-ping disabled due to disconnect'); } @@ -5044,7 +5044,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); // Stop heartbeat _apiService.disableHeartbeat(); @@ -5339,7 +5339,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); } // End noise floor session on disconnect @@ -5618,6 +5618,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Advance the idle auto-stop anchor walk by one auto-ping tick. + /// + /// Mirrors `sg_walk` in the server's stationary_guard.php: a fix within + /// [_idleAnchorRadiusMeters] of the anchor leaves the clock running, and only + /// [_idleAnchorStreakRequired] CONSECUTIVE fixes beyond it re-anchor and rearm + /// (isolated jitter spikes are one or two wild points; real movement is every + /// point far). The old rule rearmed on any ping that wasn't skipped, so GPS + /// jitter clearing the 25 m send filter kept the 30 minutes topped up + /// indefinitely and the stop never fired indoors. + void _updateIdleAutoStop() { + if (!_preferences.autoStopAfterIdle) return; + + final position = _currentPosition; + if (position == null) return; + + final anchorLat = _idleAnchorLat; + final anchorLon = _idleAnchorLon; + if (anchorLat == null || anchorLon == null) { + _anchorIdleAutoStop(position); + return; + } + + final distance = Geolocator.distanceBetween( + anchorLat, anchorLon, position.latitude, position.longitude); + + if (distance > _idleAnchorRadiusMeters) { + _idleAnchorEscapeStreak++; + if (_idleAnchorEscapeStreak >= _idleAnchorStreakRequired) { + debugLog( + '[AUTO] Idle anchor moved: ${distance.toStringAsFixed(0)}m out, ' + '$_idleAnchorEscapeStreak consecutive'); + _anchorIdleAutoStop(position); + return; + } + } else { + _idleAnchorEscapeStreak = 0; + } + + final reference = _idleAutoStopReference; + if (reference != null && + DateTime.now().difference(reference) >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); + } + } + + /// Re-anchor the idle walk here and restart the idle clock. + void _anchorIdleAutoStop(Position position) { + _idleAnchorLat = position.latitude; + _idleAnchorLon = position.longitude; + _idleAnchorEscapeStreak = 0; + _idleAutoStopReference = DateTime.now(); + } + + /// Clear the idle auto-stop clock and its anchor (auto-ping stopped). + void _resetIdleAutoStop() { + _idleAutoStopReference = null; + _idleAnchorLat = null; + _idleAnchorLon = null; + _idleAnchorEscapeStreak = 0; + } + /// Auto-stop auto-ping after prolonged idle (no movement) void _triggerIdleAutoStop() { if (!_autoPingEnabled) return; @@ -5628,7 +5689,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); - _idleAutoStopReference = null; + _resetIdleAutoStop(); toggleAutoPing(_autoMode); } @@ -5686,7 +5747,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _startIdleDisconnectTimer(); _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); _finishLiveActivitySession(); // Clear top-heard overlay on stop @@ -6314,7 +6375,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); _finishLiveActivitySession(); debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); @@ -7146,7 +7207,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle API error codes with user-friendly messages /// Returns a user-friendly message for the error code - String _getErrorMessage(String? reason, String? serverMessage) { + String _getErrorMessage(String? reason, String? serverMessage, + {String? subReason}) { + // Stationary revoke: the server's message names the actual cause and the + // window it measured, which the hardcoded text below cannot. Show it as-is. + if (reason == 'session_revoked' && + subReason == 'stationary' && + serverMessage != null && + serverMessage.isNotEmpty) { + return serverMessage; + } switch (reason) { case 'unknown_device': return 'Unknown device. Please advertise yourself on the mesh using the official MeshCore app.'; @@ -7196,8 +7266,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle session error from wardrive/heartbeat API calls /// This may trigger auto-disconnect - Future handleSessionError(String? reason, String? message) async { - final userMessage = _getErrorMessage(reason, message); + Future handleSessionError(String? reason, String? message, + {String? subReason}) async { + final userMessage = _getErrorMessage(reason, message, subReason: subReason); + final isStationaryRevoke = + reason == 'session_revoked' && subReason == 'stationary'; // Session ping-counter exhausted (wire tag's 11-bit cap). The session is still // valid here, so flush the queue under it BEFORE disconnecting: clearOnDisconnect() @@ -7235,9 +7308,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } - // Log error + // Log error. A stationary revoke is the guard working as designed (a + // surveyor pacing one spot trips it), so it warns rather than alarms. debugError('[API] Session error: $reason - $userMessage'); - logError(userMessage, severity: ErrorSeverity.error); + logError(userMessage, + severity: isStationaryRevoke + ? ErrorSeverity.warning + : ErrorSeverity.error); // Session errors that require disconnect const sessionErrors = { @@ -7267,8 +7344,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { zoneErrors.contains(reason)) { debugLog('[API] Session error requires disconnect: $reason'); - // Preserve queued wardrive data to offline storage before disconnect clears it - if (sessionErrors.contains(reason)) { + // Preserve queued wardrive data to offline storage before disconnect clears it. + // NOT on a stationary revoke: those pings were earned sitting still, and + // preserving them just re-uploads them through the offline path later, + // which is exactly what the guard exists to stop. disconnect() below + // clears the queue, so skipping the snapshot is the drop. + if (isStationaryRevoke) { + debugLog( + '[SESSION] Stationary revoke: dropping ${_apiQueueService.queueSize} queued pings instead of preserving them'); + } else if (sessionErrors.contains(reason)) { try { final queuedPings = await _apiQueueService.extractAllAsJson(); if (queuedPings.isNotEmpty) { @@ -7762,7 +7846,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); await _pingService?.forceDisableAutoPing(); debugLog('[ZONE GRACE] Auto-ping paused'); } @@ -8003,7 +8087,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; - _idleAutoStopReference = null; + _resetIdleAutoStop(); await _pingService?.forceDisableAutoPing(); debugLog('[ZONE] Auto-ping paused for zone transfer'); } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index b9e5eb1b..aa55a5ec 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -910,7 +910,12 @@ class ApiService { /// Callback for session errors (session_expired, bad_session, outside_zone) /// Set by AppStateProvider to handle auto-disconnect - Future Function(String? reason, String? message)? onSessionError; + /// + /// [subReason] carries the server's `sub_reason` where it narrows the reason + /// enough to change what the app does. Only the upload path passes it today: + /// a stationary revoke can only be raised by an upload, never a heartbeat. + Future Function(String? reason, String? message, {String? subReason})? + onSessionError; /// Callback for maintenance mode detection (while connected) void Function(String message, String? url)? onMaintenanceMode; @@ -1002,7 +1007,9 @@ class ApiService { }; if (criticalErrors.contains(reason)) { - debugError('[API] Upload batch session error: $reason'); + final subReason = result['sub_reason'] as String?; + debugError('[API] Upload batch session error: $reason' + '${subReason != null ? ' ($subReason)' : ''}'); final message = result['message'] as String?; // Clear session locally since it's invalid on server _clearSession(); @@ -1011,7 +1018,7 @@ class ApiService { // makes the caller DISCARD that same batch. Fired bare, the snapshot's // first real suspension (a non-empty RX buffer flushing to Hive) let // the discard land first, so the preserved pings came back short. - await onSessionError?.call(reason, message); + await onSessionError?.call(reason, message, subReason: subReason); return UploadResult.nonRetryable; } From 584c945306c7c69395a975778bb01e1fff1a8a06 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 22:55:46 -0400 Subject: [PATCH 318/360] Tell the difference between no coverage and no answer The shared coverage fetch returned an empty list whether the server said "nothing here" or the request never got answered, so a failed lookup was indistinguishable from a repeater that had genuinely heard nothing. It now returns nothing-vs-null and the repeater sheet carries that through. Nothing looks different yet, the sheet still renders both the same way. That comes next. The cell summary keeps collapsing failures to empty for now, it has no "couldn't load" state to show. --- lib/providers/app_state_provider.dart | 18 ++-- lib/services/api_service.dart | 36 +++++--- lib/widgets/map_widget.dart | 10 +++ test/services/api_service_coverage_test.dart | 86 ++++++++++++++++++++ 4 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 test/services/api_service_coverage_test.dart diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c988bfe4..9352d62f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1232,15 +1232,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Lazy tap-to-inspect: fetch the coverage points referencing a repeater - /// (hex-prefix superset) from the current zone's app endpoint. Returns `[]` - /// when there is no zone or on failure. The caller aggregates these into the - /// repeater's BIDIR/TX/RX/DISC/DEAD totals + max range. - Future>> fetchRepeaterCoveragePoints({ + /// (hex-prefix superset) from the current zone's app endpoint. The caller + /// aggregates these into the repeater's BIDIR/TX/RX/DISC/DEAD totals + max + /// range. + /// + /// Returns `null` when the points could not be fetched (no zone yet, or the + /// request failed) and `[]` when the zone genuinely has nothing for this + /// prefix. Keeping those apart is MeshMapper_Server#109: a stale zone list + /// leaves the previous zone's markers tappable after the zone flips, and that + /// must not read as "this repeater heard nothing". + Future>?> fetchRepeaterCoveragePoints({ required String prefix, }) { final zone = zoneCode; if (zone == null || zone.isEmpty) { - return Future.value(const >[]); + debugWarn('[COVERAGE] repeater points requested with no zone, ' + 'reporting unavailable rather than empty'); + return Future.value(null); } return _apiService.fetchRepeaterCoverage(zone: zone, prefix: prefix); } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index aa55a5ec..a3d85d9e 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1116,13 +1116,19 @@ class ApiService { /// GRID SUMMARY. Posts to the region's app-facing endpoint /// (`app_coverage.php` → `api.php` `map_data`); the app aggregates the points /// client-side (see `coverage_summary.dart`). Returns `[]` on any failure. + /// + /// NOTE: unlike [fetchRepeaterCoverage] this still collapses a failed request + /// into `[]`. The cell summary has no "couldn't load" state yet + /// (MeshMapper_Server#109 covers the repeater sheet only) and its tap flow + /// chains straight into `filterWithinBlob`. Give the cell sheet that state + /// before propagating the null here. Future>> fetchMapData({ required String zone, required double lat, required double lon, required double radiusMeters, - }) { - return _fetchCoveragePoints( + }) async { + final points = await _fetchCoveragePoints( zone: zone, label: 'map_data', body: { @@ -1132,13 +1138,17 @@ class ApiService { 'radius': radiusMeters, }, ); + return points ?? const >[]; } /// Fetch the coverage points referencing a repeater (a hex-prefix superset), /// for the repeater detail sheet's BIDIR/TX/RX/DISC/DEAD totals + max range. - /// Posts to `app_coverage.php` → `api.php` `repeater_coverage`. Returns `[]` - /// on any failure. - Future>> fetchRepeaterCoverage({ + /// Posts to `app_coverage.php` → `api.php` `repeater_coverage`. + /// + /// Returns `null` when the request could not be answered, and `[]` when the + /// zone genuinely has nothing for this prefix. The sheet renders those + /// differently (MeshMapper_Server#109). + Future>?> fetchRepeaterCoverage({ required String zone, required String prefix, }) { @@ -1153,8 +1163,14 @@ class ApiService { } /// Shared POST to `https://.meshmapper.net/app_coverage.php` with the app - /// key in the JSON body. Returns a list of point maps, or `[]` on any failure. - Future>> _fetchCoveragePoints({ + /// key in the JSON body. + /// + /// Returns the point maps on success, which may legitimately be an EMPTY list + /// meaning the server has no coverage for this query, or `null` when the + /// request could not be answered at all. Callers MUST keep those apart: + /// collapsing them into `[]` was MeshMapper_Server#109, where a failed fetch + /// rendered as a repeater that had heard nothing. + Future>?> _fetchCoveragePoints({ required String zone, required String label, required Map body, @@ -1181,13 +1197,13 @@ class ApiService { : response.body); debugWarn( '[COVERAGE] $label HTTP ${response.statusCode} in ${secs}s: $snippet'); - return []; + return null; } final decoded = json.decode(response.body); if (decoded is! List) { debugWarn('[COVERAGE] $label: unexpected response (not a JSON list)'); - return []; + return null; } final points = decoded .whereType() @@ -1198,7 +1214,7 @@ class ApiService { } catch (e) { debugWarn( '[COVERAGE] $label POST failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); - return []; + return null; } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 633713ff..0e97c58e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -9412,6 +9412,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState .fetchRepeaterCoveragePoints(prefix: repeater.id) .then((pts) { + // null means the points could not be fetched at all. Keep it null so + // the sheet can say so, rather than aggregating an empty list into a + // zeroed RepeaterStats that reads as "this repeater heard nothing" + // (MeshMapper_Server#109). An EMPTY list still aggregates normally: + // that is a real answer. + if (pts == null) { + debugWarn('[COVERAGE] repeater ${repeater.id} coverage ' + 'unavailable, not rendering it as zero coverage'); + return null; + } final res = RepeaterStats.fromCoverageWithPoints(pts, repeater, lookup); // Feature B: draw this repeater's coverage cells + status-coloured diff --git a/test/services/api_service_coverage_test.dart b/test/services/api_service_coverage_test.dart new file mode 100644 index 00000000..4ce64dfd --- /dev/null +++ b/test/services/api_service_coverage_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +void main() { + ApiService serviceAnswering(http.Response Function(http.Request) responder) => + ApiService(client: MockClient((request) async => responder(request))); + + group('fetchRepeaterCoverage separates "nothing" from "could not ask"', () { + test('points come back as points', () async { + final api = serviceAnswering((_) => http.Response( + json.encode([ + {'status': 1, 'lat': 45.0, 'lon': -75.0}, + ]), + 200, + )); + + final points = await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'); + + expect(points, isNotNull); + expect(points!.length, 1); + }); + + test('an empty list is a real answer, not a failure', () async { + final api = serviceAnswering((_) => http.Response('[]', 200)); + + final points = await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'); + + expect(points, isNotNull, reason: 'the server answered: it has nothing'); + expect(points, isEmpty); + }); + + test('an HTTP error is "could not ask"', () async { + final api = serviceAnswering((_) => http.Response('nope', 500)); + + expect( + await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'), isNull); + }); + + test('a body that is not a JSON list is "could not ask"', () async { + final api = + serviceAnswering((_) => http.Response('{"error":"boom"}', 200)); + + expect( + await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'), isNull); + }); + + test('a transport failure is "could not ask"', () async { + final api = ApiService( + client: MockClient((_) async => throw http.ClientException('down'))); + + expect( + await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'), isNull); + }); + }); + + group('fetchMapData still collapses failure to empty', () { + // The cell summary has no "couldn't load" state (#109 is the repeater + // sheet only), and its tap flow chains straight into filterWithinBlob. + test('an HTTP error yields an empty list, never null', () async { + final api = serviceAnswering((_) => http.Response('nope', 500)); + + final points = await api.fetchMapData( + zone: 'yow', lat: 45.0, lon: -75.0, radiusMeters: 300); + + expect(points, isEmpty); + }); + + test('points still come back as points', () async { + final api = serviceAnswering((_) => http.Response( + json.encode([ + {'status': 1, 'lat': 45.0, 'lon': -75.0}, + ]), + 200, + )); + + final points = await api.fetchMapData( + zone: 'yow', lat: 45.0, lon: -75.0, radiusMeters: 300); + + expect(points.length, 1); + }); + }); +} From e023e662962ffb98a8eafdadcbce346b101d9a35 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 22:56:39 -0400 Subject: [PATCH 319/360] Say when repeater coverage failed to load instead of showing zeros A repeater whose coverage lookup failed showed the same all-zero row as one that genuinely never heard anything, so a dropped connection read as dead coverage. The sheet now says "Couldn't load coverage" when the lookup failed, and adds "No coverage recorded yet" under the zeros when it really is empty. The pill shows ? rather than N/A for a failed lookup. --- lib/widgets/map_widget.dart | 105 ++++++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 23 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 0e97c58e..3f45e14a 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -7735,12 +7735,20 @@ class _MapWidgetState extends State with WidgetsBindingObserver { builder: (context, snap) { final loading = snap.connectionState != ConnectionState.done; final stats = snap.data; - final range = loading - ? '…' - : (stats?.maxRangeMeters != null - ? formatCoverageDistance(stats!.maxRangeMeters!, - isImperial: isImperial) - : 'N/A'); + // '?' rather than 'N/A' when the fetch could not be answered: 'N/A' + // means we asked and there is nothing. Tapping the pill opens the sheet, + // which explains it properly. + final String range; + if (loading) { + range = '…'; + } else if (stats == null) { + range = '?'; + } else if (stats.maxRangeMeters != null) { + range = formatCoverageDistance(stats.maxRangeMeters!, + isImperial: isImperial); + } else { + range = 'N/A'; + } return Wrap( spacing: 14, runSpacing: 6, @@ -9663,12 +9671,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final loading = snap.connectionState != ConnectionState.done; final stats = snap.data; - final range = loading - ? '…' - : (stats?.maxRangeMeters != null - ? formatCoverageDistance(stats!.maxRangeMeters!, - isImperial: isImperial) - : 'N/A'); + // Three states: still loading, could not load, and + // loaded (where a null range means nothing was heard). + final String range; + if (loading) { + range = '…'; + } else if (stats == null) { + range = 'Unavailable'; + } else if (stats.maxRangeMeters != null) { + range = formatCoverageDistance(stats.maxRangeMeters!, + isImperial: isImperial); + } else { + range = 'N/A'; + } return _repRow( context, Icons.open_in_full, @@ -9687,19 +9702,42 @@ class _MapWidgetState extends State with WidgetsBindingObserver { builder: (context, snap) { final loading = snap.connectionState != ConnectionState.done; final stats = snap.data; + // Done with no stats means the fetch could not be answered. + // Never fall through to the zero row here: that is the bug. + if (!loading && stats == null) { + return _coverageUnavailableRow(context); + } String v(int? n) => loading ? '…' : '${n ?? 0}'; - return Row( + final nothingRecorded = !loading && stats!.totalMatched == 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), - const Color(0xFF1E7E34)), - _repeaterStatCell( - context, 'TX', v(stats?.tx), const Color(0xFFFD7E14)), - _repeaterStatCell( - context, 'RX', v(stats?.rx), const Color(0xFF6F42C1)), - _repeaterStatCell(context, 'DISC', v(stats?.disc), - const Color(0xFF17A2B8)), - _repeaterStatCell(context, 'DEAD', v(stats?.dead), - const Color(0xFF6C757D)), + Row( + children: [ + _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), + const Color(0xFF1E7E34)), + _repeaterStatCell(context, 'TX', v(stats?.tx), + const Color(0xFFFD7E14)), + _repeaterStatCell(context, 'RX', v(stats?.rx), + const Color(0xFF6F42C1)), + _repeaterStatCell(context, 'DISC', v(stats?.disc), + const Color(0xFF17A2B8)), + _repeaterStatCell(context, 'DEAD', v(stats?.dead), + const Color(0xFF6C757D)), + ], + ), + if (nothingRecorded) ...[ + const SizedBox(height: 10), + Text( + 'No coverage recorded yet', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ], ); }, @@ -9757,6 +9795,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// One cell of the repeater's BIDIR/TX/RX/DISC/DEAD totals row. + /// Shown in place of the BIDIR/TX/RX/DISC/DEAD row when the coverage fetch + /// could not be answered at all. Distinct from a repeater that was looked up + /// successfully and simply has no coverage, which keeps the zero row plus a + /// "no coverage recorded yet" caption (MeshMapper_Server#109). + Widget _coverageUnavailableRow(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off, size: 16, color: scheme.onSurfaceVariant), + const SizedBox(width: 8), + Flexible( + child: Text( + "Couldn't load coverage", + style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant), + ), + ), + ], + ); + } + Widget _repeaterStatCell( BuildContext context, String label, String value, Color color) { return Expanded( From bf513d3ede3bd09da0c8b8149c2d8ad0737ce007 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 23 Aug 2026 22:57:16 -0400 Subject: [PATCH 320/360] Let a failed coverage load be retried from the sheet The stats are fetched once per selection and reused between the pill and the sheet, so a failed lookup stuck until you closed the repeater and tapped it again. The error row now carries a Retry that re-runs the lookup and keeps the repeater selected. --- lib/widgets/map_widget.dart | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3f45e14a..778d13e9 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -9705,7 +9705,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Done with no stats means the fetch could not be answered. // Never fall through to the zero row here: that is the bug. if (!loading && stats == null) { - return _coverageUnavailableRow(context); + // Pop with our own token: the result handler treats every + // other value, a bare pop included, as a real close and + // tears the selection down. + return _coverageUnavailableRow( + context, () => Navigator.of(context).pop('retry')); } String v(int? n) => loading ? '…' : '${n ?? 0}'; final nothingRecorded = !loading && stats!.totalMatched == 0; @@ -9748,7 +9752,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ).then((result) { if (!mounted) return; - if (result == 'minimized') { + if (result == 'retry') { + // Re-open expanded with NO cachedStats, which re-runs the fetch. The + // selection stays isolated throughout, same as the minimize path. + debugLog('[COVERAGE] retrying coverage fetch for ${repeater.id}'); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: true); + } else if (result == 'minimized') { // Collapse back to the stats pill. The selection (and its coverage // cells/lines + tile dim) persists throughout pill<->sheet; it's torn // down only on a real close. Reuse the already-fetched stats so the pill @@ -9799,7 +9812,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// could not be answered at all. Distinct from a repeater that was looked up /// successfully and simply has no coverage, which keeps the zero row plus a /// "no coverage recorded yet" caption (MeshMapper_Server#109). - Widget _coverageUnavailableRow(BuildContext context) { + /// + /// [onRetry] re-enters the sheet with no cached future. The stats future is + /// cached per selection and handed to both the pill and the sheet, so + /// toggling between them would otherwise just re-render the same failure. + Widget _coverageUnavailableRow(BuildContext context, VoidCallback onRetry) { final scheme = Theme.of(context).colorScheme; return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -9812,6 +9829,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant), ), ), + const SizedBox(width: 8), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Retry'), + ), ], ); } From 1b8ca458d61554a52569bee340bcd2fd24d0e6f4 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 23 Aug 2026 21:47:31 -0700 Subject: [PATCH 321/360] Add iOS App Intents and Siri shortcuts --- ios/MeshMapperAppIntents/Info.plist | 27 + .../MeshMapperAppIntents.entitlements | 10 + .../MeshMapperAppIntentsExtension.swift | 6 + .../MeshMapperReadIntents.swift | 236 +++++++++ ios/Podfile.lock | 127 +++++ ios/Runner.xcodeproj/project.pbxproj | 264 ++++++++++ ios/Runner/AppDelegate.swift | 35 ++ ...ConnectLastMeshMapperCompanionIntent.swift | 34 ++ .../MeshMapperMutationAppShortcuts.swift | 37 ++ .../AppIntents/MeshMapperSessionMode.swift | 21 + .../AppIntents/SendManualPingIntent.swift | 23 + .../AppIntents/SiriIntentCoordinator.swift | 125 +++++ .../StartMeshMapperSessionIntent.swift | 42 ++ .../StopMeshMapperSessionIntent.swift | 23 + ios/Runner/Runner.entitlements | 10 + ios/RunnerTests/AppIntentLogicTests.swift | 141 +++++ .../AppIntents/HeardRepeaterEntity.swift | 65 +++ .../AppIntents/MeshMapperSiriSnapshot.swift | 82 +++ .../MeshMapperSiriSnapshotStore.swift | 84 +++ ios/Shared/AppIntents/RepeaterEntity.swift | 83 +++ lib/main.dart | 2 + lib/providers/app_state_provider.dart | 485 ++++++++++++++---- .../app_intent_bridge_service.dart | 197 +++++++ .../app_intents/app_intent_commands.dart | 87 ++++ .../last_companion_connection.dart | 55 ++ .../app_intents/siri_snapshot_builder.dart | 190 +++++++ .../app_intents/siri_snapshot_models.dart | 230 +++++++++ .../external_command_models.dart | 148 ++++++ .../external_session_commands.dart | 107 ++++ lib/services/watch/watch_bridge_service.dart | 6 + lib/services/watch/watch_models.dart | 4 + .../app_intent_bridge_service_test.dart | 160 ++++++ .../last_companion_connection_test.dart | 76 +++ .../siri_snapshot_builder_test.dart | 298 +++++++++++ .../siri_snapshot_wire_contract_test.dart | 146 ++++++ .../external_session_commands_test.dart | 166 ++++++ 36 files changed, 3739 insertions(+), 93 deletions(-) create mode 100644 ios/MeshMapperAppIntents/Info.plist create mode 100644 ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements create mode 100644 ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift create mode 100644 ios/MeshMapperAppIntents/MeshMapperReadIntents.swift create mode 100644 ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift create mode 100644 ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift create mode 100644 ios/Runner/AppIntents/MeshMapperSessionMode.swift create mode 100644 ios/Runner/AppIntents/SendManualPingIntent.swift create mode 100644 ios/Runner/AppIntents/SiriIntentCoordinator.swift create mode 100644 ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift create mode 100644 ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift create mode 100644 ios/Runner/Runner.entitlements create mode 100644 ios/RunnerTests/AppIntentLogicTests.swift create mode 100644 ios/Shared/AppIntents/HeardRepeaterEntity.swift create mode 100644 ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift create mode 100644 ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift create mode 100644 ios/Shared/AppIntents/RepeaterEntity.swift create mode 100644 lib/services/app_intents/app_intent_bridge_service.dart create mode 100644 lib/services/app_intents/app_intent_commands.dart create mode 100644 lib/services/app_intents/last_companion_connection.dart create mode 100644 lib/services/app_intents/siri_snapshot_builder.dart create mode 100644 lib/services/app_intents/siri_snapshot_models.dart create mode 100644 lib/services/external_commands/external_command_models.dart create mode 100644 lib/services/external_commands/external_session_commands.dart create mode 100644 test/services/app_intents/app_intent_bridge_service_test.dart create mode 100644 test/services/app_intents/last_companion_connection_test.dart create mode 100644 test/services/app_intents/siri_snapshot_builder_test.dart create mode 100644 test/services/app_intents/siri_snapshot_wire_contract_test.dart create mode 100644 test/services/external_commands/external_session_commands_test.dart diff --git a/ios/MeshMapperAppIntents/Info.plist b/ios/MeshMapperAppIntents/Info.plist new file mode 100644 index 00000000..9f8e7e7c --- /dev/null +++ b/ios/MeshMapperAppIntents/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + MeshMapper Intents + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.appintents-extension + + + diff --git a/ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements b/ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements new file mode 100644 index 00000000..7037779d --- /dev/null +++ b/ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.net.meshmapper.app.shared + + + diff --git a/ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift b/ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift new file mode 100644 index 00000000..db37f461 --- /dev/null +++ b/ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift @@ -0,0 +1,6 @@ +import AppIntents +import ExtensionFoundation + +@main +@available(iOS 26.0, *) +struct MeshMapperAppIntentsExtension: AppIntentsExtension {} diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift new file mode 100644 index 00000000..6c75bb1b --- /dev/null +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -0,0 +1,236 @@ +import AppIntents +import Foundation + +@available(iOS 26.0, *) +enum RecentHeardWindow: String, AppEnum { + case lastFiveMinutes + case lastFifteenMinutes + case lastHour + case currentSession + + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Time Window" + ) + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .lastFiveMinutes: "Last 5 Minutes", + .lastFifteenMinutes: "Last 15 Minutes", + .lastHour: "Last Hour", + .currentSession: "Current Session", + ] + + var duration: TimeInterval? { + switch self { + case .lastFiveMinutes: return 5 * 60 + case .lastFifteenMinutes: return 15 * 60 + case .lastHour: return 60 * 60 + case .currentSession: return nil + } + } +} + +@available(iOS 26.0, *) +enum RepeaterSort: String, AppEnum { + case mostRecent + case strongest + case farthest + + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Repeater Sort" + ) + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .mostRecent: "Most Recent", + .strongest: "Strongest", + .farthest: "Farthest", + ] +} + +@available(iOS 26.0, *) +struct GetMeshMapperStatusIntent: AppIntent { + static let title: LocalizedStringResource = "Get MeshMapper Status" + static let description = IntentDescription( + "Reports the cached connection and mapping-session status without launching MeshMapper." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .background } + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read() else { + return .result(dialog: "Open MeshMapper once before asking for its status.") + } + + let age = Date().timeIntervalSince(snapshot.updatedAt) + if age > 5 * 60, snapshot.session.active || snapshot.session.starting { + return .result( + dialog: "MeshMapper's last session update is too old to report as current. Open the app to refresh it." + ) + } + + let prefix: String + if age >= 30 { + prefix = "As of \(Self.relativeAge(age)), " + } else { + prefix = "" + } + + if !snapshot.connection.isConnected { + return .result(dialog: "\(prefix)MeshMapper wasn't connected to a companion.") + } + if snapshot.session.starting { + return .result( + dialog: "\(prefix)MeshMapper was starting in \(Self.modeName(snapshot.session.mode)) mode." + ) + } + if !snapshot.session.active { + let device = snapshot.connection.deviceName.map { " to \($0)" } ?? "" + return .result( + dialog: "\(prefix)MeshMapper was connected\(device), but no mapping session was running." + ) + } + + let zone = snapshot.session.zoneCode.map { " in \($0)" } ?? "" + let detail = snapshot.session.phaseDetail.map { " \($0)." } ?? "" + return .result( + dialog: "\(prefix)MeshMapper was running in \(Self.modeName(snapshot.session.mode)) mode\(zone). It had sent \(snapshot.session.txCount) pings and heard \(snapshot.session.uniqueRepeatersHeard) unique repeaters. \(snapshot.session.phaseTitle).\(detail)" + ) + } + + private static func modeName(_ mode: String) -> String { + mode == "passive" ? "Passive Discovery" : mode.capitalized + } + + private static func relativeAge(_ age: TimeInterval) -> String { + if age < 60 { return "\(Int(age)) seconds ago" } + return "\(Int(age / 60)) minutes ago" + } +} + +@available(iOS 26.0, *) +struct GetRecentlyHeardRepeatersIntent: AppIntent { + static let title: LocalizedStringResource = "Get Recently Heard Repeaters" + static let description = IntentDescription( + "Returns recent MeshMapper repeater observations from a bounded local cache." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .background } + + @Parameter(title: "Within", default: .lastFifteenMinutes) + var timeWindow: RecentHeardWindow + + @Parameter(title: "Sort By", default: .mostRecent) + var sort: RepeaterSort + + @Parameter(title: "Maximum Results", default: 3) + var limit: Int + + func perform() async throws -> some IntentResult & ReturnsValue<[HeardRepeaterEntity]> & ProvidesDialog { + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read() else { + return .result( + value: [], + dialog: "Open MeshMapper once before asking what it heard." + ) + } + + let now = Date() + var observations = snapshot.recentHeard.filter { observation in + guard let duration = timeWindow.duration else { return true } + return now.timeIntervalSince(observation.observedAt) <= duration + } + switch sort { + case .mostRecent: + observations.sort { $0.observedAtMs > $1.observedAtMs } + case .strongest: + observations.sort { ($0.snr ?? -.infinity) > ($1.snr ?? -.infinity) } + case .farthest: + observations.sort { + ($0.distanceM ?? -.infinity) > ($1.distanceM ?? -.infinity) + } + } + + let requestedLimit = min(max(limit, 1), 5) + let selected = Array(observations.prefix(requestedLimit)) + let entities = selected.enumerated().map { + HeardRepeaterEntity(observation: $0.element, index: $0.offset) + } + guard !selected.isEmpty else { + return .result( + value: [], + dialog: "MeshMapper didn't hear any repeaters in that time window." + ) + } + + let spoken = selected.prefix(3).map(Self.spokenObservation).joined(separator: ", ") + let lead = selected.count == 1 ? "The result was" : "The results were" + return .result(value: entities, dialog: "\(lead) \(spoken).") + } + + private static func spokenObservation(_ observation: MeshMapperSiriObservation) -> String { + let identity = observation.name ?? "repeater \(observation.displayHexId)" + let signal = observation.snr.map { String(format: " at %.1f dB", $0) } ?? "" + let distance: String + if let meters = observation.distanceM { + distance = meters >= 1_000 + ? String(format: ", about %.1f kilometers away", meters / 1_000) + : String(format: ", about %.0f meters away", meters) + } else { + distance = "" + } + return "\(identity)\(signal)\(distance)" + } +} + +@available(iOS 26.0, *) +struct FindMeshMapperRepeaterIntent: AppIntent { + static let title: LocalizedStringResource = "Find MeshMapper Repeater" + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + static var supportedModes: IntentModes { .background } + + @Parameter(title: "Repeater") + var repeater: RepeaterEntity + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let status = repeater.isActive ? "active" : "stale" + let zone = repeater.zoneCode.map { " in \($0)" } ?? "" + return .result( + value: repeater, + dialog: "\(repeater.name) is repeater \(repeater.hexId)\(zone), and is currently \(status)." + ) + } +} + +@available(iOS 26.0, *) +struct MeshMapperReadAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: GetMeshMapperStatusIntent(), + phrases: [ + "What is \(.applicationName) doing", + "Get \(.applicationName) status", + ], + shortTitle: "Session Status", + systemImageName: "waveform" + ) + AppShortcut( + intent: GetRecentlyHeardRepeatersIntent(), + phrases: [ + "What has \(.applicationName) heard", + "Recent repeaters in \(.applicationName)", + ], + shortTitle: "Recent Repeaters", + systemImageName: "dot.radiowaves.left.and.right" + ) + AppShortcut( + intent: FindMeshMapperRepeaterIntent(), + phrases: [ + "Find a repeater in \(.applicationName)", + ], + shortTitle: "Find Repeater", + systemImageName: "magnifyingglass" + ) + } + + static var shortcutTileColor: ShortcutTileColor { .teal } +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 10431777..c0207dcf 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,45 +1,172 @@ PODS: + - app_links (6.4.1): + - Flutter + - audio_session (0.0.1): + - Flutter - disk_space_plus (0.0.1): - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter - flutter_secure_storage (6.0.0): - Flutter + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - MapLibre (6.19.1) + - maplibre_gl (0.25.0): + - Flutter + - MapLibre (= 6.19.1) + - package_info_plus (0.4.5): + - Flutter - permission_handler_apple (9.3.0): - Flutter + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter + - wakelock_plus (0.0.1): + - Flutter DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - MapLibre + - SDWebImage + - SwiftyGif EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" disk_space_plus: :path: ".symlinks/plugins/disk_space_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" + maplibre_gl: + :path: ".symlinks/plugins/maplibre_gl/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + MapLibre: 7f24faba45439f80ccb0f83393c29fa32cb81952 + maplibre_gl: a2114567cbd1065866614fbd34dfb75ab782aaa2 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 68245dd1..ab8e63b4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 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 */; }; + C10000000000000000000001 /* AppIntentLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000001 /* AppIntentLogicTests.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 */; }; @@ -38,6 +39,22 @@ 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 */; }; + B10000000000000000000001 /* SiriIntentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* SiriIntentCoordinator.swift */; }; + B10000000000000000000002 /* MeshMapperSessionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* MeshMapperSessionMode.swift */; }; + B10000000000000000000003 /* StartMeshMapperSessionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */; }; + B10000000000000000000004 /* StopMeshMapperSessionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */; }; + B10000000000000000000005 /* SendManualPingIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* SendManualPingIntent.swift */; }; + B10000000000000000000006 /* MeshMapperMutationAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* MeshMapperMutationAppShortcuts.swift */; }; + B10000000000000000000010 /* ConnectLastMeshMapperCompanionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */; }; + B10000000000000000000007 /* MeshMapperSiriSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */; }; + B10000000000000000000008 /* MeshMapperSiriSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */; }; + B10000000000000000000009 /* MeshMapperAppIntentsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000C /* MeshMapperAppIntentsExtension.swift */; }; + B1000000000000000000000A /* MeshMapperReadIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000B /* MeshMapperReadIntents.swift */; }; + B1000000000000000000000B /* MeshMapperSiriSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */; }; + B1000000000000000000000C /* MeshMapperSiriSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */; }; + B1000000000000000000000D /* RepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* RepeaterEntity.swift */; }; + B1000000000000000000000E /* HeardRepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* HeardRepeaterEntity.swift */; }; + B1000000000000000000000F /* MeshMapperAppIntents.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* MeshMapperAppIntents.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,6 +79,13 @@ remoteGlobalIDString = A50000000000000000000001; remoteInfo = MeshMapperLiveActivityExtension; }; + B60000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = B50000000000000000000001; + remoteInfo = MeshMapperAppIntents; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -97,6 +121,17 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + B40000000000000000000004 /* Embed ExtensionKit Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + B1000000000000000000000F /* MeshMapperAppIntents.appex in Embed ExtensionKit Extensions */, + ); + name = "Embed ExtensionKit Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -107,6 +142,7 @@ 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 = ""; }; + C20000000000000000000001 /* AppIntentLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentLogicTests.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 = ""; }; @@ -147,6 +183,23 @@ 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; }; + B20000000000000000000001 /* SiriIntentCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriIntentCoordinator.swift; sourceTree = ""; }; + B20000000000000000000002 /* MeshMapperSessionMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSessionMode.swift; sourceTree = ""; }; + B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartMeshMapperSessionIntent.swift; sourceTree = ""; }; + B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopMeshMapperSessionIntent.swift; sourceTree = ""; }; + B20000000000000000000005 /* SendManualPingIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendManualPingIntent.swift; sourceTree = ""; }; + B20000000000000000000006 /* MeshMapperMutationAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperMutationAppShortcuts.swift; sourceTree = ""; }; + B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSiriSnapshot.swift; sourceTree = ""; }; + B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSiriSnapshotStore.swift; sourceTree = ""; }; + B20000000000000000000009 /* RepeaterEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeaterEntity.swift; sourceTree = ""; }; + B2000000000000000000000A /* HeardRepeaterEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeardRepeaterEntity.swift; sourceTree = ""; }; + B2000000000000000000000B /* MeshMapperReadIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperReadIntents.swift; sourceTree = ""; }; + B2000000000000000000000C /* MeshMapperAppIntentsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperAppIntentsExtension.swift; sourceTree = ""; }; + B2000000000000000000000D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B2000000000000000000000E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + B2000000000000000000000F /* MeshMapperAppIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MeshMapperAppIntents.entitlements; sourceTree = ""; }; + B20000000000000000000010 /* MeshMapperAppIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = MeshMapperAppIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectLastMeshMapperCompanionIntent.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -181,6 +234,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B40000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -225,6 +285,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + C20000000000000000000001 /* AppIntentLogicTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -263,6 +324,7 @@ 97C146F01CF9000F007C117D /* Runner */, A30000000000000000000001 /* Shared */, A30000000000000000000002 /* MeshMapperLiveActivity */, + B30000000000000000000003 /* MeshMapperAppIntents */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 88C2A374596BDB4F1DE4A0B4 /* Pods */, @@ -278,6 +340,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */, A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */, 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */, + B20000000000000000000010 /* MeshMapperAppIntents.appex */, ); name = Products; sourceTree = ""; @@ -285,6 +348,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + B30000000000000000000001 /* AppIntents */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -295,6 +359,7 @@ A20000000000000000000001 /* LiveActivityManager.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */, + B2000000000000000000000E /* Runner.entitlements */, ); path = Runner; sourceTree = ""; @@ -302,6 +367,7 @@ A30000000000000000000001 /* Shared */ = { isa = PBXGroup; children = ( + B30000000000000000000002 /* AppIntents */, A20000000000000000000002 /* MeshMapperActivityAttributes.swift */, 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */, ); @@ -318,6 +384,42 @@ path = MeshMapperLiveActivity; sourceTree = ""; }; + B30000000000000000000001 /* AppIntents */ = { + isa = PBXGroup; + children = ( + B20000000000000000000001 /* SiriIntentCoordinator.swift */, + B20000000000000000000002 /* MeshMapperSessionMode.swift */, + B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */, + B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */, + B20000000000000000000005 /* SendManualPingIntent.swift */, + B20000000000000000000006 /* MeshMapperMutationAppShortcuts.swift */, + B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; + B30000000000000000000002 /* AppIntents */ = { + isa = PBXGroup; + children = ( + B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */, + B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */, + B20000000000000000000009 /* RepeaterEntity.swift */, + B2000000000000000000000A /* HeardRepeaterEntity.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; + B30000000000000000000003 /* MeshMapperAppIntents */ = { + isa = PBXGroup; + children = ( + B2000000000000000000000B /* MeshMapperReadIntents.swift */, + B2000000000000000000000C /* MeshMapperAppIntentsExtension.swift */, + B2000000000000000000000D /* Info.plist */, + B2000000000000000000000F /* MeshMapperAppIntents.entitlements */, + ); + path = MeshMapperAppIntents; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -368,6 +470,7 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, A40000000000000000000004 /* Embed Foundation Extensions */, + B40000000000000000000004 /* Embed ExtensionKit Extensions */, 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, FF739F0FAE03970EB94871F2 /* [CP] Embed Pods Frameworks */, @@ -377,6 +480,7 @@ ); dependencies = ( A70000000000000000000001 /* PBXTargetDependency */, + B70000000000000000000001 /* PBXTargetDependency */, F203972D6336527AC5F4F89D /* PBXTargetDependency */, ); name = Runner; @@ -404,6 +508,23 @@ productReference = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + B50000000000000000000001 /* MeshMapperAppIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = B90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperAppIntents" */; + buildPhases = ( + B40000000000000000000001 /* Sources */, + B40000000000000000000002 /* Frameworks */, + B40000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshMapperAppIntents; + productName = MeshMapperAppIntents; + productReference = B20000000000000000000010 /* MeshMapperAppIntents.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -425,6 +546,9 @@ A50000000000000000000001 = { CreatedOnToolsVersion = 15.1; }; + B50000000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -446,6 +570,7 @@ 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, A50000000000000000000001 /* MeshMapperLiveActivityExtension */, + B50000000000000000000001 /* MeshMapperAppIntents */, 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */, ); }; @@ -485,6 +610,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B40000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -605,6 +737,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + C10000000000000000000001 /* AppIntentLogicTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -618,6 +751,15 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */, 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */, + B10000000000000000000001 /* SiriIntentCoordinator.swift in Sources */, + B10000000000000000000002 /* MeshMapperSessionMode.swift in Sources */, + B10000000000000000000003 /* StartMeshMapperSessionIntent.swift in Sources */, + B10000000000000000000004 /* StopMeshMapperSessionIntent.swift in Sources */, + B10000000000000000000005 /* SendManualPingIntent.swift in Sources */, + B10000000000000000000006 /* MeshMapperMutationAppShortcuts.swift in Sources */, + B10000000000000000000010 /* ConnectLastMeshMapperCompanionIntent.swift in Sources */, + B10000000000000000000007 /* MeshMapperSiriSnapshot.swift in Sources */, + B10000000000000000000008 /* MeshMapperSiriSnapshotStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -649,6 +791,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B40000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000009 /* MeshMapperAppIntentsExtension.swift in Sources */, + B1000000000000000000000A /* MeshMapperReadIntents.swift in Sources */, + B1000000000000000000000B /* MeshMapperSiriSnapshot.swift in Sources */, + B1000000000000000000000C /* MeshMapperSiriSnapshotStore.swift in Sources */, + B1000000000000000000000D /* RepeaterEntity.swift in Sources */, + B1000000000000000000000E /* HeardRepeaterEntity.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -662,6 +817,11 @@ target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */; targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */; }; + B70000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B50000000000000000000001 /* MeshMapperAppIntents */; + targetProxy = B60000000000000000000001 /* PBXContainerItemProxy */; + }; F203972D6336527AC5F4F89D /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = MeshMapperWatch; @@ -753,6 +913,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -978,6 +1139,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1000,6 +1162,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1161,6 +1324,97 @@ }; name = Debug; }; + B80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = MeshMapperAppIntents/MeshMapperAppIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperAppIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).appintents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = MeshMapperAppIntents/MeshMapperAppIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperAppIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).appintents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B80000000000000000000003 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = MeshMapperAppIntents/MeshMapperAppIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperAppIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).appintents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1204,6 +1458,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperAppIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B80000000000000000000001 /* Debug */, + B80000000000000000000002 /* Release */, + B80000000000000000000003 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 3f7e7a16..81bcdced 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -69,6 +69,8 @@ class IOSMapOfflineBridge { private let mapOfflineBridge = IOSMapOfflineBridge() private let liveActivityManager = LiveActivityManager() private let watchSessionManager = WatchSessionManager() + private let siriIntentCoordinator = SiriIntentCoordinator.shared + private let siriSnapshotStore = MeshMapperSiriSnapshotStore.shared override func application( _ application: UIApplication, @@ -141,6 +143,39 @@ class IOSMapOfflineBridge { } watchSessionManager.attach(channel: watchChannel) + // Method channel: App Intents. Mutation intents invoke Dart through this + // channel; Dart publishes a separate App Group snapshot for read-only + // intents that must not launch Flutter. + let appIntentChannel = FlutterMethodChannel( + name: "meshmapper/app_intents", + binaryMessenger: controller.binaryMessenger + ) + appIntentChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + switch call.method { + case "publishSnapshot": + do { + try self.siriSnapshotStore.writeFlutterPayload(call.arguments) + result(nil) + } catch { + result(FlutterError( + code: "snapshot_write_failed", + message: error.localizedDescription, + details: nil + )) + } + case "clearSnapshot": + self.siriSnapshotStore.clear() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + siriIntentCoordinator.attach(channel: appIntentChannel) + // 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/AppIntents/ConnectLastMeshMapperCompanionIntent.swift b/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift new file mode 100644 index 00000000..72945913 --- /dev/null +++ b/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift @@ -0,0 +1,34 @@ +import AppIntents + +@available(iOS 26.0, *) +struct ConnectLastMeshMapperCompanionIntent: AppIntent { + static let title: LocalizedStringResource = "Connect MeshMapper to Last Companion" + static let description = IntentDescription( + "Reconnects MeshMapper to the companion it most recently connected to." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .foreground(.dynamic) } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .connectLastCompanion) + ) + return .result(dialog: "\(result.message ?? Self.fallback(for: result))") + } + + private static func fallback(for result: SiriCommandResult) -> String { + switch result.disposition { + case .admitted: + return result.success + ? "MeshMapper connected to its last companion." + : "MeshMapper couldn't connect to its last companion." + case .noOp: + return "MeshMapper is already connected to its last companion." + case .refused: + return "MeshMapper couldn't connect to its last companion." + } + } +} diff --git a/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift b/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift new file mode 100644 index 00000000..4ef8f99b --- /dev/null +++ b/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift @@ -0,0 +1,37 @@ +import AppIntents + +@available(iOS 26.0, *) +struct MeshMapperMutationAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ConnectLastMeshMapperCompanionIntent(), + phrases: [ + "Connect \(.applicationName) to its last companion", + "Reconnect \(.applicationName)", + ], + shortTitle: "Connect Companion", + systemImageName: "link" + ) + AppShortcut( + intent: StartMeshMapperSessionIntent(), + phrases: [ + "Start \(.applicationName)", + "Start a session in \(.applicationName)", + "Start mapping with \(.applicationName)", + ], + shortTitle: "Start Session", + systemImageName: "antenna.radiowaves.left.and.right" + ) + AppShortcut( + intent: StopMeshMapperSessionIntent(), + phrases: [ + "Stop \(.applicationName)", + "Stop the session in \(.applicationName)", + ], + shortTitle: "Stop Session", + systemImageName: "stop.fill" + ) + } + + static var shortcutTileColor: ShortcutTileColor { .teal } +} diff --git a/ios/Runner/AppIntents/MeshMapperSessionMode.swift b/ios/Runner/AppIntents/MeshMapperSessionMode.swift new file mode 100644 index 00000000..a94fa960 --- /dev/null +++ b/ios/Runner/AppIntents/MeshMapperSessionMode.swift @@ -0,0 +1,21 @@ +import AppIntents + +@available(iOS 26.0, *) +enum MeshMapperSessionMode: String, AppEnum { + case passive + case active + case hybrid + + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "MeshMapper Mode" + ) + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .passive: "Passive Discovery", + .active: "Active", + .hybrid: "Hybrid", + ] + + var displayName: String { + self == .passive ? "Passive Discovery" : rawValue.capitalized + } +} diff --git a/ios/Runner/AppIntents/SendManualPingIntent.swift b/ios/Runner/AppIntents/SendManualPingIntent.swift new file mode 100644 index 00000000..01a93d59 --- /dev/null +++ b/ios/Runner/AppIntents/SendManualPingIntent.swift @@ -0,0 +1,23 @@ +import AppIntents + +@available(iOS 26.0, *) +struct SendManualPingIntent: AppIntent { + static let title: LocalizedStringResource = "Send MeshMapper Ping" + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .foreground(.dynamic) } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try await requestConfirmation( + conditions: [], + actionName: .send, + dialog: "Send a MeshMapper radio ping?" + ) + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .manualPing) + ) + return .result(dialog: "\(result.message ?? "MeshMapper couldn't send a ping.")") + } +} diff --git a/ios/Runner/AppIntents/SiriIntentCoordinator.swift b/ios/Runner/AppIntents/SiriIntentCoordinator.swift new file mode 100644 index 00000000..73d1f182 --- /dev/null +++ b/ios/Runner/AppIntents/SiriIntentCoordinator.swift @@ -0,0 +1,125 @@ +import Flutter +import Foundation + +enum SiriBridgeError: LocalizedError { + case appUnavailable + case invalidResponse + case flutter(String) + + var errorDescription: String? { + switch self { + case .appUnavailable: + return "MeshMapper did not finish launching in time." + case .invalidResponse: + return "MeshMapper returned an invalid command result." + case .flutter(let message): + return message + } + } +} + +struct SiriCommand { + enum Kind: String { + case startSession + case stopSession + case manualPing + case connectLastCompanion + } + + let id = UUID().uuidString + let kind: Kind + let mode: String? + let sessionId: String? + let issuedAt = Date() + + init(kind: Kind, mode: String? = nil, sessionId: String? = nil) { + self.kind = kind + self.mode = mode + self.sessionId = sessionId + } + + var dictionary: [String: Any?] { + [ + "id": id, + "source": "siri", + "kind": kind.rawValue, + "issuedAtMs": Int64(issuedAt.timeIntervalSince1970 * 1_000), + "mode": mode, + "sessionId": sessionId, + ] + } +} + +struct SiriCommandResult { + enum Disposition: String { + case admitted + case noOp + case refused + } + + let success: Bool + let disposition: Disposition + let message: String? + let sessionId: String? + let mode: String? + + init(response: Any?) throws { + if let error = response as? FlutterError { + throw SiriBridgeError.flutter(error.message ?? error.code) + } + guard let response = response as? [String: Any], + let success = response["success"] as? Bool, + let rawDisposition = response["disposition"] as? String, + let disposition = Disposition(rawValue: rawDisposition) + else { + throw SiriBridgeError.invalidResponse + } + self.success = success + self.disposition = disposition + message = response["message"] as? String + sessionId = response["sessionId"] as? String + mode = response["mode"] as? String + } +} + +@MainActor +final class SiriIntentCoordinator { + static let shared = SiriIntentCoordinator() + + private var channel: FlutterMethodChannel? + private init() {} + + func attach(channel: FlutterMethodChannel) { + self.channel = channel + } + + func execute(_ command: SiriCommand) async throws -> SiriCommandResult { + let deadline = Date().addingTimeInterval(10) + while channel == nil, Date() < deadline { + try Task.checkCancellation() + try await Task.sleep(nanoseconds: 100_000_000) + } + guard let channel else { throw SiriBridgeError.appUnavailable } + + return try await withCheckedThrowingContinuation { continuation in + var completed = false + let responseTimeout: TimeInterval = command.kind == .connectLastCompanion ? 30 : 10 + let timeout = DispatchWorkItem { + guard !completed else { return } + completed = true + continuation.resume(throwing: SiriBridgeError.appUnavailable) + } + DispatchQueue.main.asyncAfter(deadline: .now() + responseTimeout, execute: timeout) + channel.invokeMethod("command", arguments: command.dictionary) { response in + guard !completed else { return } + completed = true + timeout.cancel() + do { + continuation.resume(returning: try SiriCommandResult(response: response)) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift new file mode 100644 index 00000000..38f90d2f --- /dev/null +++ b/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift @@ -0,0 +1,42 @@ +import AppIntents + +@available(iOS 26.0, *) +struct StartMeshMapperSessionIntent: AppIntent { + static let title: LocalizedStringResource = "Start MeshMapper Session" + static let description = IntentDescription( + "Starts Active, Passive Discovery, or Hybrid mapping using MeshMapper's current radio policy." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .foreground(.dynamic) } + + @Parameter(title: "Mode", default: .passive) + var mode: MeshMapperSessionMode + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .startSession, mode: mode.rawValue) + ) + return .result( + dialog: "\(result.message ?? Self.fallback(for: result, mode: mode))" + ) + } + + private static func fallback( + for result: SiriCommandResult, + mode: MeshMapperSessionMode + ) -> String { + switch result.disposition { + case .admitted: + return result.success + ? "MeshMapper started in \(mode.displayName) mode." + : "MeshMapper couldn't start." + case .noOp: + return "MeshMapper is already running." + case .refused: + return "MeshMapper couldn't start." + } + } +} diff --git a/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift new file mode 100644 index 00000000..52d5dbe5 --- /dev/null +++ b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift @@ -0,0 +1,23 @@ +import AppIntents + +@available(iOS 26.0, *) +struct StopMeshMapperSessionIntent: AppIntent { + static let title: LocalizedStringResource = "Stop MeshMapper Session" + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + static var supportedModes: IntentModes { + [.background, .foreground(.dynamic)] + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read(), + snapshot.session.active || snapshot.session.starting + else { + return .result(dialog: "MeshMapper is already stopped.") + } + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .stopSession, sessionId: snapshot.session.id) + ) + return .result(dialog: "\(result.message ?? "MeshMapper couldn't stop.")") + } +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..7037779d --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.net.meshmapper.app.shared + + + diff --git a/ios/RunnerTests/AppIntentLogicTests.swift b/ios/RunnerTests/AppIntentLogicTests.swift new file mode 100644 index 00000000..dafc67ce --- /dev/null +++ b/ios/RunnerTests/AppIntentLogicTests.swift @@ -0,0 +1,141 @@ +import XCTest + +@testable import Runner + +final class AppIntentLogicTests: XCTestCase { + func testConnectLastCompanionCommandUsesDedicatedWireKind() { + let command = SiriCommand(kind: .connectLastCompanion) + let payload = command.dictionary + + XCTAssertEqual(payload["source"] as? String, "siri") + XCTAssertEqual(payload["kind"] as? String, "connectLastCompanion") + XCTAssertNil(payload["mode"] as? String) + XCTAssertNil(payload["sessionId"] as? String) + } + + func testDecodesVersionOneSnapshotWithoutUserCoordinates() throws { + let json = """ + { + "version": 1, + "updatedAtMs": 1787529600000, + "connection": { + "isConnected": true, + "deviceName": "MeshCore", + "batteryPercent": 83, + "gpsStatus": "fresh" + }, + "session": { + "id": "session-1", + "active": true, + "starting": false, + "mode": "passive", + "phase": "listening", + "phaseTitle": "Listening", + "phaseDetail": null, + "phaseEndsAtMs": null, + "zoneCode": "US-CA", + "txCount": 0, + "rxCount": 1, + "discoveryCount": 0, + "traceCount": 0, + "queueSize": 0, + "uniqueRepeatersHeard": 1 + }, + "controls": { + "availableStartModes": ["active", "passive", "hybrid"], + "canStart": false, + "startBlockedReason": "A session is already running.", + "canStop": true, + "canManualPing": false, + "manualPingBlockedReason": "Manual ping is unavailable in passive mode.", + "manualCooldownEndsAtMs": null + }, + "recentHeard": [{ + "entityId": "repeater-1", + "displayHexId": "A1B2C3D4", + "name": "Ridge", + "observedAtMs": 1787529590000, + "kind": "rx", + "direct": true, + "hopCount": 0, + "snr": 7.5, + "rssi": -91, + "distanceM": 1200.0, + "repeaterLat": 37.1, + "repeaterLon": -122.1, + "resolved": true + }], + "repeaters": [{ + "id": "repeater-1", + "name": "Ridge", + "hexId": "A1B2C3D4", + "zoneCode": "US-CA", + "isActive": true, + "isNew": false, + "serverLastHeardMs": 1787529590000, + "latitude": 37.1, + "longitude": -122.1 + }] + } + """ + + let snapshot = try JSONDecoder().decode( + MeshMapperSiriSnapshot.self, + from: XCTUnwrap(json.data(using: .utf8)) + ) + + XCTAssertEqual(snapshot.version, MeshMapperSiriSnapshotStore.supportedVersion) + XCTAssertEqual(snapshot.session.id, "session-1") + XCTAssertEqual(snapshot.recentHeard.first?.entityId, "repeater-1") + XCTAssertEqual(snapshot.recentHeard.first?.repeaterLat, 37.1) + XCTAssertFalse(json.contains("userLat")) + XCTAssertFalse(json.contains("userLon")) + } + + func testRejectsUnsupportedSnapshotVersionBeforeWriting() { + let snapshot = MeshMapperSiriSnapshot( + version: 2, + updatedAtMs: 0, + connection: MeshMapperSiriConnection( + isConnected: false, + deviceName: nil, + batteryPercent: nil, + gpsStatus: "unavailable" + ), + session: MeshMapperSiriSession( + id: nil, + active: false, + starting: false, + mode: "none", + phase: "idle", + phaseTitle: "Idle", + phaseDetail: nil, + phaseEndsAtMs: nil, + zoneCode: nil, + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + uniqueRepeatersHeard: 0 + ), + controls: MeshMapperSiriControls( + availableStartModes: ["active", "passive", "hybrid"], + canStart: false, + startBlockedReason: "Connect first.", + canStop: false, + canManualPing: false, + manualPingBlockedReason: "Connect first.", + manualCooldownEndsAtMs: nil + ), + recentHeard: [], + repeaters: [] + ) + + XCTAssertThrowsError(try MeshMapperSiriSnapshotStore().write(snapshot)) { error in + guard case MeshMapperSiriSnapshotStoreError.unsupportedVersion(2) = error else { + return XCTFail("Expected unsupportedVersion(2), got \(error)") + } + } + } +} diff --git a/ios/Shared/AppIntents/HeardRepeaterEntity.swift b/ios/Shared/AppIntents/HeardRepeaterEntity.swift new file mode 100644 index 00000000..878d881c --- /dev/null +++ b/ios/Shared/AppIntents/HeardRepeaterEntity.swift @@ -0,0 +1,65 @@ +import AppIntents +import Foundation + +@available(iOS 26.0, *) +struct HeardRepeaterEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Heard MeshMapper Repeater", + numericFormat: "\(placeholder: .int) heard repeaters" + ) + static var defaultQuery = HeardRepeaterEntityQuery() + + let id: String + let repeater: RepeaterEntity? + let displayHexId: String + let observedAtMs: Int64 + let snr: Double? + let distanceM: Double? + let kind: String + let direct: Bool + + init(observation: MeshMapperSiriObservation, index: Int) { + id = "\(observation.entityId ?? observation.displayHexId)|\(observation.observedAtMs)|\(observation.kind)|\(index)" + if let entityId = observation.entityId, + let snapshot = try? MeshMapperSiriSnapshotStore.shared.read(), + let repeaterSnapshot = snapshot.repeaters.first(where: { $0.id == entityId }) { + repeater = RepeaterEntity(snapshot: repeaterSnapshot) + } else { + repeater = nil + } + displayHexId = observation.displayHexId + observedAtMs = observation.observedAtMs + snr = observation.snr + distanceM = observation.distanceM + kind = observation.kind + direct = observation.direct + } + + var displayRepresentation: DisplayRepresentation { + let title = repeater?.name ?? "Repeater \(displayHexId)" + let subtitle = snr.map { String(format: "%.1f dB", $0) } + return DisplayRepresentation( + title: "\(title)", + subtitle: subtitle.map { "\($0)" } + ) + } +} + +@available(iOS 26.0, *) +struct HeardRepeaterEntityQuery: EntityQuery { + func entities(for identifiers: [HeardRepeaterEntity.ID]) async throws -> [HeardRepeaterEntity] { + let wanted = Set(identifiers) + return try observations().filter { wanted.contains($0.id) } + } + + func suggestedEntities() async throws -> [HeardRepeaterEntity] { + Array(try observations().prefix(5)) + } + + private func observations() throws -> [HeardRepeaterEntity] { + try MeshMapperSiriSnapshotStore.shared.read()? + .recentHeard + .enumerated() + .map { HeardRepeaterEntity(observation: $0.element, index: $0.offset) } ?? [] + } +} diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift new file mode 100644 index 00000000..9d6aea76 --- /dev/null +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift @@ -0,0 +1,82 @@ +import Foundation + +struct MeshMapperSiriSnapshot: Codable, Sendable { + let version: Int + let updatedAtMs: Int64 + let connection: MeshMapperSiriConnection + let session: MeshMapperSiriSession + let controls: MeshMapperSiriControls + let recentHeard: [MeshMapperSiriObservation] + let repeaters: [MeshMapperSiriRepeater] + + var updatedAt: Date { + Date(timeIntervalSince1970: Double(updatedAtMs) / 1_000) + } +} + +struct MeshMapperSiriConnection: Codable, Sendable { + let isConnected: Bool + let deviceName: String? + let batteryPercent: Int? + let gpsStatus: String +} + +struct MeshMapperSiriSession: Codable, Sendable { + let id: String? + let active: Bool + let starting: Bool + let mode: String + let phase: String + let phaseTitle: String + let phaseDetail: String? + let phaseEndsAtMs: Int64? + let zoneCode: String? + let txCount: Int + let rxCount: Int + let discoveryCount: Int + let traceCount: Int + let queueSize: Int + let uniqueRepeatersHeard: Int +} + +struct MeshMapperSiriControls: Codable, Sendable { + let availableStartModes: [String] + let canStart: Bool + let startBlockedReason: String? + let canStop: Bool + let canManualPing: Bool + let manualPingBlockedReason: String? + let manualCooldownEndsAtMs: Int64? +} + +struct MeshMapperSiriObservation: Codable, Sendable { + let entityId: String? + let displayHexId: String + let name: String? + let observedAtMs: Int64 + let kind: String + let direct: Bool + let hopCount: Int + let snr: Double? + let rssi: Int? + let distanceM: Double? + let repeaterLat: Double? + let repeaterLon: Double? + let resolved: Bool + + var observedAt: Date { + Date(timeIntervalSince1970: Double(observedAtMs) / 1_000) + } +} + +struct MeshMapperSiriRepeater: Codable, Sendable { + let id: String + let name: String + let hexId: String + let zoneCode: String? + let isActive: Bool + let isNew: Bool + let serverLastHeardMs: Int64? + let latitude: Double? + let longitude: Double? +} diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift new file mode 100644 index 00000000..a1410445 --- /dev/null +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift @@ -0,0 +1,84 @@ +import Foundation + +enum MeshMapperSiriSnapshotStoreError: LocalizedError { + case appGroupUnavailable + case invalidPayload + case unsupportedVersion(Int) + + var errorDescription: String? { + switch self { + case .appGroupUnavailable: + return "The MeshMapper App Group is unavailable." + case .invalidPayload: + return "The Siri snapshot payload is invalid." + case .unsupportedVersion(let version): + return "Unsupported Siri snapshot version \(version)." + } + } +} + +final class MeshMapperSiriSnapshotStore { + static let shared = MeshMapperSiriSnapshotStore() + static let appGroupIdentifier = "group.net.meshmapper.app.shared" + static let supportedVersion = 1 + + private let fileManager: FileManager + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + encoder.outputFormatting = [.sortedKeys] + } + + private var snapshotURL: URL? { + fileManager + .containerURL( + forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier + )? + .appendingPathComponent("siri-snapshot.json", isDirectory: false) + } + + func writeFlutterPayload(_ arguments: Any?) throws { + guard let arguments, + JSONSerialization.isValidJSONObject(arguments) + else { + throw MeshMapperSiriSnapshotStoreError.invalidPayload + } + let incoming = try JSONSerialization.data( + withJSONObject: arguments, + options: [.sortedKeys] + ) + let snapshot = try decoder.decode(MeshMapperSiriSnapshot.self, from: incoming) + try write(snapshot) + } + + func write(_ snapshot: MeshMapperSiriSnapshot) throws { + guard snapshot.version == Self.supportedVersion else { + throw MeshMapperSiriSnapshotStoreError.unsupportedVersion(snapshot.version) + } + guard let snapshotURL else { + throw MeshMapperSiriSnapshotStoreError.appGroupUnavailable + } + let data = try encoder.encode(snapshot) + try data.write(to: snapshotURL, options: .atomic) + } + + func read() throws -> MeshMapperSiriSnapshot? { + guard let snapshotURL else { + throw MeshMapperSiriSnapshotStoreError.appGroupUnavailable + } + guard fileManager.fileExists(atPath: snapshotURL.path) else { return nil } + let data = try Data(contentsOf: snapshotURL) + let snapshot = try decoder.decode(MeshMapperSiriSnapshot.self, from: data) + guard snapshot.version == Self.supportedVersion else { + throw MeshMapperSiriSnapshotStoreError.unsupportedVersion(snapshot.version) + } + return snapshot + } + + func clear() { + guard let snapshotURL else { return } + try? fileManager.removeItem(at: snapshotURL) + } +} diff --git a/ios/Shared/AppIntents/RepeaterEntity.swift b/ios/Shared/AppIntents/RepeaterEntity.swift new file mode 100644 index 00000000..1bab0ab3 --- /dev/null +++ b/ios/Shared/AppIntents/RepeaterEntity.swift @@ -0,0 +1,83 @@ +import AppIntents +import CoreSpotlight +import Foundation + +@available(iOS 26.0, *) +struct RepeaterEntity: AppEntity, IndexedEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "MeshMapper Repeater", + numericFormat: "\(placeholder: .int) repeaters" + ) + static var defaultQuery = RepeaterEntityQuery() + + let id: String + let name: String + let hexId: String + let zoneCode: String? + let isActive: Bool + let isNew: Bool + let serverLastHeardMs: Int64? + let latitude: Double? + let longitude: Double? + + init(snapshot: MeshMapperSiriRepeater) { + id = snapshot.id + name = snapshot.name + hexId = snapshot.hexId + zoneCode = snapshot.zoneCode + isActive = snapshot.isActive + isNew = snapshot.isNew + serverLastHeardMs = snapshot.serverLastHeardMs + latitude = snapshot.latitude + longitude = snapshot.longitude + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: zoneCode.map { "\($0) · \(hexId)" } ?? "\(hexId)" + ) + } + + var attributeSet: CSSearchableItemAttributeSet { + let attributes = defaultAttributeSet + attributes.title = name + attributes.contentDescription = [zoneCode, hexId] + .compactMap { $0 } + .joined(separator: " · ") + attributes.keywords = [name, hexId, zoneCode].compactMap { $0 } + if let latitude, let longitude { + attributes.latitude = latitude as NSNumber + attributes.longitude = longitude as NSNumber + } + return attributes + } +} + +@available(iOS 26.0, *) +struct RepeaterEntityQuery: EntityStringQuery { + func entities(for identifiers: [RepeaterEntity.ID]) async throws -> [RepeaterEntity] { + let wanted = Set(identifiers) + return try catalog().filter { wanted.contains($0.id) } + } + + func entities(matching string: String) async throws -> [RepeaterEntity] { + let needle = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return try catalog() } + return try catalog().filter { + $0.name.localizedCaseInsensitiveContains(needle) + || $0.hexId.localizedCaseInsensitiveContains(needle) + || ($0.zoneCode?.localizedCaseInsensitiveContains(needle) ?? false) + } + } + + func suggestedEntities() async throws -> [RepeaterEntity] { + Array(try catalog().filter(\.isActive).prefix(20)) + } + + private func catalog() throws -> [RepeaterEntity] { + try MeshMapperSiriSnapshotStore.shared.read()? + .repeaters + .map(RepeaterEntity.init(snapshot:)) ?? [] + } +} diff --git a/lib/main.dart b/lib/main.dart index aa9f7edd..98cf00f0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'services/bluetooth/bluetooth_service.dart'; import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; +import 'services/app_intents/app_intent_bridge_service.dart'; import 'services/debug_file_logger.dart'; import 'services/offline_map_service.dart'; import 'services/watch/watch_bridge_service.dart'; @@ -26,6 +27,7 @@ void main() async { // native relay fires as soon as WatchConnectivity delivers — long before the // provider finishes initializing and starts listening. WatchBridgeService.reserveCommandQueue(); + AppIntentBridgeService.reserveCommandQueue(); // Enable debug file logging FIRST on mobile to capture early logs // This must happen before DebugLogger.initialize() to capture early logs diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 6fc4ecf2..aa31bee0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -47,6 +47,13 @@ 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/app_intents/app_intent_bridge_service.dart'; +import '../services/app_intents/app_intent_commands.dart'; +import '../services/app_intents/last_companion_connection.dart'; +import '../services/app_intents/siri_snapshot_builder.dart'; +import '../services/app_intents/siri_snapshot_models.dart'; +import '../services/external_commands/external_command_models.dart'; +import '../services/external_commands/external_session_commands.dart'; import '../services/live_activity/live_activity_models.dart'; import '../services/live_activity/live_activity_service.dart'; import '../services/watch/watch_bridge_service.dart'; @@ -146,6 +153,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); + final AppIntentBridgeService _appIntentBridge = AppIntentBridgeService(); bool _hasEverPairedWatch = false; /// Last position handed to the watch, kept so a dropped GPS fix leaves the @@ -340,6 +348,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; + final Completer _rememberedDeviceReady = Completer(); // User's original preferences before zone admin overrides (single baseline). // Saved on initial connect; restored before applying each new zone's policies. @@ -683,6 +692,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hasEverPaired: _hasEverPairedWatch, ); } + TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; @@ -780,8 +790,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final sorted = bestSnr.entries - .map((entry) => - (repeaterId: entry.key, snr: entry.value, type: type)) + .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); @@ -1401,6 +1410,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void _scheduleLiveActivitySync({bool immediate = false}) { if (_isDisposed) return; + // Siri is local App Group state and must not depend on Watch availability. + _appIntentBridge.schedule(_buildSiriSnapshotMap, immediate: immediate); // The watch mirrors state even with no session running — otherwise you // could never start one from the wrist. _scheduleWatchSync(immediate: immediate); @@ -1439,7 +1450,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - void _scheduleWatchSync({bool immediate = false, bool forceDelivery = false}) { + void _scheduleWatchSync( + {bool immediate = false, bool forceDelivery = false}) { if (_isDisposed || !_watchBridge.canSync) return; _watchBridge.schedule( _buildWatchSnapshot, @@ -1818,9 +1830,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { floodTrafficEnabled && !isTxModeRunning && !isTargetedModeRunning, - manualCooldownEndsAt: cooldownMs > 0 - ? _manualPingCooldownTimer.endTime - : null, + 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. @@ -1839,6 +1850,79 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { traceLogEntries: _traceLogEntries, ); + Map? _buildSiriSnapshotMap() { + if (_isDisposed) return null; + + final recentHeard = SiriSnapshotBuilder.buildRecentHeard( + txEntries: _txLogEntries, + rxEntries: _rxLogEntries, + discoveryEntries: _discLogEntries, + traceEntries: _traceLogEntries, + repeaters: _repeaters, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + hopBytes: _hopBytes, + ); + final phase = _resolveWatchPhase(); + final passive = sessionStartAvailability(AutoMode.passive); + final active = sessionStartAvailability(AutoMode.active); + final hybrid = sessionStartAvailability(AutoMode.hybrid); + final manualPing = _manualPingAvailability; + final sessionBusy = _autoPingEnabled || _autoPingStarting; + final availableStartModes = [ + if (!sessionBusy && passive.allowed) AutoMode.passive.name, + if (!sessionBusy && active.allowed) AutoMode.active.name, + if (!sessionBusy && hybrid.allowed) AutoMode.hybrid.name, + ]; + final uniqueHeard = recentHeard + .map((item) => item.entityId ?? 'unresolved:${item.displayHexId}') + .toSet() + .length; + + return SiriSnapshot( + updatedAt: DateTime.now(), + connection: SiriConnectionSnapshot( + isConnected: isConnected, + deviceName: displayDeviceName, + batteryPercent: _currentBatteryPercent, + gpsStatus: _gpsStatus.name, + ), + session: SiriSessionSnapshot( + id: _liveActivitySessionId, + active: _autoPingEnabled, + starting: _autoPingStarting, + mode: sessionBusy ? _autoMode.name : 'idle', + phase: phase.phase.name, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + discoveryCount: _pingStats.discCount, + traceCount: _pingStats.traceCount, + queueSize: _queueSize, + uniqueRepeatersHeard: uniqueHeard, + ), + controls: SiriControlsSnapshot( + availableStartModes: availableStartModes, + canStart: !sessionBusy && passive.allowed, + startBlockedReason: _autoPingEnabled + ? 'Already running' + : _autoPingStarting + ? 'Already starting' + : passive.reason, + canStop: _autoPingEnabled, + canManualPing: manualPing.allowed, + manualPingBlockedReason: manualPing.reason, + manualCooldownEndsAt: _manualPingCooldownTimer.isRunning + ? _manualPingCooldownTimer.endTime + : null, + ), + recentHeard: recentHeard, + repeaters: SiriSnapshotBuilder.buildRepeaterCatalog(_repeaters), + ).toMap(); + } + /// Whether [mode] may begin now, with the reason the wearer should see. /// /// This is the sole provider-side start gate. One caller publishes wrist @@ -1872,6 +1956,258 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } + /// Applies the app's current session/radio rules to any external transport. + ExternalCommandAdmission admitExternalSessionCommand( + ExternalSessionCommand command, + ) { + if (_isDisposed) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'App closing', + ); + } + + final transition = resolveExternalSessionTransition( + command: command, + isSessionActive: _autoPingEnabled, + isSessionStarting: _autoPingStarting, + currentMode: _autoMode.name, + currentSessionId: _liveActivitySessionId ?? 'idle', + ); + if (!transition.shouldExecute) return transition; + + switch (command.kind) { + case ExternalSessionCommandKind.startSession: + final mode = transition.mode; + if (mode == null) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'Unsupported start mode', + ); + } + final availability = + sessionStartAvailability(_autoModeFromExternal(mode)); + if (!availability.allowed) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: availability.reason ?? 'Could not start', + mode: mode, + ); + } + return transition; + + case ExternalSessionCommandKind.stopSession: + return transition; + + case ExternalSessionCommandKind.manualPing: + final availability = _manualPingAvailability; + if (!availability.allowed) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: availability.reason ?? 'Ping unavailable', + ); + } + return transition; + } + } + + /// Waits for the admitted operation's real outcome. Watch intentionally + /// does not await this; Siri does, so it never says "started" at admission. + Future executeExternalSessionCommand( + ExternalSessionCommand command, + ExternalCommandAdmission admission, + ) async { + if (!admission.shouldExecute) { + return ExternalCommandCompletion( + success: admission.disposition == ExternalCommandDisposition.noOp, + disposition: admission.disposition, + message: admission.reason, + sessionId: _liveActivitySessionId, + mode: admission.mode, + ); + } + + _lastSessionCheckFailureReason = null; + try { + switch (command.kind) { + case ExternalSessionCommandKind.startSession: + final mode = admission.mode!; + final started = await toggleAutoPing(_autoModeFromExternal(mode)); + if (!started || !_autoPingEnabled) { + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: _externalStartFailureReason(mode), + mode: mode, + ); + } + return ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: 'MeshMapper started in ${mode.displayName} mode.', + sessionId: _liveActivitySessionId, + mode: mode, + ); + + case ExternalSessionCommandKind.stopSession: + final stopped = await toggleAutoPing(_autoMode); + if (!stopped) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: 'Could not stop', + ); + } + final stopping = _autoPingEnabled || isPendingDisable; + return ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: + stopping ? 'MeshMapper is stopping.' : 'MeshMapper stopped.', + sessionId: stopping ? _liveActivitySessionId : null, + ); + + case ExternalSessionCommandKind.manualPing: + final sent = await sendPing(); + if (!sent) { + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: _lastSessionCheckFailureReason ?? 'Ping failed', + ); + } + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: 'MeshMapper sent a manual ping.', + ); + } + } catch (error) { + debugError('[EXTERNAL] ${command.kind.name} failed: $error'); + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: command.kind == ExternalSessionCommandKind.startSession + ? _externalStartFailureReason(admission.mode) + : command.kind == ExternalSessionCommandKind.stopSession + ? 'Could not stop' + : (_lastSessionCheckFailureReason ?? 'Ping failed'), + mode: admission.mode, + ); + } + } + + Future _handleSiriCommand( + AppIntentCommand command, + ) async { + if (command.kind == AppIntentCommandKind.connectLastCompanion) { + return _connectToLastCompanion(command); + } + final sessionCommand = command.toExternalSessionCommand(); + if (sessionCommand == null) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: 'Unsupported command', + ); + } + final admission = admitExternalSessionCommand(sessionCommand); + return executeExternalSessionCommand(sessionCommand, admission); + } + + Future _connectToLastCompanion( + AppIntentCommand command, + ) async { + await _rememberedDeviceReady.future; + if (_isDisposed) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: 'App closing', + ); + } + + final remembered = _rememberedDevice; + final connectedDevice = + (_activeTransport ?? _bluetoothService).connectedDevice; + final isConnectedToRemembered = remembered != null && + connectedDevice?.id == remembered.id && + _selectedTransport == remembered.transportType; + final admission = resolveLastCompanionConnection( + command: command, + hasRememberedCompanion: remembered != null, + isConnected: isConnected, + isConnectedToRememberedCompanion: isConnectedToRemembered, + isConnecting: _isConnecting || _isAutoReconnecting, + canReconnectWithoutUserInput: + remembered?.transportType != TransportType.usbSerial, + ); + + if (!admission.shouldExecute) { + final message = switch (admission.reason) { + 'No remembered companion' => + 'MeshMapper has no remembered companion. Connect one in the app first.', + 'Already connected' => + 'MeshMapper is already connected to ${remembered!.displayName}.', + 'Another companion is connected' => + 'MeshMapper is already connected to ${displayDeviceName ?? connectedDevice?.name ?? 'another companion'}.', + 'Already connecting' => + 'MeshMapper is already connecting. Try again shortly.', + 'User interaction required' => + 'The last companion uses USB and must be selected in MeshMapper.', + _ => admission.reason ?? 'MeshMapper could not connect.', + }; + return ExternalCommandCompletion( + success: admission.disposition == ExternalCommandDisposition.noOp, + disposition: admission.disposition, + message: message, + ); + } + + try { + await reconnectToRememberedDevice(); + final reconnectedDevice = + (_activeTransport ?? _bluetoothService).connectedDevice; + if (isConnected && reconnectedDevice?.id == remembered!.id) { + return ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: 'MeshMapper connected to ${remembered.displayName}.', + ); + } + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: _connectionError ?? + 'MeshMapper could not connect to ${remembered!.displayName}.', + ); + } catch (error) { + debugError('[SIRI] Connect to last companion failed: $error'); + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: _connectionError ?? + 'MeshMapper could not connect to ${remembered!.displayName}.', + ); + } + } + + static AutoMode _autoModeFromExternal(ExternalSessionMode mode) => + switch (mode) { + ExternalSessionMode.active => AutoMode.active, + ExternalSessionMode.passive => AutoMode.passive, + ExternalSessionMode.hybrid => AutoMode.hybrid, + }; + + String _externalStartFailureReason(ExternalSessionMode? mode) { + final sessionReason = _lastSessionCheckFailureReason; + if (sessionReason != null) return sessionReason; + if (mode != ExternalSessionMode.passive && _cooldownTimer.isRunning) { + return 'Cooling down'; + } + return 'Could not start'; + } + /// Decides whether an intent from the wrist may begin. /// /// Returns null when accepted, or a reason to show on the watch. Every guard @@ -1883,11 +2219,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// 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) { + switch (command.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 @@ -1902,13 +2234,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; case WatchCommandKind.startSession: - final admission = resolveWatchSessionCommandAdmission( - kind: kind, - isSessionActive: _autoPingEnabled, - isSessionStarting: _autoPingStarting, - ); - if (admission.refusal != null) return admission.refusal; - if (!admission.shouldRun) return null; final requested = resolveWatchRequestedStartMode( requestedMode: command.mode, isConnected: isConnected, @@ -1920,82 +2245,49 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchStartMode.hybrid => AutoMode.hybrid, null => _resolvedWatchSessionMode, }; - final availability = sessionStartAvailability(mode); - if (!availability.allowed) { - return availability.reason ?? 'Could not start'; - } - unawaited(_runWatchStartSession(mode)); - return null; + return _admitAndRunWatchCommand(command, mode: mode.name); case WatchCommandKind.stopSession: - final admission = resolveWatchSessionCommandAdmission( - kind: kind, - isSessionActive: _autoPingEnabled, - isSessionStarting: _autoPingStarting, - requestedSessionId: command.sessionId, - // The same value the wrist rendered, so a stop is matched against - // what the wearer was looking at rather than against a field only - // this side knows. - currentSessionId: _liveActivitySessionId ?? 'idle', - ); - if (admission.refusal != null) return admission.refusal; - if (!admission.shouldRun) return null; - unawaited(_runWatchStopSession(_resolvedWatchSessionMode)); - return null; + return _admitAndRunWatchCommand(command); case WatchCommandKind.manualPing: - final availability = _manualPingAvailability; - if (!availability.allowed) { - return availability.reason ?? 'Ping unavailable'; - } - unawaited(_runWatchManualPing()); - return null; + return _admitAndRunWatchCommand(command); } } - 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'); + String? _admitAndRunWatchCommand(WatchCommand command, {String? mode}) { + final kind = switch (command.kind) { + WatchCommandKind.startSession => ExternalSessionCommandKind.startSession, + WatchCommandKind.stopSession => ExternalSessionCommandKind.stopSession, + WatchCommandKind.manualPing => ExternalSessionCommandKind.manualPing, + WatchCommandKind.requestSnapshot => throw ArgumentError.value( + command.kind, + 'kind', + 'Snapshot is a Watch transport command', + ), + }; + final external = ExternalSessionCommand( + id: command.id ?? const Uuid().v4(), + source: ExternalCommandSource.watch, + kind: kind, + issuedAt: command.issuedAt ?? DateTime.now(), + mode: mode, + sessionId: command.sessionId, + ); + final admission = admitExternalSessionCommand(external); + if (admission.disposition == ExternalCommandDisposition.refused) { + return admission.reason; + } + if (admission.shouldExecute) { + unawaited(executeExternalSessionCommand(external, admission).then( + (completion) { + if (!completion.success) { + _emitWatchFailure(completion.message ?? 'Command failed'); + } + }, + )); } + return null; } void _emitWatchFailure(String message) { @@ -2203,8 +2495,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - if (_liveActivityManualSession && - _manualPingCooldownTimer.isRunning) { + if (_liveActivityManualSession && _manualPingCooldownTimer.isRunning) { return ( phase: LiveActivityPhase.cooldown, title: 'Cooldown', @@ -2525,6 +2816,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }, ); } + if (_appIntentBridge.isSupportedPlatform) { + _appIntentBridge.attachCommandHandler(_handleSiriCommand); + } // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -8740,13 +9034,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load remembered device from Hive storage Future _loadRememberedDevice() async { - // Skip on web - Web Bluetooth requires user interaction for each connection - if (kIsWeb) return; + try { + // Skip on web - Web Bluetooth requires user interaction for each connection + if (kIsWeb) return; - final box = await _openBoxSafely(_rememberedDeviceBoxName); - if (box == null) return; + final box = await _openBoxSafely(_rememberedDeviceBoxName); + if (box == null) return; - try { final json = box.get('device'); if (json != null) { _rememberedDevice = @@ -8758,6 +9052,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } catch (e) { debugLog('[APP] Failed to load remembered device: $e'); + } finally { + if (!_rememberedDeviceReady.isCompleted) { + _rememberedDeviceReady.complete(); + } } } @@ -9818,6 +10116,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.removeListener(_handleLiveActivityTimerChange); } _liveActivityService.dispose(); + _appIntentBridge.dispose(); _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/services/app_intents/app_intent_bridge_service.dart b/lib/services/app_intents/app_intent_bridge_service.dart new file mode 100644 index 00000000..08e31d5a --- /dev/null +++ b/lib/services/app_intents/app_intent_bridge_service.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../external_commands/external_command_models.dart'; +import 'app_intent_commands.dart'; + +typedef AppIntentCommandHandler = Future Function( + AppIntentCommand command, +); +typedef AppIntentSnapshotBuilder = Map? Function(); + +/// Independent Flutter bridge for Siri/App Intents. +/// +/// It deliberately has no WatchConnectivity concepts. Native reads are served +/// from the App Group snapshot; only mutations cross this channel into Dart. +class AppIntentBridgeService { + AppIntentBridgeService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + }) : _channel = channel ?? const MethodChannel(_channelName), + _debounceDelay = debounceDelay, + _minimumNonUrgentInterval = minimumNonUrgentInterval; + + static const String _channelName = 'meshmapper/app_intents'; + static const int _commandQueueDepth = 8; + static const int _maximumRememberedCommands = 64; + static const Duration defaultDebounceDelay = Duration(milliseconds: 200); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 2); + + static void reserveCommandQueue() { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return; + ServicesBinding.instance.channelBuffers + .resize(_channelName, _commandQueueDepth); + } + + final MethodChannel _channel; + final Duration _debounceDelay; + final Duration _minimumNonUrgentInterval; + AppIntentCommandHandler? _commandHandler; + final Map _commandOutcomes = {}; + bool _disposed = false; + Timer? _scheduledUpdate; + AppIntentSnapshotBuilder? _pendingSnapshotBuilder; + String? _lastFingerprint; + DateTime? _lastPublishedAt; + Future _operationChain = Future.value(); + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + void attachCommandHandler(AppIntentCommandHandler handler) { + _commandHandler = handler; + if (!isSupportedPlatform) return; + _channel.setMethodCallHandler(_handleNativeCall); + } + + Future _handleNativeCall(MethodCall call) async { + if (call.method != 'command') return null; + final arguments = call.arguments; + if (arguments is! Map) { + return _refusal('Malformed command').toMap(); + } + final command = AppIntentCommand.fromMap(arguments); + if (command == null) { + return _refusal('Malformed command').toMap(); + } + + final previous = _commandOutcomes[command.id]; + if (previous != null) return previous.toMap(commandId: command.id); + + final handler = _commandHandler; + if (handler == null) { + return _refusal('App not ready').toMap(commandId: command.id); + } + + try { + final completion = await handler(command); + _remember(command.id, completion); + return completion.toMap(commandId: command.id); + } catch (error) { + debugError('[SIRI] ${command.kind.name} failed: $error'); + final completion = _refusal('Command failed'); + _remember(command.id, completion); + return completion.toMap(commandId: command.id); + } + } + + Future publishSnapshot(Map snapshot) async { + if (!isSupportedPlatform || _disposed) return false; + try { + await _channel.invokeMethod('publishSnapshot', snapshot); + return true; + } on MissingPluginException { + return false; + } on PlatformException catch (error) { + debugError( + '[SIRI] Snapshot publish failed: ${error.code}: ${error.message}', + ); + return false; + } + } + + void schedule( + AppIntentSnapshotBuilder builder, { + bool immediate = false, + }) { + if (!isSupportedPlatform || _disposed) return; + _pendingSnapshotBuilder = builder; + if (immediate) { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _queueFlush(); + return; + } + _scheduledUpdate ??= Timer(_debounceDelay, () { + _scheduledUpdate = null; + final lastPublishedAt = _lastPublishedAt; + final remaining = lastPublishedAt == null + ? Duration.zero + : _minimumNonUrgentInterval - + DateTime.now().difference(lastPublishedAt); + if (remaining > Duration.zero) { + _scheduledUpdate = Timer(remaining, () { + _scheduledUpdate = null; + _queueFlush(); + }); + } else { + _queueFlush(); + } + }); + } + + void _queueFlush() { + _operationChain = _operationChain.then((_) => _flush()); + } + + Future _flush() async { + if (_disposed) return; + final builder = _pendingSnapshotBuilder; + _pendingSnapshotBuilder = null; + if (builder == null) return; + final snapshot = builder(); + if (snapshot == null) return; + + final semantic = Map.from(snapshot)..remove('updatedAtMs'); + final fingerprint = jsonEncode(semantic); + if (fingerprint == _lastFingerprint) return; + if (await publishSnapshot(snapshot)) { + _lastFingerprint = fingerprint; + _lastPublishedAt = DateTime.now(); + } + } + + Future clearSnapshot() async { + if (!isSupportedPlatform || _disposed) return; + try { + await _channel.invokeMethod('clearSnapshot'); + } on MissingPluginException { + // Older native builds have no App Intent bridge. + } on PlatformException catch (error) { + debugError( + '[SIRI] Snapshot clear failed: ${error.code}: ${error.message}', + ); + } + } + + void _remember(String id, ExternalCommandCompletion completion) { + _commandOutcomes[id] = completion; + while (_commandOutcomes.length > _maximumRememberedCommands) { + _commandOutcomes.remove(_commandOutcomes.keys.first); + } + } + + static ExternalCommandCompletion _refusal(String message) => + ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: message, + ); + + void dispose() { + if (_disposed) return; + _disposed = true; + _commandHandler = null; + _commandOutcomes.clear(); + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _pendingSnapshotBuilder = null; + if (isSupportedPlatform) _channel.setMethodCallHandler(null); + } +} diff --git a/lib/services/app_intents/app_intent_commands.dart b/lib/services/app_intents/app_intent_commands.dart new file mode 100644 index 00000000..1ffc9fa0 --- /dev/null +++ b/lib/services/app_intents/app_intent_commands.dart @@ -0,0 +1,87 @@ +import '../external_commands/external_command_models.dart'; + +enum AppIntentCommandKind { + startSession, + stopSession, + manualPing, + connectLastCompanion; + + static AppIntentCommandKind? fromWire(String value) { + for (final kind in values) { + if (kind.name == value) return kind; + } + return null; + } + + ExternalSessionCommandKind? get sessionKind => switch (this) { + AppIntentCommandKind.startSession => + ExternalSessionCommandKind.startSession, + AppIntentCommandKind.stopSession => + ExternalSessionCommandKind.stopSession, + AppIntentCommandKind.manualPing => + ExternalSessionCommandKind.manualPing, + AppIntentCommandKind.connectLastCompanion => null, + }; +} + +/// A mutation arriving from the iOS App Intents transport. +/// +/// Session mutations convert to [ExternalSessionCommand] so Siri and Watch +/// retain one policy path. Connecting is intentionally Siri-only and remains +/// outside that session model. +class AppIntentCommand { + const AppIntentCommand({ + required this.id, + required this.kind, + required this.issuedAt, + this.mode, + this.sessionId, + }); + + final String id; + final AppIntentCommandKind kind; + final DateTime issuedAt; + final String? mode; + final String? sessionId; + + static AppIntentCommand? fromMap(Map map) { + final id = map['id']; + final source = map['source']; + final kind = map['kind']; + final issuedAtMs = map['issuedAtMs']; + if (id is! String || + id.isEmpty || + source != ExternalCommandSource.siri.name || + kind is! String || + issuedAtMs is! num) { + return null; + } + final parsedKind = AppIntentCommandKind.fromWire(kind); + if (parsedKind == null) return null; + final mode = map['mode']; + final sessionId = map['sessionId']; + if (mode != null && mode is! String) return null; + if (sessionId != null && sessionId is! String) return null; + + return AppIntentCommand( + id: id, + kind: parsedKind, + issuedAt: DateTime.fromMillisecondsSinceEpoch(issuedAtMs.toInt()), + mode: mode as String?, + sessionId: sessionId as String?, + ); + } + + ExternalSessionCommand? toExternalSessionCommand() { + final sessionKind = kind.sessionKind; + if (sessionKind == null) return null; + return ExternalSessionCommand( + id: id, + source: ExternalCommandSource.siri, + kind: sessionKind, + issuedAt: issuedAt, + mode: mode, + sessionId: sessionId, + ); + } +} diff --git a/lib/services/app_intents/last_companion_connection.dart b/lib/services/app_intents/last_companion_connection.dart new file mode 100644 index 00000000..1ce679f9 --- /dev/null +++ b/lib/services/app_intents/last_companion_connection.dart @@ -0,0 +1,55 @@ +import '../external_commands/external_command_models.dart'; +import '../external_commands/external_session_commands.dart'; +import 'app_intent_commands.dart'; + +ExternalCommandAdmission resolveLastCompanionConnection({ + required AppIntentCommand command, + required bool hasRememberedCompanion, + required bool isConnected, + required bool isConnectedToRememberedCompanion, + required bool isConnecting, + required bool canReconnectWithoutUserInput, + DateTime? now, +}) { + final ageRefusal = externalCommandTimestampRefusal( + command.issuedAt, + now: now, + ); + if (ageRefusal != null) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ageRefusal, + ); + } + if (!hasRememberedCompanion) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'No remembered companion', + ); + } + if (isConnected) { + return ExternalCommandAdmission( + disposition: isConnectedToRememberedCompanion + ? ExternalCommandDisposition.noOp + : ExternalCommandDisposition.refused, + reason: isConnectedToRememberedCompanion + ? 'Already connected' + : 'Another companion is connected', + ); + } + if (isConnecting) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'Already connecting', + ); + } + if (!canReconnectWithoutUserInput) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'User interaction required', + ); + } + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + ); +} diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart new file mode 100644 index 00000000..6926617c --- /dev/null +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -0,0 +1,190 @@ +import '../../models/log_entry.dart'; +import '../../models/repeater.dart'; +import '../watch/watch_geo_builder.dart'; +import '../watch/watch_models.dart'; +import 'siri_snapshot_models.dart'; + +class SiriSnapshotBuilder { + const SiriSnapshotBuilder._(); + + static const int maximumObservations = 64; + static const Duration maximumObservationAge = Duration(hours: 2); + + static List buildRecentHeard({ + required List txEntries, + required List rxEntries, + required List discoveryEntries, + required List traceEntries, + required List repeaters, + required String? zoneCode, + int? hopBytes, + DateTime? now, + }) { + final cutoff = (now ?? DateTime.now()).subtract(maximumObservationAge); + final observations = []; + + SiriRepeaterObservation observation({ + required String displayId, + String? identity, + required DateTime observedAt, + required SiriObservationKind kind, + required bool direct, + required int hopCount, + required double latitude, + required double longitude, + double? snr, + int? rssi, + }) { + final normalizedId = displayId.toUpperCase(); + final repeater = (identity == null + ? null + : WatchGeoBuilder.resolveRepeater( + repeaters: repeaters, + id: identity, + hopBytes: hopBytes, + )) ?? + WatchGeoBuilder.resolveRepeater( + repeaters: repeaters, + id: normalizedId, + hopBytes: hopBytes, + ); + final resolved = repeater != null; + final hasDistance = resolved && + repeater.hasLocation && + latitude.isFinite && + longitude.isFinite; + return SiriRepeaterObservation( + entityId: resolved + ? '${repeater.iata ?? zoneCode ?? 'GLOBAL'}|${repeater.id}' + : null, + displayHexId: normalizedId, + name: resolved && repeater.name != 'Unknown' ? repeater.name : null, + observedAt: observedAt, + kind: kind, + direct: direct, + hopCount: hopCount, + snr: snr, + rssi: rssi, + distanceM: hasDistance + ? WatchWire.distanceMeters( + latitude, + longitude, + repeater.lat, + repeater.lon, + ) + : null, + repeaterLat: resolved && repeater.hasLocation ? repeater.lat : null, + repeaterLon: resolved && repeater.hasLocation ? repeater.lon : null, + resolved: resolved, + ); + } + + for (final entry in txEntries) { + if (entry.timestamp.isBefore(cutoff)) continue; + for (final event in entry.events) { + observations.add(observation( + displayId: event.repeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.txEcho, + direct: true, + hopCount: 1, + latitude: entry.latitude, + longitude: entry.longitude, + snr: event.snr, + rssi: event.rssi, + )); + } + for (final event in entry.multiHopEvents) { + observations.add(observation( + displayId: event.repeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.txEcho, + direct: false, + hopCount: event.pathHops.isEmpty ? 2 : event.pathHops.length, + latitude: entry.latitude, + longitude: entry.longitude, + snr: event.snr, + rssi: event.rssi, + )); + } + } + + for (final entry in rxEntries) { + if (entry.timestamp.isBefore(cutoff)) continue; + observations.add(observation( + displayId: entry.repeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.passiveRx, + direct: entry.pathLength <= 1, + hopCount: entry.pathLength, + latitude: entry.latitude, + longitude: entry.longitude, + snr: entry.snr, + rssi: entry.rssi, + )); + } + + for (final entry in discoveryEntries) { + if (entry.timestamp.isBefore(cutoff)) continue; + for (final node in entry.discoveredNodes + .where((node) => node.nodeType == 'REPEATER')) { + observations.add(observation( + displayId: node.repeaterId, + identity: node.pubkeyHex, + observedAt: entry.timestamp, + kind: SiriObservationKind.discovery, + direct: true, + hopCount: 1, + latitude: entry.latitude, + longitude: entry.longitude, + snr: node.localSnr, + rssi: node.localRssi, + )); + } + } + + for (final entry in traceEntries.where((entry) => entry.success)) { + if (entry.timestamp.isBefore(cutoff)) continue; + observations.add(observation( + displayId: entry.targetRepeaterId, + identity: entry.targetRepeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.trace, + direct: true, + hopCount: 0, + latitude: entry.latitude, + longitude: entry.longitude, + snr: entry.localSnr, + rssi: entry.localRssi, + )); + } + + observations.sort((a, b) => b.observedAt.compareTo(a.observedAt)); + return observations.length > maximumObservations + ? observations.sublist(0, maximumObservations) + : observations; + } + + static List buildRepeaterCatalog( + List repeaters, + ) => + repeaters + .map( + (repeater) => SiriRepeaterEntitySnapshot( + id: '${repeater.iata ?? 'GLOBAL'}|${repeater.id}', + name: repeater.name, + hexId: repeater.hexId.toUpperCase(), + zoneCode: repeater.iata, + isActive: repeater.isActive, + isNew: repeater.isNew, + serverLastHeard: repeater.lastHeard == 0 + ? null + : DateTime.fromMillisecondsSinceEpoch( + repeater.lastHeard * 1000, + ), + latitude: repeater.hasLocation ? repeater.lat : null, + longitude: repeater.hasLocation ? repeater.lon : null, + ), + ) + .toList(growable: false); +} diff --git a/lib/services/app_intents/siri_snapshot_models.dart b/lib/services/app_intents/siri_snapshot_models.dart new file mode 100644 index 00000000..e91ae58c --- /dev/null +++ b/lib/services/app_intents/siri_snapshot_models.dart @@ -0,0 +1,230 @@ +import 'package:flutter/foundation.dart'; + +const int siriSnapshotWireVersion = 1; + +enum SiriObservationKind { txEcho, passiveRx, discovery, trace } + +@immutable +class SiriConnectionSnapshot { + const SiriConnectionSnapshot({ + required this.isConnected, + this.deviceName, + this.batteryPercent, + required this.gpsStatus, + }); + + final bool isConnected; + final String? deviceName; + final int? batteryPercent; + final String gpsStatus; + + Map toMap() => { + 'isConnected': isConnected, + 'deviceName': deviceName, + 'batteryPercent': batteryPercent, + 'gpsStatus': gpsStatus, + }; +} + +@immutable +class SiriSessionSnapshot { + const SiriSessionSnapshot({ + this.id, + required this.active, + required this.starting, + required this.mode, + required this.phase, + required this.phaseTitle, + this.phaseDetail, + this.phaseEndsAt, + this.zoneCode, + required this.txCount, + required this.rxCount, + required this.discoveryCount, + required this.traceCount, + required this.queueSize, + required this.uniqueRepeatersHeard, + }); + + final String? id; + final bool active; + final bool starting; + final String mode; + final String phase; + final String phaseTitle; + final String? phaseDetail; + final DateTime? phaseEndsAt; + final String? zoneCode; + final int txCount; + final int rxCount; + final int discoveryCount; + final int traceCount; + final int queueSize; + final int uniqueRepeatersHeard; + + Map toMap() => { + 'id': id, + 'active': active, + 'starting': starting, + 'mode': mode, + 'phase': phase, + 'phaseTitle': phaseTitle, + 'phaseDetail': phaseDetail, + 'phaseEndsAtMs': phaseEndsAt?.millisecondsSinceEpoch, + 'zoneCode': zoneCode, + 'txCount': txCount, + 'rxCount': rxCount, + 'discoveryCount': discoveryCount, + 'traceCount': traceCount, + 'queueSize': queueSize, + 'uniqueRepeatersHeard': uniqueRepeatersHeard, + }; +} + +@immutable +class SiriControlsSnapshot { + const SiriControlsSnapshot({ + required this.availableStartModes, + required this.canStart, + this.startBlockedReason, + required this.canStop, + required this.canManualPing, + this.manualPingBlockedReason, + this.manualCooldownEndsAt, + }); + + final List availableStartModes; + final bool canStart; + final String? startBlockedReason; + final bool canStop; + final bool canManualPing; + final String? manualPingBlockedReason; + final DateTime? manualCooldownEndsAt; + + Map toMap() => { + 'availableStartModes': availableStartModes, + 'canStart': canStart, + 'startBlockedReason': startBlockedReason, + 'canStop': canStop, + 'canManualPing': canManualPing, + 'manualPingBlockedReason': manualPingBlockedReason, + 'manualCooldownEndsAtMs': manualCooldownEndsAt?.millisecondsSinceEpoch, + }; +} + +@immutable +class SiriRepeaterObservation { + const SiriRepeaterObservation({ + this.entityId, + required this.displayHexId, + this.name, + required this.observedAt, + required this.kind, + required this.direct, + required this.hopCount, + this.snr, + this.rssi, + this.distanceM, + this.repeaterLat, + this.repeaterLon, + required this.resolved, + }); + + final String? entityId; + final String displayHexId; + final String? name; + final DateTime observedAt; + final SiriObservationKind kind; + final bool direct; + final int hopCount; + final double? snr; + final int? rssi; + final double? distanceM; + final double? repeaterLat; + final double? repeaterLon; + final bool resolved; + + Map toMap() => { + 'entityId': entityId, + 'displayHexId': displayHexId, + 'name': name, + 'observedAtMs': observedAt.millisecondsSinceEpoch, + 'kind': kind.name, + 'direct': direct, + 'hopCount': hopCount, + 'snr': snr, + 'rssi': rssi, + 'distanceM': distanceM, + 'repeaterLat': repeaterLat, + 'repeaterLon': repeaterLon, + 'resolved': resolved, + }; +} + +@immutable +class SiriRepeaterEntitySnapshot { + const SiriRepeaterEntitySnapshot({ + required this.id, + required this.name, + required this.hexId, + this.zoneCode, + required this.isActive, + required this.isNew, + this.serverLastHeard, + this.latitude, + this.longitude, + }); + + final String id; + final String name; + final String hexId; + final String? zoneCode; + final bool isActive; + final bool isNew; + final DateTime? serverLastHeard; + final double? latitude; + final double? longitude; + + Map toMap() => { + 'id': id, + 'name': name, + 'hexId': hexId, + 'zoneCode': zoneCode, + 'isActive': isActive, + 'isNew': isNew, + 'serverLastHeardMs': serverLastHeard?.millisecondsSinceEpoch, + 'latitude': latitude, + 'longitude': longitude, + }; +} + +@immutable +class SiriSnapshot { + const SiriSnapshot({ + this.version = siriSnapshotWireVersion, + required this.updatedAt, + required this.connection, + required this.session, + required this.controls, + required this.recentHeard, + required this.repeaters, + }); + + final int version; + final DateTime updatedAt; + final SiriConnectionSnapshot connection; + final SiriSessionSnapshot session; + final SiriControlsSnapshot controls; + final List recentHeard; + final List repeaters; + + Map toMap() => { + 'version': version, + 'updatedAtMs': updatedAt.millisecondsSinceEpoch, + 'connection': connection.toMap(), + 'session': session.toMap(), + 'controls': controls.toMap(), + 'recentHeard': recentHeard.map((item) => item.toMap()).toList(), + 'repeaters': repeaters.map((item) => item.toMap()).toList(), + }; +} diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart new file mode 100644 index 00000000..4f6b2c21 --- /dev/null +++ b/lib/services/external_commands/external_command_models.dart @@ -0,0 +1,148 @@ +import 'package:flutter/foundation.dart'; + +enum ExternalCommandSource { + watch, + siri; + + static ExternalCommandSource? fromWire(String value) { + for (final source in values) { + if (source.name == value) return source; + } + return null; + } +} + +enum ExternalSessionCommandKind { + startSession, + stopSession, + manualPing; + + static ExternalSessionCommandKind? fromWire(String value) { + for (final kind in values) { + if (kind.name == value) return kind; + } + return null; + } +} + +/// The generic session modes an external surface may request. +/// +/// Trace is intentionally absent: it needs a stable repeater identity and a +/// dedicated command rather than a string smuggled through generic Start. +enum ExternalSessionMode { + active, + passive, + hybrid; + + static ExternalSessionMode? fromWire(String value) { + for (final mode in values) { + if (mode.name == value) return mode; + } + return null; + } + + String get displayName => switch (this) { + ExternalSessionMode.active => 'Active', + ExternalSessionMode.passive => 'Passive Discovery', + ExternalSessionMode.hybrid => 'Hybrid', + }; +} + +enum ExternalCommandDisposition { + admitted, + noOp, + refused; +} + +@immutable +class ExternalSessionCommand { + const ExternalSessionCommand({ + required this.id, + required this.source, + required this.kind, + required this.issuedAt, + this.mode, + this.sessionId, + }); + + final String id; + final ExternalCommandSource source; + final ExternalSessionCommandKind kind; + final DateTime issuedAt; + final String? mode; + final String? sessionId; + + static ExternalSessionCommand? fromMap( + Map map, { + required ExternalCommandSource expectedSource, + }) { + final id = map['id']; + final source = map['source']; + final kind = map['kind']; + final issuedAtMs = map['issuedAtMs']; + if (id is! String || + id.isEmpty || + source is! String || + ExternalCommandSource.fromWire(source) != expectedSource || + kind is! String || + issuedAtMs is! num) { + return null; + } + final parsedKind = ExternalSessionCommandKind.fromWire(kind); + if (parsedKind == null) return null; + final mode = map['mode']; + final sessionId = map['sessionId']; + if (mode != null && mode is! String) return null; + if (sessionId != null && sessionId is! String) return null; + + return ExternalSessionCommand( + id: id, + source: expectedSource, + kind: parsedKind, + issuedAt: DateTime.fromMillisecondsSinceEpoch(issuedAtMs.toInt()), + mode: mode as String?, + sessionId: sessionId as String?, + ); + } +} + +@immutable +class ExternalCommandAdmission { + const ExternalCommandAdmission({ + required this.disposition, + this.reason, + this.mode, + }); + + final ExternalCommandDisposition disposition; + final String? reason; + final ExternalSessionMode? mode; + + bool get shouldExecute => disposition == ExternalCommandDisposition.admitted; +} + +@immutable +class ExternalCommandCompletion { + const ExternalCommandCompletion({ + required this.success, + required this.disposition, + this.message, + this.sessionId, + this.mode, + }); + + final bool success; + final ExternalCommandDisposition disposition; + final String? message; + final String? sessionId; + final ExternalSessionMode? mode; + + Map toMap({String? commandId}) => { + if (commandId != null) 'id': commandId, + 'success': success, + 'disposition': disposition.name, + 'message': message, + 'sessionId': sessionId, + 'mode': mode?.name, + }; +} diff --git a/lib/services/external_commands/external_session_commands.dart b/lib/services/external_commands/external_session_commands.dart new file mode 100644 index 00000000..0ce25843 --- /dev/null +++ b/lib/services/external_commands/external_session_commands.dart @@ -0,0 +1,107 @@ +import 'external_command_models.dart'; + +const Duration maximumExternalCommandAge = Duration(seconds: 30); +const Duration externalCommandFutureTolerance = Duration(seconds: 5); + +String? externalCommandTimestampRefusal( + DateTime issuedAt, { + DateTime? now, +}) { + final age = (now ?? DateTime.now()).difference(issuedAt); + if (age > maximumExternalCommandAge || + age < -externalCommandFutureTolerance) { + return 'Took too long to reach iPhone'; + } + return null; +} + +/// Reject commands whose delayed execution could transmit from the wrong +/// location. Stop is deliberately exempt and is instead bound to a session ID. +String? externalCommandAgeRefusal( + ExternalSessionCommand command, { + DateTime? now, +}) { + if (command.kind == ExternalSessionCommandKind.stopSession) return null; + return externalCommandTimestampRefusal(command.issuedAt, now: now); +} + +ExternalCommandAdmission resolveExternalSessionTransition({ + required ExternalSessionCommand command, + required bool isSessionActive, + required bool isSessionStarting, + required String currentMode, + required String currentSessionId, + DateTime? now, +}) { + final ageRefusal = externalCommandAgeRefusal(command, now: now); + if (ageRefusal != null) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ageRefusal, + ); + } + + switch (command.kind) { + case ExternalSessionCommandKind.startSession: + if (isSessionActive) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.noOp, + reason: 'MeshMapper is already running in $currentMode mode.', + ); + } + if (isSessionStarting) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.noOp, + reason: 'MeshMapper is already starting.', + ); + } + final rawMode = command.mode ?? + (command.source == ExternalCommandSource.siri ? 'passive' : null); + if (rawMode == null) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'No start mode was provided', + ); + } + final mode = ExternalSessionMode.fromWire(rawMode); + if (mode == null) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'Unsupported start mode', + ); + } + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + mode: mode, + ); + + case ExternalSessionCommandKind.stopSession: + if (isSessionStarting && !isSessionActive) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'Still starting — try Stop again', + ); + } + if (!isSessionActive) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.noOp, + reason: "There isn't a MeshMapper session running.", + ); + } + if (command.sessionId != null && command.sessionId != currentSessionId) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: 'That session already ended', + ); + } + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + mode: ExternalSessionMode.fromWire(currentMode.toLowerCase()), + ); + + case ExternalSessionCommandKind.manualPing: + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + ); + } +} diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 729b5dee..63d176ed 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -342,6 +342,12 @@ class WatchBridgeService { // normal snapshots and one-shot cues. final admission = handler(WatchCommand( kind: kind, + id: id, + issuedAt: issuedAtMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (issuedAtMs + clockOffsetMs).round(), + ), mode: args['mode'] as String?, mapGeoNeeded: effectiveMapGeoNeeded, forceRefresh: args['forceRefresh'] == true, diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index b713ad18..1c29b2ee 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -480,6 +480,8 @@ enum WatchCommandKind { class WatchCommand { const WatchCommand({ required this.kind, + this.id, + this.issuedAt, this.mode, this.mapGeoNeeded, this.forceRefresh = false, @@ -487,6 +489,8 @@ class WatchCommand { }); final WatchCommandKind kind; + final String? id; + final DateTime? issuedAt; final String? mode; /// Which session a [WatchCommandKind.stopSession] means, taken from the diff --git a/test/services/app_intents/app_intent_bridge_service_test.dart b/test/services/app_intents/app_intent_bridge_service_test.dart new file mode 100644 index 00000000..ce4599d5 --- /dev/null +++ b/test/services/app_intents/app_intent_bridge_service_test.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/app_intents/app_intent_bridge_service.dart'; +import 'package:mesh_mapper/services/app_intents/app_intent_commands.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AppIntentBridgeService', () { + late MethodChannel channel; + late AppIntentBridgeService bridge; + late List> published; + var clearCalls = 0; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/app_intents_test'); + published = []; + clearCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'publishSnapshot') { + published.add(call.arguments as Map); + } else if (call.method == 'clearSnapshot') { + clearCalls++; + } + return null; + }); + bridge = AppIntentBridgeService( + channel: channel, + debounceDelay: const Duration(milliseconds: 5), + minimumNonUrgentInterval: const Duration(milliseconds: 25), + ); + }); + + tearDown(() { + bridge.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future> sendCommand( + String id, { + String source = 'siri', + String kind = 'startSession', + String? mode = 'passive', + }) async { + final reply = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('command', { + 'id': id, + 'source': source, + 'kind': kind, + 'issuedAtMs': DateTime.now().millisecondsSinceEpoch, + if (mode != null) 'mode': mode, + })), + null, + ); + return channel.codec.decodeEnvelope(reply!) as Map; + } + + test('native mutation waits for completion and deduplicates its UUID', + () async { + var calls = 0; + final released = Completer(); + bridge.attachCommandHandler((command) async { + calls++; + await released.future; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: 'MeshMapper started in Passive Discovery mode.', + sessionId: 'session-1', + mode: ExternalSessionMode.passive, + ); + }); + + final pending = sendCommand('command-1'); + await Future.delayed(Duration.zero); + expect(calls, 1); + var completed = false; + unawaited(pending.then((_) => completed = true)); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + + released.complete(); + final first = await pending; + final duplicate = await sendCommand('command-1'); + + expect(first['success'], isTrue); + expect(first['disposition'], 'admitted'); + expect(first['sessionId'], 'session-1'); + expect(duplicate, first); + expect(calls, 1); + }); + + test('wrong-source and malformed commands fail closed', () async { + bridge.attachCommandHandler((_) async => throw StateError('not called')); + + final result = await sendCommand('command-2', source: 'watch'); + + expect(result['success'], isFalse); + expect(result['disposition'], 'refused'); + expect(result['message'], 'Malformed command'); + }); + + test('connect-last-companion remains a Siri-only mutation', () async { + AppIntentCommand? received; + bridge.attachCommandHandler((command) async { + received = command; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: 'MeshMapper connected to Test Radio.', + ); + }); + + final result = await sendCommand( + 'command-connect', + kind: 'connectLastCompanion', + mode: null, + ); + + expect(received?.kind, AppIntentCommandKind.connectLastCompanion); + expect(received?.toExternalSessionCommand(), isNull); + expect(result['success'], isTrue); + expect(result['message'], 'MeshMapper connected to Test Radio.'); + }); + + test('semantic duplicate snapshots ignore updatedAtMs', () async { + Map snapshot(int updatedAtMs, {bool active = false}) => { + 'version': 1, + 'updatedAtMs': updatedAtMs, + 'session': {'active': active}, + }; + + bridge.schedule(() => snapshot(1), immediate: true); + await Future.delayed(const Duration(milliseconds: 10)); + bridge.schedule(() => snapshot(2), immediate: true); + await Future.delayed(const Duration(milliseconds: 10)); + bridge.schedule(() => snapshot(3, active: true), immediate: true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(published, hasLength(2)); + expect((published.last['session'] as Map)['active'], isTrue); + }); + + test('clearSnapshot uses the independent App Intents channel', () async { + await bridge.clearSnapshot(); + expect(clearCalls, 1); + }); + }); +} diff --git a/test/services/app_intents/last_companion_connection_test.dart b/test/services/app_intents/last_companion_connection_test.dart new file mode 100644 index 00000000..14c78610 --- /dev/null +++ b/test/services/app_intents/last_companion_connection_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/app_intents/app_intent_commands.dart'; +import 'package:mesh_mapper/services/app_intents/last_companion_connection.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; + +void main() { + final now = DateTime.fromMillisecondsSinceEpoch(100000); + + AppIntentCommand command({DateTime? issuedAt}) => AppIntentCommand( + id: 'connect-1', + kind: AppIntentCommandKind.connectLastCompanion, + issuedAt: issuedAt ?? now, + ); + + ExternalCommandAdmission resolve({ + bool remembered = true, + bool connected = false, + bool connectedToRemembered = false, + bool connecting = false, + bool unattended = true, + DateTime? issuedAt, + }) => + resolveLastCompanionConnection( + command: command(issuedAt: issuedAt), + hasRememberedCompanion: remembered, + isConnected: connected, + isConnectedToRememberedCompanion: connectedToRemembered, + isConnecting: connecting, + canReconnectWithoutUserInput: unattended, + now: now, + ); + + test('admits an unattended reconnect to the remembered companion', () { + expect(resolve().disposition, ExternalCommandDisposition.admitted); + }); + + test('already connected to the remembered companion is a no-op', () { + final result = resolve( + connected: true, + connectedToRemembered: true, + ); + + expect(result.disposition, ExternalCommandDisposition.noOp); + expect(result.reason, 'Already connected'); + }); + + test('fails closed for missing, busy, switched, and USB companions', () { + expect( + resolve(remembered: false).reason, + 'No remembered companion', + ); + expect( + resolve(connecting: true).reason, + 'Already connecting', + ); + expect( + resolve(connected: true).reason, + 'Another companion is connected', + ); + expect( + resolve(unattended: false).reason, + 'User interaction required', + ); + }); + + test('stale and future connect commands are refused', () { + expect( + resolve(issuedAt: now.subtract(const Duration(seconds: 31))).reason, + 'Took too long to reach iPhone', + ); + expect( + resolve(issuedAt: now.add(const Duration(seconds: 6))).reason, + 'Took too long to reach iPhone', + ); + }); +} diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart new file mode 100644 index 00000000..8e60f4e1 --- /dev/null +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -0,0 +1,298 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/log_entry.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/services/app_intents/siri_snapshot_builder.dart'; +import 'package:mesh_mapper/services/app_intents/siri_snapshot_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +void main() { + final now = DateTime.fromMillisecondsSinceEpoch(1787535920000); + const capitolHill = Repeater( + id: 'database-123', + hexId: '4E5D82AA', + name: 'Capitol Hill', + lat: 47.6202, + lon: -122.3194, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + + List build({ + List tx = const [], + List rx = const [], + List discovery = const [], + List trace = const [], + List repeaters = const [capitolHill], + }) => + SiriSnapshotBuilder.buildRecentHeard( + txEntries: tx, + rxEntries: rx, + discoveryEntries: discovery, + traceEntries: trace, + repeaters: repeaters, + zoneCode: 'SEA', + hopBytes: 2, + now: now, + ); + + test('direct TX resolves only an unambiguous identity and uses event GPS', + () { + final at = now.subtract(const Duration(minutes: 1)); + final observations = build(tx: [ + TxLogEntry( + timestamp: at, + latitude: 47.6000, + longitude: -122.3000, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 8.5, rssi: -91)], + ), + ]); + + expect(observations, hasLength(1)); + final heard = observations.single; + expect(heard.name, 'Capitol Hill'); + expect(heard.entityId, 'SEA|database-123'); + expect(heard.direct, isTrue); + expect( + heard.distanceM, + closeTo( + WatchWire.distanceMeters( + 47.6000, + -122.3000, + capitolHill.lat, + capitolHill.lon, + ), + 0.01, + )); + }); + + test('ambiguous short hashes stay unnamed', () { + const collision = Repeater( + id: 'database-456', + hexId: '4E5D99BB', + name: 'Collision', + lat: 47.7, + lon: -122.4, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + tx: [ + TxLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 7.2)], + ), + ], + repeaters: const [capitolHill, collision], + ); + + expect(observations.single.resolved, isFalse); + expect(observations.single.name, isNull); + expect(observations.single.entityId, isNull); + }); + + test('multi-hop TX preserves route semantics and signal values', () { + final observations = build(tx: [ + TxLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + power: 1, + events: const [], + multiHopEvents: [ + MultiHopEchoEvent( + repeaterId: '4E5D82AA', + snr: 3.25, + rssi: -102, + pathHops: const ['AA', 'BB', '4E5D82AA'], + ), + ], + ), + ]); + + final heard = observations.single; + expect(heard.kind, SiriObservationKind.txEcho); + expect(heard.direct, isFalse); + expect(heard.hopCount, 3); + expect(heard.snr, 3.25); + expect(heard.rssi, -102); + }); + + test('passive RX preserves SNR RSSI and path count', () { + final observations = build(rx: [ + RxLogEntry( + timestamp: now, + repeaterId: '4E5D82AA', + snr: -1.5, + rssi: -111, + pathLength: 2, + header: 0x01, + latitude: 47.6, + longitude: -122.3, + ), + ]); + + final heard = observations.single; + expect(heard.kind, SiriObservationKind.passiveRx); + expect(heard.direct, isFalse); + expect(heard.hopCount, 2); + expect(heard.snr, -1.5); + expect(heard.rssi, -111); + }); + + test('discovery full pubkey disambiguates while failed trace is omitted', () { + const collision = Repeater( + id: 'database-456', + hexId: '4E5D99BB', + name: 'Collision', + lat: 47.7, + lon: -122.4, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + discovery: [ + DiscLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 6, + localRssi: -90, + remoteSnr: 4, + pubkeyHex: + '4E5D82AA00000000000000000000000000000000000000000000000000000000', + ), + ], + ), + ], + trace: [ + TraceLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + targetRepeaterId: '4E5D82AA', + success: false, + ), + ], + repeaters: const [capitolHill, collision], + ); + + expect(observations, hasLength(1)); + expect(observations.single.kind, SiriObservationKind.discovery); + expect(observations.single.name, 'Capitol Hill'); + }); + + test('discovery without enough identity never guesses a collision', () { + const collision = Repeater( + id: 'database-456', + hexId: '4E5D99BB', + name: 'Collision', + lat: 47.7, + lon: -122.4, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + discovery: [ + DiscLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 2, + localRssi: -105, + remoteSnr: 1, + ), + ], + ), + ], + repeaters: const [capitolHill, collision], + ); + + expect(observations.single.resolved, isFalse); + expect(observations.single.name, isNull); + }); + + test('successful trace resolves its exact target', () { + final observations = build(trace: [ + TraceLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + targetRepeaterId: '4E5D82AA', + localSnr: 9, + localRssi: -88, + success: true, + ), + ]); + + final heard = observations.single; + expect(heard.kind, SiriObservationKind.trace); + expect(heard.name, 'Capitol Hill'); + expect(heard.entityId, 'SEA|database-123'); + }); + + test('duplicate observations are retained as separate events', () { + final entries = [ + for (var index = 0; index < 2; index++) + RxLogEntry( + timestamp: now.subtract(Duration(seconds: index)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ]; + + final observations = build(rx: entries); + expect(observations, hasLength(2)); + expect(observations.map((item) => item.entityId).toSet(), hasLength(1)); + }); + + test('history is newest-first, two-hour bounded, and capped at 64', () { + final entries = List.generate( + 70, + (index) => RxLogEntry( + timestamp: now.subtract(Duration(minutes: index)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + )..add(RxLogEntry( + timestamp: now.subtract(const Duration(hours: 3)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + )); + + final observations = build(rx: entries); + + expect(observations, hasLength(64)); + expect(observations.first.observedAt, now); + expect( + observations.every( + (item) => now.difference(item.observedAt) <= const Duration(hours: 2), + ), + isTrue, + ); + }); +} diff --git a/test/services/app_intents/siri_snapshot_wire_contract_test.dart b/test/services/app_intents/siri_snapshot_wire_contract_test.dart new file mode 100644 index 00000000..8f545a66 --- /dev/null +++ b/test/services/app_intents/siri_snapshot_wire_contract_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/app_intents/siri_snapshot_models.dart'; + +void main() { + test('Dart snapshot keys match the Swift Codable mirror exactly', () { + final at = DateTime.fromMillisecondsSinceEpoch(1787535920000); + final snapshot = SiriSnapshot( + updatedAt: at, + connection: const SiriConnectionSnapshot( + isConnected: true, + deviceName: 'MrAlders0n', + batteryPercent: 76, + gpsStatus: 'locked', + ), + session: SiriSessionSnapshot( + id: 'session-1', + active: true, + starting: false, + mode: 'hybrid', + phase: 'listening', + phaseTitle: 'Listening', + phaseDetail: 'Waiting for echoes', + phaseEndsAt: at, + zoneCode: 'SEA', + txCount: 12, + rxCount: 8, + discoveryCount: 4, + traceCount: 0, + queueSize: 0, + uniqueRepeatersHeard: 9, + ), + controls: SiriControlsSnapshot( + availableStartModes: const ['passive', 'active', 'hybrid'], + canStart: false, + startBlockedReason: 'Already running', + canStop: true, + canManualPing: false, + manualPingBlockedReason: 'Cooling down', + manualCooldownEndsAt: at, + ), + recentHeard: [ + SiriRepeaterObservation( + entityId: 'SEA|database-123', + displayHexId: '4E5D', + name: 'Capitol Hill', + observedAt: at, + kind: SiriObservationKind.txEcho, + direct: true, + hopCount: 1, + snr: 8.5, + rssi: -91, + distanceM: 2149.3, + repeaterLat: 47.6202, + repeaterLon: -122.3194, + resolved: true, + ), + ], + repeaters: [ + SiriRepeaterEntitySnapshot( + id: 'SEA|database-123', + name: 'Capitol Hill', + hexId: '4E5D82AA', + zoneCode: 'SEA', + isActive: true, + isNew: false, + serverLastHeard: at, + latitude: 47.6202, + longitude: -122.3194, + ), + ], + ).toMap(); + + expect(snapshot.keys.toSet(), { + 'version', + 'updatedAtMs', + 'connection', + 'session', + 'controls', + 'recentHeard', + 'repeaters', + }); + expect((snapshot['connection']! as Map).keys.toSet(), { + 'isConnected', + 'deviceName', + 'batteryPercent', + 'gpsStatus', + }); + expect((snapshot['session']! as Map).keys.toSet(), { + 'id', + 'active', + 'starting', + 'mode', + 'phase', + 'phaseTitle', + 'phaseDetail', + 'phaseEndsAtMs', + 'zoneCode', + 'txCount', + 'rxCount', + 'discoveryCount', + 'traceCount', + 'queueSize', + 'uniqueRepeatersHeard', + }); + expect((snapshot['controls']! as Map).keys.toSet(), { + 'availableStartModes', + 'canStart', + 'startBlockedReason', + 'canStop', + 'canManualPing', + 'manualPingBlockedReason', + 'manualCooldownEndsAtMs', + }); + expect(((snapshot['recentHeard']! as List).single as Map).keys.toSet(), { + 'entityId', + 'displayHexId', + 'name', + 'observedAtMs', + 'kind', + 'direct', + 'hopCount', + 'snr', + 'rssi', + 'distanceM', + 'repeaterLat', + 'repeaterLon', + 'resolved', + }); + expect(((snapshot['repeaters']! as List).single as Map).keys.toSet(), { + 'id', + 'name', + 'hexId', + 'zoneCode', + 'isActive', + 'isNew', + 'serverLastHeardMs', + 'latitude', + 'longitude', + }); + + final encoded = snapshot.toString(); + expect(encoded, isNot(contains('currentUserLatitude'))); + expect(encoded, isNot(contains('observationUserLatitude'))); + expect(encoded, isNot(contains('observationUserLongitude'))); + }); +} diff --git a/test/services/external_commands/external_session_commands_test.dart b/test/services/external_commands/external_session_commands_test.dart new file mode 100644 index 00000000..d78e29bc --- /dev/null +++ b/test/services/external_commands/external_session_commands_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; + +ExternalSessionCommand command({ + ExternalSessionCommandKind kind = ExternalSessionCommandKind.startSession, + ExternalCommandSource source = ExternalCommandSource.siri, + String? mode = 'passive', + String? sessionId, + DateTime? issuedAt, +}) => + ExternalSessionCommand( + id: 'command-1', + source: source, + kind: kind, + issuedAt: issuedAt ?? DateTime.fromMillisecondsSinceEpoch(100000), + mode: mode, + sessionId: sessionId, + ); + +void main() { + final now = DateTime.fromMillisecondsSinceEpoch(100000); + + group('wire decoding', () { + test('accepts only a complete Siri payload', () { + final decoded = ExternalSessionCommand.fromMap( + { + 'id': 'abc', + 'source': 'siri', + 'kind': 'startSession', + 'issuedAtMs': 100000, + 'mode': 'hybrid', + 'sessionId': null, + }, + expectedSource: ExternalCommandSource.siri, + ); + + expect(decoded, isNotNull); + expect(decoded!.kind, ExternalSessionCommandKind.startSession); + expect(decoded.mode, 'hybrid'); + }); + + test('rejects another transport and unknown commands', () { + expect( + ExternalSessionCommand.fromMap( + { + 'id': 'abc', + 'source': 'watch', + 'kind': 'startSession', + 'issuedAtMs': 100000, + }, + expectedSource: ExternalCommandSource.siri, + ), + isNull, + ); + expect( + ExternalSessionCommand.fromMap( + { + 'id': 'abc', + 'source': 'siri', + 'kind': 'traceRepeater', + 'issuedAtMs': 100000, + }, + expectedSource: ExternalCommandSource.siri, + ), + isNull, + ); + }); + }); + + group('transition admission', () { + ExternalCommandAdmission resolve( + ExternalSessionCommand value, { + bool active = false, + bool starting = false, + String currentMode = 'hybrid', + String currentSessionId = 'session-a', + }) => + resolveExternalSessionTransition( + command: value, + isSessionActive: active, + isSessionStarting: starting, + currentMode: currentMode, + currentSessionId: currentSessionId, + now: now, + ); + + test('Siri defaults an unqualified Start to Passive Discovery', () { + final admission = resolve(command(mode: null)); + + expect(admission.disposition, ExternalCommandDisposition.admitted); + expect(admission.mode, ExternalSessionMode.passive); + }); + + test('all three general modes are admitted and Trace is refused', () { + for (final mode in ['active', 'passive', 'hybrid']) { + expect( + resolve(command(mode: mode)).mode?.name, + mode, + ); + } + final trace = resolve(command(mode: 'targeted')); + expect(trace.disposition, ExternalCommandDisposition.refused); + expect(trace.reason, 'Unsupported start mode'); + }); + + test('duplicate Start and idle Stop are first-class no-ops', () { + final duplicate = resolve(command(), active: true); + final idleStop = resolve(command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + )); + + expect(duplicate.disposition, ExternalCommandDisposition.noOp); + expect(duplicate.reason, contains('already running')); + expect(idleStop.disposition, ExternalCommandDisposition.noOp); + expect(idleStop.reason, contains("isn't")); + }); + + test('Stop cannot terminate a newer session', () { + final admission = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + sessionId: 'session-a', + ), + active: true, + currentSessionId: 'session-b', + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + expect(admission.reason, 'That session already ended'); + }); + + test('Stop is age exempt but Start and Manual Ping expire', () { + final stale = now.subtract(const Duration(seconds: 31)); + final start = resolve(command(issuedAt: stale)); + final ping = resolve(command( + kind: ExternalSessionCommandKind.manualPing, + mode: null, + issuedAt: stale, + )); + final stop = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + sessionId: 'session-a', + issuedAt: stale, + ), + active: true, + ); + + expect(start.reason, 'Took too long to reach iPhone'); + expect(ping.reason, 'Took too long to reach iPhone'); + expect(stop.disposition, ExternalCommandDisposition.admitted); + }); + + test('future timestamps beyond tolerance are refused', () { + final admission = resolve( + command(issuedAt: now.add(const Duration(seconds: 6))), + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + }); + }); +} From e740c061927492937c185cf0ddbf41520ec119b5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 09:07:22 -0400 Subject: [PATCH 322/360] Actually stop auto-ping when a skipped ping triggers the idle stop --- lib/services/ping_service.dart | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 6d1e3b94..88468f34 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -565,6 +565,11 @@ class PingService { final validation = canPing(); if (validation != PingValidation.valid) { + // Unlock BEFORE scheduling. onAutoPingScheduled fires synchronously + // and the idle auto-stop hangs off it, so a disable arriving while + // this is still true latches as pending and never drains (a skipped + // ping arms no RX window). Matches _sendDiscoveryRequest's ordering. + _pingInProgress = false; // For auto mode, schedule next attempt if distance check failed if (_autoPingEnabled && !_passiveModeEnabled) { if (validation == PingValidation.tooCloseToLastPing) { @@ -578,7 +583,6 @@ class PingService { _scheduleNextAutoPing(); } } - _pingInProgress = false; return false; } } @@ -990,6 +994,15 @@ class PingService { // The AutoPingTimer in countdown_timer_service.dart handles the display onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + // That callback runs synchronously and can stop auto mode (the idle + // auto-stop hangs off it), which cancels _autoTimer. Re-check so we don't + // re-arm a timer the stop just cleared. + if (!_autoPingEnabled || _passiveModeEnabled) { + debugLog( + '[ACTIVE MODE] Auto mode stopped while scheduling, not arming timer'); + return; + } + // Schedule the next ping _autoTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[ACTIVE MODE] Auto ping timer fired'); @@ -1500,6 +1513,12 @@ class PingService { onAutoPingScheduled?.call(waitMs, _skipReason); + // See _scheduleNextAutoPing: the callback can stop auto mode synchronously. + if (!_autoPingEnabled || !_hybridModeEnabled) { + debugLog('[HYBRID] Auto mode stopped while scheduling, not arming timer'); + return; + } + _autoTimer = Timer(Duration(milliseconds: waitMs), () { if (!_autoPingEnabled || !_hybridModeEnabled) return; if (_connection.currentStep != ConnectionStep.connected) { From e47894c6834542bb4e9468228cc35e5ada3d8a5c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 09:08:06 -0400 Subject: [PATCH 323/360] Export release secrets read from the local env file --- Build.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Build.sh b/Build.sh index c5af7df7..bbf0f234 100755 --- a/Build.sh +++ b/Build.sh @@ -50,7 +50,12 @@ export PATH="$JAVA_HOME/bin:$PATH" # Local release secrets (optional, never committed; lives outside the repo) RELEASE_ENV="$HOME/.meshmapper_release.env" if [ -f "$RELEASE_ENV" ]; then + # set -a exports everything the file assigns. Gradle reads the signing + # passwords with System.getenv, so a plain "NAME=value" in the file has to + # be exported here or the release build fails on a null store password. + set -a source "$RELEASE_ENV" + set +a fi # Semver comparison: returns 0 (true) if $1 >= $2 From 7272aa4126a00b2a6af7478f7bb01a33c7937e90 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 09:10:56 -0400 Subject: [PATCH 324/360] Stop knocking on portal routes the server has blocked --- lib/providers/app_state_provider.dart | 11 ++++++++ lib/screens/settings_screen.dart | 17 +++++++++++-- lib/services/portal_account_service.dart | 16 ++++++++++++ .../services/portal_account_service_test.dart | 25 +++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 9352d62f..e8ef8200 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -9317,6 +9317,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return const PortalLinkOutcome(PortalLinkStatus.skipped); } debugLog('[ACCOUNT] Manual link requested for ${_pkPrefix(pubkey)}'); + + // The local ladder below is ours to clear; a block the SERVER asked for is + // not. Knocking on a live 429 re-arms a fresh penalty on the portal's + // sliding bucket, so an impatient tap would extend the user's own lockout. + final blocked = _portalAccountService.linkLaneBackoff; + if (blocked != null) { + debugLog('[ACCOUNT] Manual link blocked by portal for ' + '${blocked.inSeconds}s'); + return PortalLinkOutcome(PortalLinkStatus.failed, retryAfter: blocked); + } + _portalLinkDeclinedDevices.remove(pubkey); _portalLinkAttempts.remove(pubkey); _portalLinkRetryAfter.remove(pubkey); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 30396554..d6439437 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2823,11 +2823,24 @@ class _SettingsScreenState extends State { case PortalLinkStatus.skipped: case PortalLinkStatus.failed: // A manual tap deserves feedback even though the automatic path is - // deliberately silent. - AppToast.error(context, 'Could not link right now — try again later'); + // deliberately silent. When the portal named a wait, say it: retrying + // inside the block only re-arms a fresh penalty. + final wait = outcome.retryAfter; + if (wait != null) { + AppToast.warning(context, + 'Too many attempts. Try again in ${_waitLabel(wait)}.'); + } else { + AppToast.error(context, 'Could not link right now — try again later'); + } } } + /// Round a backoff up to a whole unit the user can act on. + String _waitLabel(Duration wait) { + if (wait.inSeconds < 60) return '${wait.inSeconds}s'; + return '${(wait.inSeconds / 60).ceil()} min'; + } + Future _unlinkPortalDevice( BuildContext context, AppStateProvider appState) async { final ok = await appState.unlinkCurrentDevice(); diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart index f34a7e2f..62eddd3f 100644 --- a/lib/services/portal_account_service.dart +++ b/lib/services/portal_account_service.dart @@ -198,10 +198,15 @@ class PortalLinkOutcome { final int adoptionDeviceCount; final String? accountName; + /// How long the portal asked us to stay off the link lane, when that is why + /// this attempt did not happen. Null for every other outcome. + final Duration? retryAfter; + const PortalLinkOutcome( this.status, { this.adoptionDeviceCount = 0, this.accountName, + this.retryAfter, }); } @@ -844,6 +849,17 @@ class PortalAccountService { Map body, String? token, ) async { + // Answer a live block locally instead of sending. The portal's buckets + // slide and a blocked request does NOT reset the count, it re-arms a FRESH + // penalty, so every extra knock extends the user's own lockout. Deliberately + // does NOT touch _rateLimitedUntil: only a real server 429 moves that clock. + final blocked = rateLimitBackoff(action); + if (blocked != null) { + _warn('$action not sent: rate limited, ${blocked.inSeconds}s left'); + return _PortalResponse(429, const {}, 'rate_limited', + retryAfter: blocked); + } + final headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'Accept': 'application/json', diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index dd63bdab..0f020137 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -1014,6 +1014,31 @@ void main() { expectBackoff(service.linkLaneBackoff, const Duration(seconds: 120)); }); + + test('a blocked route is not knocked on a second time', () async { + final service = + await signedIn(recordingClient((_) => rateLimited(retryAfter: '120'))); + + expect(await service.requestNonce('A' * 64), isNull); + expect(callsTo('nonce'), 1); + + // The portal's bucket slides and a blocked request re-arms a FRESH + // penalty, so the second attempt must never leave the app. + expect(await service.requestNonce('A' * 64), isNull); + expect(callsTo('nonce'), 1); + expectBackoff(service.linkLaneBackoff, const Duration(seconds: 120)); + }); + + test('a block on one route does not block another', () async { + final service = await signedIn(recordingClient((request) => + request.url.queryParameters['action'] == 'nonce' + ? rateLimited(retryAfter: '120') + : http.Response('{"ok":true}', 200))); + + expect(await service.requestNonce('A' * 64), isNull); + expect(await service.unlinkDevice('A' * 64), isTrue); + expect(callsTo('unlink'), 1); + }); }); } From cd67162f24b24538a3e010dc02714e33c266a904 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 09:12:00 -0400 Subject: [PATCH 325/360] Save the last RX observations when a session expires --- lib/providers/app_state_provider.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e8ef8200..5b2b959a 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -7362,6 +7362,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[SESSION] Stationary revoke: dropping ${_apiQueueService.queueSize} queued pings instead of preserving them'); } else if (sessionErrors.contains(reason)) { try { + // Land the per-repeater RX batches in the queue FIRST. disconnect() + // below flushes them too, but only after this snapshot has been + // taken and right before it clears the queue, so without this the + // best observation for every currently audible repeater is lost. + await _rxLogger?.flushAllBatches(trigger: 'session_expiry'); + final queuedPings = await _apiQueueService.extractAllAsJson(); if (queuedPings.isNotEmpty) { final offlineDeviceName = _offlineDeviceName; From f529f16e691c29996db00d73c216cf6750a3fa7b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 09:13:22 -0400 Subject: [PATCH 326/360] Don't offer a coverage retry that can't work offline --- lib/widgets/map_widget.dart | 44 +++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 778d13e9..743e7980 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -9705,11 +9705,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Done with no stats means the fetch could not be answered. // Never fall through to the zero row here: that is the bug. if (!loading && stats == null) { + // With no zone (Offline Mode, or not connected) there is + // nothing to retry against: fetchRepeaterCoveragePoints + // answers null without making a request, so a Retry button + // would just loop on the same answer. Say why instead. + final hasZone = (appState.zoneCode ?? '').isNotEmpty; // Pop with our own token: the result handler treats every // other value, a bare pop included, as a real close and // tears the selection down. return _coverageUnavailableRow( - context, () => Navigator.of(context).pop('retry')); + context, + onRetry: hasZone + ? () => Navigator.of(context).pop('retry') + : null, + message: hasZone + ? "Couldn't load coverage" + : 'Connect to load coverage', + ); } String v(int? n) => loading ? '…' : '${n ?? 0}'; final nothingRecorded = !loading && stats!.totalMatched == 0; @@ -9816,7 +9828,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// [onRetry] re-enters the sheet with no cached future. The stats future is /// cached per selection and handed to both the pill and the sheet, so /// toggling between them would otherwise just re-render the same failure. - Widget _coverageUnavailableRow(BuildContext context, VoidCallback onRetry) { + /// A null [onRetry] means there is nothing to retry against, so the row + /// states the reason rather than offering a button that cannot work. + Widget _coverageUnavailableRow( + BuildContext context, { + required VoidCallback? onRetry, + required String message, + }) { final scheme = Theme.of(context).colorScheme; return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -9825,20 +9843,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { const SizedBox(width: 8), Flexible( child: Text( - "Couldn't load coverage", + message, style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant), ), ), - const SizedBox(width: 8), - TextButton( - onPressed: onRetry, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 32), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + if (onRetry != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Retry'), ), - child: const Text('Retry'), - ), + ], ], ); } From 8c4e6e9a0ff89b6cd8773db158b3ac1d10a99692 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 09:14:27 -0400 Subject: [PATCH 327/360] Make the wire tag drop test actually check the queue --- test/services/api_queue_wire_tag_test.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/services/api_queue_wire_tag_test.dart b/test/services/api_queue_wire_tag_test.dart index 9e70541d..e02f39ef 100644 --- a/test/services/api_queue_wire_tag_test.dart +++ b/test/services/api_queue_wire_tag_test.dart @@ -74,12 +74,21 @@ void main() { final queue = newQueue(); await enqueueTaggedTx(queue); await enqueueRx(queue); + // RX sits in the per-repeater buffer until a flush, so the main queue + // holds exactly the tagged TX ping at this point. + expect(queue.queueSize, 1); // Auto-reconnect deliberately preserves the queue, but /auth only reuses // a session while it is status=1 and unexpired. Otherwise a new // session_id comes back and everything already queued is stale. await queue.dropStaleTaggedItems(); + // Assert on queueSize, NOT on extractAllAsJson: the snapshot applies the + // same tag filter itself, so checking only its output passes even when + // dropStaleTaggedItems drops nothing at all. + expect(queue.queueSize, 0, + reason: 'the tagged TX ping must actually leave the queue'); + final pending = await queue.extractAllAsJson(); expect(pending, hasLength(1)); expect(pending.single['type'], 'RX', From c4dd370a7d786881499b83264e64c2c790bc9fa8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 10:42:57 -0400 Subject: [PATCH 328/360] Set the TestFlight what to test text after an upload The text isn't part of the archive and xcodebuild has no flag for it, so it goes through the App Store Connect API once the build registers. Same key as upload_ios.sh, no new secrets. Writes to the app's own primary locale rather than en-US. App Store Connect pre-creates an empty localization in the primary locale on every build and TestFlight falls back to it, so text written only to en-US lands in a row most testers never open. --- .gitignore | 3 + DEVELOPMENT.md | 5 + set_whats_new.sh | 462 +++++++++++++++++++++++++++++++++++++++++++++++ upload_ios.sh | 6 + 4 files changed, 476 insertions(+) create mode 100755 set_whats_new.sh diff --git a/.gitignore b/.gitignore index ef05ec63..7f45a74b 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ drive_route_map*.html # Build metadata written by Build.sh (machine-specific) .last_build +# Local TestFlight "What to Test" scratch text picked up by set_whats_new.sh +WhatToTest.txt + # Claude Code .claude/ CLAUDE.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e08b53a2..cbb0bb76 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -74,6 +74,11 @@ MESHMAPPER_API_KEY= ./Build.sh # Upload the built iOS archive to App Store Connect (needs ASC API key, see upload_ios.sh) ./upload_ios.sh + +# Set the TestFlight "What to Test" text on the uploaded build (same ASC API key). +# This cannot ride along with the upload, so it is a separate leg that waits for +# App Store Connect to register the build. +./set_whats_new.sh --notes-file notes.txt ``` ### Debug Logging diff --git a/set_whats_new.sh b/set_whats_new.sh new file mode 100755 index 00000000..c7ce669a --- /dev/null +++ b/set_whats_new.sh @@ -0,0 +1,462 @@ +#!/bin/bash + +# MeshMapper TestFlight "What to Test" script +# Sets the per-build "What to Test" text on a TestFlight build through the +# App Store Connect API (betaBuildLocalizations). +# +# This cannot be done at upload time: the text is not part of the IPA, the +# archive, or the export. There is no -exportOptionsPlist key for it and altool +# has no flag for it. It has to be set after the upload, once App Store Connect +# has registered the build (usually a couple of minutes). +# +# Auth: the same credentials upload_ios.sh uses. ASC_KEY_ID and ASC_ISSUER_ID in +# the environment or ~/.meshmapper_release.env, with the .p8 at +# ~/.appstoreconnect/private_keys/AuthKey_.p8 unless ASC_KEY_PATH says +# otherwise. Set ASC_APP_ID in that file to skip the bundle-id lookup. +# +# Usage: +# ./set_whats_new.sh --notes-file notes.txt # build number from .last_build +# ./set_whats_new.sh --notes-file notes.txt --build-number 1787524245 +# echo "Fixed the map freezing on reconnect" | ./set_whats_new.sh +# ./set_whats_new.sh --notes-file notes.txt --dry-run # look it up, send nothing +# +# The text is always shown for confirmation before anything is sent. Pass --yes +# to skip that prompt (the release flow approves the draft before calling this). + +set -e + +cd "$(dirname "$0")" + +# ASC_API_BASE only exists so the flow can be exercised against a stub server +API_BASE="${ASC_API_BASE:-https://api.appstoreconnect.apple.com}" +BUNDLE_ID="${ASC_BUNDLE_ID:-net.meshmapper.app}" +MAX_WHATS_NEW=4000 + +NOTES_FILE="" +BUILD_NUMBER="" +LOCALE="" +ASSUME_YES=0 +DRY_RUN=0 +TIMEOUT=900 +POLL_INTERVAL=15 + +usage() { + echo "Usage: ./set_whats_new.sh [--notes-file PATH|-] [--build-number N] [--locale L]" + echo " [--yes] [--dry-run] [--timeout SECONDS]" + echo "" + echo " --notes-file File holding the What to Test text ('-' reads stdin)." + echo " Defaults to stdin when piped, else WhatToTest.txt." + echo " --build-number TestFlight build number. Defaults to EPOCH from .last_build." + echo " --locale App Store Connect locale. Defaults to the app's own" + echo " primary locale, which is what TestFlight falls back to." + echo " --yes Skip the confirmation prompt." + echo " --dry-run Resolve the app and build, print the text, send nothing." + echo " --timeout How long to wait for the build to register. Default 900s." +} + +while [ $# -gt 0 ]; do + case "$1" in + --notes-file) NOTES_FILE="$2"; shift 2 ;; + --build-number) BUILD_NUMBER="$2"; shift 2 ;; + --locale) LOCALE="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --yes|-y) ASSUME_YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "Error: Unknown argument: $1" + usage + exit 1 + ;; + esac +done + +# Local release secrets (optional, never committed; lives outside the repo) +RELEASE_ENV="$HOME/.meshmapper_release.env" +if [ -f "$RELEASE_ENV" ]; then + source "$RELEASE_ENV" +fi + +if [ -z "$ASC_KEY_ID" ] || [ -z "$ASC_ISSUER_ID" ]; then + echo "Error: ASC_KEY_ID and ASC_ISSUER_ID must be set (env or $RELEASE_ENV)." + exit 1 +fi + +ASC_KEY_PATH="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8}" +if [ ! -f "$ASC_KEY_PATH" ]; then + echo "Error: API key file not found: $ASC_KEY_PATH" + exit 1 +fi + +for tool in curl jq python3 openssl; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Error: $tool is required but not installed." + exit 1 + fi +done + +# --------------------------------------------------------------------------- +# Resolve the text +# --------------------------------------------------------------------------- + +has_content() { + printf '%s' "$1" | grep -q '[^[:space:]]' +} + +WHATS_NEW="" +if [ -n "$NOTES_FILE" ]; then + if [ "$NOTES_FILE" = "-" ]; then + WHATS_NEW=$(cat) + else + if [ ! -f "$NOTES_FILE" ]; then + echo "Error: Notes file not found: $NOTES_FILE" + exit 1 + fi + WHATS_NEW=$(cat "$NOTES_FILE") + fi +else + # Piped input wins, but an empty pipe (a run with stdin on /dev/null) falls + # through to WhatToTest.txt rather than looking like an empty note. + if [ ! -t 0 ]; then + WHATS_NEW=$(cat) + fi + if ! has_content "$WHATS_NEW" && [ -f WhatToTest.txt ]; then + WHATS_NEW=$(cat WhatToTest.txt) + fi +fi + +if ! has_content "$WHATS_NEW"; then + echo "Error: No What to Test text. Pass --notes-file PATH, pipe it in, or create WhatToTest.txt." + exit 1 +fi + +# Drop blank and whitespace-only lines from the top and bottom +WHATS_NEW=$(printf '%s\n' "$WHATS_NEW" | awk ' + { lines[NR] = $0 } + END { + start = 1 + end = NR + while (start <= end && lines[start] ~ /^[[:space:]]*$/) start++ + while (end >= start && lines[end] ~ /^[[:space:]]*$/) end-- + for (i = start; i <= end; i++) print lines[i] + }') + +# Count characters, not bytes: App Store Connect counts characters and release +# notes routinely carry non-ASCII +CHAR_COUNT=$(printf '%s' "$WHATS_NEW" | python3 -c 'import sys; sys.stdout.write(str(len(sys.stdin.read())))') +if [ "$CHAR_COUNT" -gt "$MAX_WHATS_NEW" ]; then + echo "Error: What to Test is $CHAR_COUNT characters. App Store Connect caps it at $MAX_WHATS_NEW." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Resolve the build number +# --------------------------------------------------------------------------- + +if [ -z "$BUILD_NUMBER" ] && [ -f .last_build ]; then + BUILD_NUMBER=$(grep '^EPOCH=' .last_build | cut -d= -f2-) +fi +if [ -z "$BUILD_NUMBER" ]; then + echo "Error: No build number. Pass --build-number, or run ./Build.sh so .last_build exists." + exit 1 +fi + +# --------------------------------------------------------------------------- +# App Store Connect API +# --------------------------------------------------------------------------- + +# Mints a fresh ES256 JWT. Called per request so a long build-poll can never +# outlive the token (App Store Connect caps token lifetime at 20 minutes). +mint_jwt() { + python3 - "$ASC_KEY_PATH" "$ASC_KEY_ID" "$ASC_ISSUER_ID" <<'PY' +import base64 +import json +import subprocess +import sys +import time + +key_path, key_id, issuer_id = sys.argv[1], sys.argv[2], sys.argv[3] + + +def b64(raw): + return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + + +def compact(obj): + return json.dumps(obj, separators=(',', ':')).encode('utf-8') + + +now = int(time.time()) +header = {'alg': 'ES256', 'kid': key_id, 'typ': 'JWT'} +payload = {'iss': issuer_id, 'iat': now, 'exp': now + 900, 'aud': 'appstoreconnect-v1'} +signing_input = (b64(compact(header)) + '.' + b64(compact(payload))).encode('ascii') + +result = subprocess.run( + ['openssl', 'dgst', '-sha256', '-sign', key_path], + input=signing_input, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, +) +if result.returncode != 0: + sys.stderr.write('openssl could not sign with the API key:\n') + sys.stderr.write(result.stderr.decode('utf-8', 'replace')) + sys.exit(1) + +# openssl emits the ECDSA signature as DER (SEQUENCE of two INTEGERs). +# JWS ES256 wants the raw pair, each padded to the 32-byte P-256 field size. +der = result.stdout +if not der or der[0] != 0x30: + sys.stderr.write('Unexpected signature encoding from openssl.\n') + sys.exit(1) + +idx = 1 +length_byte = der[idx] +idx += 1 +if length_byte & 0x80: + idx += length_byte & 0x7F + +parts = [] +for _ in range(2): + if der[idx] != 0x02: + sys.stderr.write('Unexpected signature encoding from openssl.\n') + sys.exit(1) + idx += 1 + int_len = der[idx] + idx += 1 + parts.append(int.from_bytes(der[idx:idx + int_len], 'big')) + idx += int_len + +signature = parts[0].to_bytes(32, 'big') + parts[1].to_bytes(32, 'big') +sys.stdout.write(signing_input.decode('ascii') + '.' + b64(signature)) +PY +} + +HTTP_STATUS="" +HTTP_BODY="" + +# asc_request METHOD PATH [BODY_FILE] - fills HTTP_STATUS and HTTP_BODY +asc_request() { + local method="$1" + local path="$2" + local body_file="${3:-}" + local token + local response + + if ! token=$(mint_jwt); then + echo "Error: Could not mint an App Store Connect token." + exit 1 + fi + + # -g keeps curl from treating the filter[...] brackets as a glob range + if [ -n "$body_file" ]; then + response=$(curl -sS -g -X "$method" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + --data-binary "@$body_file" \ + -w $'\n%{http_code}' \ + "${API_BASE}${path}") || return 1 + else + response=$(curl -sS -g -X "$method" \ + -H "Authorization: Bearer $token" \ + -w $'\n%{http_code}' \ + "${API_BASE}${path}") || return 1 + fi + + HTTP_STATUS="${response##*$'\n'}" + HTTP_BODY="${response%$'\n'*}" + return 0 +} + +api_error() { + local detail + detail=$(printf '%s' "$HTTP_BODY" | jq -r '.errors[]? | " [\(.status)] \(.code): \(.title) \(.detail // "")"' 2>/dev/null || true) + if [ -n "$detail" ]; then + printf '%s\n' "$detail" + else + printf ' %s\n' "$HTTP_BODY" + fi +} + +# --- App id --------------------------------------------------------------- + +APP_ID="$ASC_APP_ID" +if [ -z "$APP_ID" ]; then + echo "Looking up $BUNDLE_ID..." + if ! asc_request GET "/v1/apps?filter[bundleId]=${BUNDLE_ID}&limit=1"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: App lookup failed (HTTP $HTTP_STATUS)." + api_error + exit 1 + fi + APP_ID=$(printf '%s' "$HTTP_BODY" | jq -r '.data[0].id // empty') + if [ -z "$APP_ID" ]; then + echo "Error: No app found for bundle id $BUNDLE_ID." + exit 1 + fi + echo " app id $APP_ID (set ASC_APP_ID=$APP_ID in $RELEASE_ENV to skip this lookup)" +fi + +# --- Locale --------------------------------------------------------------- +# Default to the app's own primary locale. App Store Connect pre-creates a +# localization in that locale on every build and TestFlight falls back to it, so +# writing only en-US on an en-CA app leaves most testers reading an empty What to +# Test while the text sits in a row they never see. + +if [ -z "$LOCALE" ]; then + if ! asc_request GET "/v1/apps/${APP_ID}"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: Could not read the app's primary locale (HTTP $HTTP_STATUS)." + api_error + exit 1 + fi + LOCALE=$(printf '%s' "$HTTP_BODY" | jq -r '.data.attributes.primaryLocale // empty') + if [ -z "$LOCALE" ]; then + LOCALE="en-US" + echo " no primary locale reported, falling back to $LOCALE" + else + echo " app primary locale: $LOCALE" + fi +fi + +# --------------------------------------------------------------------------- +# Confirmation gate (everything below this point writes) +# --------------------------------------------------------------------------- + +echo "" +echo "============================================" +echo "TestFlight What to Test" +echo "App: $BUNDLE_ID" +echo "Build: $BUILD_NUMBER" +echo "Locale: $LOCALE" +echo "Length: $CHAR_COUNT / $MAX_WHATS_NEW characters" +echo "--------------------------------------------" +printf '%s\n' "$WHATS_NEW" +echo "============================================" +echo "" + +if [ "$ASSUME_YES" != "1" ] && [ "$DRY_RUN" != "1" ]; then + # Read the answer from the terminal, not stdin: stdin may have carried the note + if ! { exec 3< /dev/tty; } 2>/dev/null; then + echo "Error: No terminal to confirm on. Re-run with --yes to send without a prompt." + exit 1 + fi + printf "Send this to TestFlight? [y/N] " + read -r REPLY <&3 + exec 3<&- + case "$REPLY" in + [yY]|[yY][eE][sS]) ;; + *) echo "Aborted. Nothing was sent."; exit 1 ;; + esac + echo "" +fi + +# --- Build ---------------------------------------------------------------- +# filter[version] on /v1/builds is the build number (CFBundleVersion), which is +# the epoch Build.sh stamps in. The build shows up here a minute or two after the +# upload, well before processing finishes. + +BUILD_ID="" +ELAPSED=0 +echo "Finding build $BUILD_NUMBER..." +while true; do + if ! asc_request GET "/v1/builds?filter[app]=${APP_ID}&filter[version]=${BUILD_NUMBER}&sort=-uploadedDate&limit=1"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: Build lookup failed (HTTP $HTTP_STATUS)." + api_error + exit 1 + fi + + BUILD_ID=$(printf '%s' "$HTTP_BODY" | jq -r '.data[0].id // empty') + if [ -n "$BUILD_ID" ]; then + BUILD_STATE=$(printf '%s' "$HTTP_BODY" | jq -r '.data[0].attributes.processingState // "UNKNOWN"') + echo " found build $BUILD_ID (processing state: $BUILD_STATE)" + break + fi + + if [ "$ELAPSED" -ge "$TIMEOUT" ]; then + echo "" + echo "Error: Build $BUILD_NUMBER has not appeared in App Store Connect after ${TIMEOUT}s." + echo "The upload may still be in flight. Re-run this script once it lands:" + echo " ./set_whats_new.sh --notes-file --build-number $BUILD_NUMBER" + exit 1 + fi + + echo " not registered yet, waiting (${ELAPSED}s elapsed)..." + sleep "$POLL_INTERVAL" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) +done + +# --- Existing localization ------------------------------------------------ +# POSTing a locale that already exists returns 409, so an existing row is +# PATCHed instead. Xcode itself never creates one, but a re-run of this script +# does. + +if ! asc_request GET "/v1/builds/${BUILD_ID}/betaBuildLocalizations?limit=200"; then + echo "Error: Could not reach App Store Connect." + exit 1 +fi +if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: Localization lookup failed (HTTP $HTTP_STATUS)." + api_error + exit 1 +fi +LOCALIZATION_ID=$(printf '%s' "$HTTP_BODY" | jq -r --arg locale "$LOCALE" '.data[]? | select(.attributes.locale == $locale) | .id' | head -1) + +if [ "$DRY_RUN" = "1" ]; then + echo "" + echo "============================================" + echo "DRY RUN - nothing was sent" + echo "App id: $APP_ID" + echo "Build id: $BUILD_ID (build number $BUILD_NUMBER)" + if [ -n "$LOCALIZATION_ID" ]; then + echo "Would PATCH existing $LOCALE localization $LOCALIZATION_ID" + else + echo "Would POST a new $LOCALE localization" + fi + echo "============================================" + exit 0 +fi + +# --- Write ---------------------------------------------------------------- + +BODY_FILE=$(mktemp -t meshmapper_whatsnew) +trap 'rm -f "$BODY_FILE"' EXIT + +if [ -n "$LOCALIZATION_ID" ]; then + echo "Updating the existing $LOCALE What to Test..." + jq -n --arg id "$LOCALIZATION_ID" --arg whatsNew "$WHATS_NEW" \ + '{data: {id: $id, type: "betaBuildLocalizations", attributes: {whatsNew: $whatsNew}}}' > "$BODY_FILE" + if ! asc_request PATCH "/v1/betaBuildLocalizations/${LOCALIZATION_ID}" "$BODY_FILE"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + EXPECTED_STATUS="200" +else + echo "Setting the $LOCALE What to Test..." + jq -n --arg locale "$LOCALE" --arg whatsNew "$WHATS_NEW" --arg build "$BUILD_ID" \ + '{data: {type: "betaBuildLocalizations", attributes: {locale: $locale, whatsNew: $whatsNew}, relationships: {build: {data: {type: "builds", id: $build}}}}}' > "$BODY_FILE" + if ! asc_request POST "/v1/betaBuildLocalizations" "$BODY_FILE"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + EXPECTED_STATUS="201" +fi + +if [ "$HTTP_STATUS" != "$EXPECTED_STATUS" ]; then + echo "Error: App Store Connect rejected the What to Test text (HTTP $HTTP_STATUS)." + api_error + exit 1 +fi + +echo "" +echo "What to Test set on build $BUILD_NUMBER ($LOCALE)." +echo "Testers see it in TestFlight once the build finishes processing." diff --git a/upload_ios.sh b/upload_ios.sh index d6045a82..3f97a3f9 100755 --- a/upload_ios.sh +++ b/upload_ios.sh @@ -12,6 +12,9 @@ # Fallback if a future Xcode breaks this path (altool is deprecated but still ships): # xcrun altool --upload-app -f -t ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" # +# The TestFlight "What to Test" text is not part of the archive and cannot be set +# here. Run ./set_whats_new.sh after this, once App Store Connect registers the build. +# # Usage: ./upload_ios.sh [path/to/Runner.xcarchive] set -e @@ -60,3 +63,6 @@ xcodebuild -exportArchive \ echo "" echo "Upload accepted. The build appears in TestFlight after Apple-side processing (~5-15 min)." +echo "" +echo "To set the TestFlight \"What to Test\" text for this build:" +echo " ./set_whats_new.sh --notes-file " From 51ea0196e23da35b94bd8209f9336b3a8483d7d1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 12:21:32 -0400 Subject: [PATCH 329/360] Stop hybrid when the ping it waited on never happens Stopping while a ping is in flight parks the disable until the RX window ends, but that window is only armed once a ping actually goes out. A ping rejected by validation clears the in-progress flag and returns without arming one, so the parked disable had nothing to wait for and auto mode kept its timer. A reporter's log has hybrid still scheduling pings four minutes after the stop, with the watchdog calling it every few seconds. The parked disable now carries a 12s backstop, comfortably past the longest real window, so it drains either way. Also drops the GPS accuracy warning from canPing(). The UI calls that on every notify, so a poor fix wrote a line a second: 196 lines in four minutes, a fifth of that reporter's whole log. The send path logs the blocking reason once per real attempt instead. --- lib/services/ping_service.dart | 117 +++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 27 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 88468f34..bbf04f66 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -122,6 +122,18 @@ class PingService { // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; + // Backstop for the flag above. It is drained at exactly one point (the end of + // the RX window) while _pingInProgress is cleared on ~18 paths, so a ping that + // ends without ever arming a window (validation reject, no GPS fix, failed + // send) parks the disable forever and auto mode keeps its timer and keeps + // pinging after the user stopped it (#476). + Timer? _pendingDisableTimeout; + + /// How long to wait for the real window completion before forcing a parked + /// disable. Comfortably past the longest legitimate in-flight ping (the 7s + /// discovery window) so a TX that is already on the air still gets its echoes. + static const Duration _pendingDisableTimeoutDelay = Duration(seconds: 12); + // Auto-ping interval in milliseconds (default 30s, options: 15s, 30s, 60s) // Reference: getSelectedIntervalMs() in wardrive.js int _autoPingIntervalMs = 30000; @@ -337,8 +349,12 @@ class PingService { // If user hasn't moved, old position is still valid // Check GPS accuracy (< 100m) + // Deliberately silent, like the other two validators. This runs on every + // provider notify (ping_controls.dart reads it to enable the buttons), so + // logging here wrote a line per second for as long as GPS stayed poor: 196 + // lines in four minutes, 20% of a reporter's whole debug file (#476). The + // send paths log the blocking reason instead, once per real attempt. if (!_gpsService.isAccuracyAcceptableForPing(position)) { - debugWarn('[PING] GPS accuracy too low, rejecting ping'); return PingValidation.gpsInaccurate; } @@ -570,12 +586,13 @@ class PingService { // this is still true latches as pending and never drains (a skipped // ping arms no RX window). Matches _sendDiscoveryRequest's ordering. _pingInProgress = false; + // Logged here rather than inside canPing(), which the UI calls on + // every notify. Once per real attempt, matching the manual path. + debugLog('[PING] Auto ping blocked by validation: $validation'); // For auto mode, schedule next attempt if distance check failed if (_autoPingEnabled && !_passiveModeEnabled) { if (validation == PingValidation.tooCloseToLastPing) { _skipReason = 'too close'; - debugLog( - '[PING] Auto ping blocked: too close to last ping, scheduling next'); } if (_hybridModeEnabled) { _scheduleNextHybridPing(); @@ -925,30 +942,7 @@ class PingService { // After RX window ends, check if disable was requested during the window if (_pendingDisable) { - debugLog('[PING] Executing pending disable after RX window'); - _pendingDisable = false; - final wasHybrid = _hybridModeEnabled; - final wasTargeted = _targetedModeEnabled; - _autoPingEnabled = false; - _passiveModeEnabled = false; - _hybridModeEnabled = false; - _targetedModeEnabled = false; - _nextPingIsDiscovery = true; - _autoTimer?.cancel(); - _autoTimer = null; - // Clean up discovery infrastructure if hybrid was enabled - if (wasHybrid) { - _stopDiscoveryMode(); - } - // Clean up targeted infrastructure if targeted was enabled - if (wasTargeted) { - _stopTargetedMode(); - } - // Start cooldown immediately - _cooldownTimer.start(_autoPingCooldown.inMilliseconds); - debugLog('[PING] Pending disable complete, cooldown started'); - // Notify AppStateProvider to update its state and cleanup - await onPendingDisableComplete?.call(); + await _executePendingDisable('after RX window'); return; // Don't schedule next auto ping } @@ -1155,6 +1149,7 @@ class PingService { if (_pingInProgress) { debugLog('[PING] Ping in progress, queuing disable for after RX window'); _pendingDisable = true; + _armPendingDisableTimeout(); return true; // Return true to indicate disable was accepted (pending) } @@ -1198,10 +1193,76 @@ class PingService { return true; } + /// Run the disable that [disableAutoPing] parked while a ping was in flight: + /// clear auto mode, cancel the auto timer, start the cooldown, then hand back + /// to AppStateProvider for its half of the teardown. + /// + /// Safe to call from either the RX window completion or the timeout backstop: + /// it clears [_pendingDisable] and the timeout first, and both callers gate on + /// [_pendingDisable] still being set, so it can never run twice. + Future _executePendingDisable(String trigger) async { + debugLog('[PING] Executing pending disable ($trigger)'); + _pendingDisable = false; + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = null; + final wasHybrid = _hybridModeEnabled; + final wasTargeted = _targetedModeEnabled; + _autoPingEnabled = false; + _passiveModeEnabled = false; + _hybridModeEnabled = false; + _targetedModeEnabled = false; + _nextPingIsDiscovery = true; + _autoTimer?.cancel(); + _autoTimer = null; + // Clean up discovery infrastructure if hybrid was enabled + if (wasHybrid) { + _stopDiscoveryMode(); + } + // Clean up targeted infrastructure if targeted was enabled + if (wasTargeted) { + _stopTargetedMode(); + } + // Start cooldown immediately + _cooldownTimer.start(_autoPingCooldown.inMilliseconds); + debugLog('[PING] Pending disable complete, cooldown started'); + // Notify AppStateProvider to update its state and cleanup + await onPendingDisableComplete?.call(); + } + + /// Guarantee a parked disable is drained even when the RX window that was + /// supposed to drain it never arrives. + /// + /// The window is only armed once a ping actually goes out. A ping rejected by + /// validation, or one that fails to send, clears [_pingInProgress] and returns + /// without arming anything, so the queued disable had no completion to wait + /// for and auto mode kept running (#476: hybrid kept scheduling pings for four + /// minutes after the user stopped it). + void _armPendingDisableTimeout() { + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = Timer(_pendingDisableTimeoutDelay, () async { + if (!_pendingDisable) return; // the window drained it, nothing to do + debugWarn( + '[PING] Pending disable never drained after ' + '${_pendingDisableTimeoutDelay.inSeconds}s (no RX window arrived) - forcing it'); + // The ping this was waiting on is gone. Leaving this set would keep the + // ping controls locked out until a restart. + _pingInProgress = false; + try { + await _executePendingDisable('timeout backstop'); + } catch (e) { + // Nothing is awaiting a timer callback, so an escaping error here would + // go unhandled. The flags are already cleared by this point. + debugError('[PING] Forced pending disable failed: $e'); + } + }); + } + /// Force disable auto-ping (ignores cooldown, used for disconnect) Future forceDisableAutoPing() async { debugLog('[PING] Force disabling auto-ping'); _pendingDisable = false; // Clear any pending disable + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = null; _autoTimer?.cancel(); _autoTimer = null; _skipReason = null; @@ -1790,6 +1851,8 @@ class PingService { _rxWindowTimer = null; _autoTimer?.cancel(); _autoTimer = null; + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = null; _stopDiscoveryMode(); _stopTargetedMode(); _wakelockService.dispose(); From ef515d051fc882139251b067dced2a0e25ef4255 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 12:21:37 -0400 Subject: [PATCH 330/360] Pin down the samples lost when there's no cell coverage A dead socket comes back as retryable, same as a server rejection, so it burns a slot on the 5-attempt ladder. Roughly a minute off-grid and the queued pings are past it, filtered out of every future batch, with nothing that resets the count and no reader for failedItems. Both tests fail today and are skipped until the fix lands. --- .../api_queue_offline_retry_test.dart | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 test/services/api_queue_offline_retry_test.dart diff --git a/test/services/api_queue_offline_retry_test.dart b/test/services/api_queue_offline_retry_test.dart new file mode 100644 index 00000000..4d12b7a3 --- /dev/null +++ b/test/services/api_queue_offline_retry_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +/// Regression tests for #437: driving without cell coverage loses samples. +/// +/// A phone with no signal fails the POST at the socket, so the queue can't +/// tell "the server rejected this" from "I couldn't reach the server". Both +/// come back as UploadResult.retryable and both burn a slot on the 5-attempt +/// retry ladder. Once an item hits _maxRetries it is filtered out of every +/// future batch, nothing ever resets the counter, and `failedItems` (the only +/// thing that can still see it) has no callers, so the ping is stranded in +/// Hive for good. +void main() { + /// An ApiService holding a live session whose /wardrive posts all fail the + /// way an out-of-coverage phone fails: the socket never connects. + Future<({ApiService api, List wardrivePosts})> offlineApi() async { + final wardrivePosts = []; + final api = ApiService(client: MockClient((request) async { + if (request.url.path.endsWith('/auth')) { + return http.Response( + json.encode({ + 'success': true, + 'session_id': 'yow-20260824-0001', + 'tx_allowed': true, + 'rx_allowed': true, + 'expires_at': DateTime.now() + .add(const Duration(hours: 1)) + .toUtc() + .toIso8601String(), + }), + 200, + ); + } + wardrivePosts.add(request.url.path); + throw const SocketException('Network is unreachable'); + })); + + await api.requestAuth( + reason: 'connect', + publicKey: 'ab' * 32, + lat: 45.26974, + lon: -75.77746, + accuracyMeters: 5, + ); + + return (api: api, wardrivePosts: wardrivePosts); + } + + Future enqueueRx(ApiQueueService queue) => queue.enqueueRx( + latitude: 45.26974, + longitude: -75.77746, + heardRepeats: '4e(12.25)', + timestamp: 1750000000, + repeaterId: '4e', + externalAntenna: false, + ); + + // Both of these fail today. They describe the behaviour #437 needs, and are + // skipped so the suite stays green until that fix lands. Un-skip with it. + const openBug = 'unfixed: #437'; + + test('an unreachable network must not consume the retry ladder', () async { + final harness = await offlineApi(); + // No init(), so Hive stays closed and the service uses its in-memory + // queue. Same enqueue/upload code paths, no Hive box needed. + final queue = ApiQueueService(apiService: harness.api); + await enqueueRx(queue); + + await queue.flushQueue(); + await queue.flushQueue(); + + expect( + harness.wardrivePosts, hasLength(2), + reason: 'both flushes should have tried the network. If the second was ' + 'skipped, the first failure started the backoff ladder, which means ' + 'a dead socket is being counted as a server rejection', + ); + }, skip: openBug); + + test('a ping is stranded by a 30 second outage', () async { + final harness = await offlineApi(); + final queue = ApiQueueService(apiService: harness.api); + await enqueueRx(queue); + + // Walk the real backoff ladder: 1s, 2s, 4s, 8s, 16s. Real time, because + // isReadyForRetry reads DateTime.now() directly. + for (var i = 0; i < 6; i++) { + await queue.flushQueue(); + await Future.delayed(Duration(seconds: 1 << i)); + } + + expect(queue.queueSize, 1, reason: 'the sample is still held in the queue'); + expect( + queue.failedItems, isEmpty, + reason: 'nothing in the app ever reads failedItems or resets retryCount, ' + 'so an item that lands here is coverage the user will never get', + ); + }, timeout: const Timeout(Duration(minutes: 2)), skip: openBug); +} From 4d692c8af74f6444fdf24a81c243f40d5d1ff1c9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 12:35:51 -0400 Subject: [PATCH 331/360] Explain a flood-disabled region instead of interrupting The one-shot dialog fired on auth and on every zone change, and said little more than the Connection screen already does. Drop it and put the explanation where users go looking for it: why the region turned flood traffic off, that it covers the whole region rather than just here, that it isn't a capacity limit, and that Passive and Trace map a mesh this size well. --- lib/providers/app_state_provider.dart | 20 --------------- lib/screens/connection_screen.dart | 17 +++++++------ lib/screens/main_scaffold.dart | 36 --------------------------- 3 files changed, 10 insertions(+), 63 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 5b2b959a..a4381b49 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1302,17 +1302,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get floodTrafficEnabled => !_apiService.floodDisabled && _preferences.floodTrafficEnabled; - /// One-shot flag: true when the user had flood traffic enabled and the - /// region forced it off on auth/zone-change. UI shows a dialog, then calls - /// [clearFloodDisabledAlert]. - bool _floodDisabledAlertPending = false; - bool get floodDisabledAlertPending => _floodDisabledAlertPending; - void clearFloodDisabledAlert() { - if (!_floodDisabledAlertPending) return; - _floodDisabledAlertPending = false; - notifyListeners(); - } - int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; @@ -3868,7 +3857,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Discovery drop force-enabled by regional admin'); } - final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; final shouldEnableFlood = !_apiService.floodDisabled; if (_preferences.floodTrafficEnabled != shouldEnableFlood) { _preferences = @@ -3877,10 +3865,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? '[CONN] Flood traffic auto-enabled (region permits)' : '[CONN] Flood traffic disabled by regional admin'); } - if (wasFloodEnabledByUser && _apiService.floodDisabled) { - _floodDisabledAlertPending = true; - } - if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); @@ -8343,7 +8327,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(discDropEnabled: true); debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } - final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; final shouldEnableFlood = !_apiService.floodDisabled; if (_preferences.floodTrafficEnabled != shouldEnableFlood) { _preferences = @@ -8352,9 +8335,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? '[ZONE] Flood traffic auto-enabled (new zone permits)' : '[ZONE] Flood traffic disabled by new zone admin'); } - if (wasFloodEnabledByUser && _apiService.floodDisabled) { - _floodDisabledAlertPending = true; - } if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith( autoPingInterval: _apiService.minModeInterval); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 2bdd44e4..2b3a8e51 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -1686,21 +1686,24 @@ class _ConnectionScreenState extends State mainAxisSize: MainAxisSize.min, children: [ Text( - 'Your regional admin has turned off flood traffic in ' - 'this area, so Active and Hybrid modes are unavailable ' - 'here. This is a regional setting, not a full zone.', + 'This region has grown large enough that flood traffic ' + 'started hurting the mesh, so the regional mesh chose ' + 'to turn it off. Active and Hybrid modes are ' + 'unavailable across the whole MeshMapper region you ' + 'are in. It is not a capacity limit.', style: bodyStyle, ), const SizedBox(height: 12), Text( - 'You can still wardrive. Passive Mode and Trace Mode ' - 'work normally, and the app keeps logging everything ' - 'your radio hears.', + 'Passive Mode (discovery requests and RX listening) and ' + 'Trace Mode still work, and they map a mesh this size ' + 'well. The app keeps logging everything your radio ' + 'hears.', style: bodyStyle, ), const SizedBox(height: 12), Text( - 'Contact your regional admin if you have questions.', + 'For more information, talk to your regional admin.', style: bodyStyle, ), ], diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index fcf64037..47127b02 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -30,7 +30,6 @@ class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; bool _hasShownLocationSettingsPrompt = false; - bool _floodDisabledDialogOpen = false; bool _linkPromptDialogOpen = false; bool _signInErrorToastOpen = false; @@ -139,31 +138,6 @@ class _MainScaffoldState extends State { } } - Future _showFloodDisabledDialog() async { - final appState = context.read(); - debugLog('[APP] Showing flood-traffic-disabled-by-region alert'); - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Flood Traffic Disabled'), - content: const Text( - 'Your regional admin has turned off flood traffic in this area, so ' - 'Active and Hybrid modes have been turned off for this session. You ' - 'can still wardrive. Passive Mode and Trace Mode work normally. ' - 'Contact your regional admin if you have questions.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('OK'), - ), - ], - ), - ); - appState.clearFloodDisabledAlert(); - _floodDisabledDialogOpen = false; - } - /// One-tap offer to bind the connected radio to the signed-in account. /// /// Mirrors the flood-disabled alert: a postFrameCallback plus a local @@ -335,16 +309,6 @@ class _MainScaffoldState extends State { }); } - // Listen for flood-traffic-disabled-by-region alert (user had it on, - // region forced it off on auth/zone-change) - if (appState.floodDisabledAlertPending && !_floodDisabledDialogOpen) { - _floodDisabledDialogOpen = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _showFloodDisabledDialog(); - }); - } - // MyMeshMapper account link offer (post-connection, non-fatal). // A pending prompt survives disconnect by design (the provider no-ops // safely if it is answered afterwards), but never RAISE the dialog for a From a8e4b44b80b6b5d19f8fc6ef2e384253ea5ec64a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 12:46:13 -0400 Subject: [PATCH 332/360] Hold pings when there's no signal instead of writing them off A dead socket and a server rejection both came back as retryable, so a failed connection spent a slot on the 5-attempt ladder. About 75 seconds out of coverage and every queued ping was past it: filtered out of each later batch, with nothing that resets the count and no reader for failedItems. They sat in Hive until the box was cleared. A request that never reached the server is now its own result and leaves the retry state alone. The data is fine, we just have nowhere to send it yet, and the flush cadence is pacing enough. A successful upload is also the one honest proof the server is answering, so it puts anything already written off back in the running. That covers the pings people are carrying around today. --- lib/services/api_queue_service.dart | 47 +++++++++++++ lib/services/api_service.dart | 47 ++++++++++++- .../api_queue_offline_retry_test.dart | 69 +++++++++++++++++-- 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index cd307084..0ecbbd2d 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import '../models/api_queue_item.dart'; @@ -614,6 +615,9 @@ class ApiQueueService { _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); + // The network is demonstrably back, so give anything the ladder has + // already written off one more chance. + _reviveFailedItems(); onUploadSuccess?.call(uploadedCount, items); // Fire-and-forget: forward to custom API endpoint customApiService?.forwardPings(pings); @@ -629,6 +633,13 @@ class ApiQueueService { } debugWarn( '[API QUEUE] Discarded ${items.length} items (non-retryable error)'); + } else if (result == UploadResult.unreachable) { + // We never got an answer, so this says nothing about the data. Leave + // retryCount and lastRetryAt alone: spending a retry here meant ~75s + // out of coverage wrote every queued ping off for good (#437). The + // flush cadence is the pacing; there is nothing to back off from. + debugLog( + '[API QUEUE] Upload deferred: ${items.length} items held, no route to server'); } else { // Mark items as retried for (final item in hiveItems) { @@ -716,6 +727,42 @@ class ApiQueueService { } } + /// Put items the retry ladder has written off back in the running. + /// + /// Called after a successful upload, which is the only proof we have that the + /// server is reachable and answering. Without it [_maxRetries] is a one-way + /// door: nothing resets the counter, so a written-off ping sat in Hive + /// forever, never uploaded and never surfaced (#437). + /// + /// Only items past the ladder are touched. Anything still climbing it is + /// mid-backoff for a reason the server gave us, and is left alone. + void _reviveFailedItems() { + final stranded = failedItems; + if (stranded.isEmpty) return; + + for (final item in stranded) { + item.retryCount = 0; + item.lastRetryAt = null; + if (item.isInBox) { + try { + item.save(); + } catch (_) {} + } + } + debugLog('[API QUEUE] Revived ${stranded.length} items the retry ladder ' + 'had written off'); + } + + /// Every item currently held, Hive and memory alike. + /// + /// Exists so tests can set an item's retry state directly instead of spending + /// 31 seconds of real time climbing the backoff ladder to reach it. + @visibleForTesting + List get heldItems => [ + ..._safeRead((box) => box.values.toList(), []), + ..._memoryQueue, + ]; + /// Get failed items (exceeded max retries) List get failedItems { final hiveItems = _safeRead( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index a3d85d9e..6c5dd5e9 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -9,7 +9,41 @@ import '../models/repeater.dart'; import '../utils/debug_logger_io.dart'; /// Result of a batch upload attempt -enum UploadResult { success, retryable, sessionError, nonRetryable } +/// +/// [unreachable] is deliberately separate from [retryable]: the data is fine, +/// we simply never got an answer. The queue must not spend a retry on it, or a +/// drive through a dead zone burns the whole ladder and strands the pings +/// (#437). +enum UploadResult { success, retryable, unreachable, sessionError, nonRetryable } + +/// The request never reached the server: no coverage, DNS failure, connection +/// reset, TLS handshake, or a timeout waiting for the first byte. +/// +/// Distinct from the server answering with a rejection, which is a verdict on +/// the data itself. +class _RequestNeverReachedServer implements Exception { + final Object cause; + const _RequestNeverReachedServer(this.cause); + + @override + String toString() => 'Request never reached the server: $cause'; +} + +/// Classify a thrown request error as "never got an answer" or not. +/// +/// A FormatException is excluded on purpose: the server did answer, just with +/// something unparseable, which is a verdict-shaped failure and keeps its place +/// on the retry ladder. SocketException and HandshakeException live in dart:io, +/// which this file must not import because it is shared with the web build, so +/// they are matched by type name. +bool _requestNeverReachedServer(Object error) { + if (error is FormatException) return false; + if (error is TimeoutException) return true; + if (error is http.ClientException) return true; + final name = error.runtimeType.toString(); + return name.contains('SocketException') || + name.contains('HandshakeException'); +} /// MeshMapper API service /// Handles communication with the MeshMapper backend @@ -546,7 +580,10 @@ class ApiService { /// Matches submitWardriveData() in wardrive.js /// /// @param entries List of wardrive entries (TX/RX) - /// @returns Map with success, expires_at, reason, message + /// @returns Map with success, expires_at, reason, message, or null when the + /// server answered with something unusable + /// @throws when the request never reached the server, so the caller can tell + /// that apart from a rejection and hold the data without spending a retry Future?> submitWardriveData( List> entries) async { if (_sessionId == null) { @@ -611,6 +648,9 @@ class ApiService { } catch (e) { stopwatch.stop(); debugError('[API] POST /wardrive-api.php/wardrive failed: $e'); + // Let uploadBatch tell the two failures apart. Returning null for both + // is what let a dead socket spend a retry (#437). + if (_requestNeverReachedServer(e)) throw _RequestNeverReachedServer(e); return null; } } @@ -1047,6 +1087,9 @@ class ApiService { } return UploadResult.retryable; + } on _RequestNeverReachedServer catch (e) { + debugWarn('[API] Upload batch could not reach the server: ${e.cause}'); + return UploadResult.unreachable; } catch (e) { debugError('[API] Upload batch exception: $e'); return UploadResult.retryable; diff --git a/test/services/api_queue_offline_retry_test.dart b/test/services/api_queue_offline_retry_test.dart index 4d12b7a3..660aa018 100644 --- a/test/services/api_queue_offline_retry_test.dart +++ b/test/services/api_queue_offline_retry_test.dart @@ -61,10 +61,6 @@ void main() { externalAntenna: false, ); - // Both of these fail today. They describe the behaviour #437 needs, and are - // skipped so the suite stays green until that fix lands. Un-skip with it. - const openBug = 'unfixed: #437'; - test('an unreachable network must not consume the retry ladder', () async { final harness = await offlineApi(); // No init(), so Hive stays closed and the service uses its in-memory @@ -81,7 +77,7 @@ void main() { 'skipped, the first failure started the backoff ladder, which means ' 'a dead socket is being counted as a server rejection', ); - }, skip: openBug); + }); test('a ping is stranded by a 30 second outage', () async { final harness = await offlineApi(); @@ -92,7 +88,9 @@ void main() { // isReadyForRetry reads DateTime.now() directly. for (var i = 0; i < 6; i++) { await queue.flushQueue(); - await Future.delayed(Duration(seconds: 1 << i)); + // Wait out the backoff a failed attempt would have armed, so the next + // flush is a real attempt. 1s, 2s, 4s, 8s, 16s is the whole ladder. + if (i < 5) await Future.delayed(Duration(seconds: 1 << i)); } expect(queue.queueSize, 1, reason: 'the sample is still held in the queue'); @@ -101,5 +99,62 @@ void main() { reason: 'nothing in the app ever reads failedItems or resets retryCount, ' 'so an item that lands here is coverage the user will never get', ); - }, timeout: const Timeout(Duration(minutes: 2)), skip: openBug); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('coming back online revives what the ladder wrote off', () async { + // An item can still exhaust the ladder against a server that is reachable + // but keeps rejecting: five 500s in a row. Nothing resets the counter, so + // without a revive it would sit in Hive forever (#437). + var reachable = false; + final api = ApiService(client: MockClient((request) async { + if (request.url.path.endsWith('/auth')) { + return http.Response( + json.encode({ + 'success': true, + 'session_id': 'yow-20260824-0002', + 'tx_allowed': true, + 'rx_allowed': true, + }), + 200, + ); + } + return reachable + ? http.Response(json.encode({'success': true}), 200) + : http.Response('upstream is having a moment', 500); + })); + await api.requestAuth( + reason: 'connect', + publicKey: 'ab' * 32, + lat: 45.26974, + lon: -75.77746, + accuracyMeters: 5, + ); + + final queue = ApiQueueService(apiService: api); + await enqueueRx(queue); + // RX pings sit in the per-repeater buffer until a flush moves them into the + // queue proper, so this first pass is what makes the item visible at all. + await queue.flushQueue(); + + // Strand it. Set the retry state directly rather than spending 31 seconds + // of real time climbing the ladder. + for (final item in queue.heldItems) { + item.retryCount = 5; + item.lastRetryAt = null; + } + expect(queue.failedItems, hasLength(1), + reason: 'precondition: the item is past the ladder'); + + // A second ping arrives and the server is answering again. + reachable = true; + await enqueueRx(queue); + await queue.flushQueue(); + + expect(queue.failedItems, isEmpty, + reason: 'a successful upload proves the server is reachable, so the ' + 'written-off item goes back in the running'); + await queue.flushQueue(); + expect(queue.queueSize, 0, + reason: 'and on the next pass it actually uploads'); + }); } From 4df70ccc9ac33f8f6eededcc20da5691744815af Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 12:52:32 -0400 Subject: [PATCH 333/360] Report the connected radio's power, not the last one's The session is opened at Step 6, inside MeshCoreConnection.connect(), but the new radio's wattage was only applied in _postConnectionSetup, which runs after connect() returns. So the first connect with a different radio authed with the previous radio's power next to the new radio's model string. It looked right from the second connect on, because by then the preference held the corrected value. Power is now derived from the connecting radio before /auth is told about it: the saved override for that radio if there is one, else the model's rating. With neither, the flags are cleared rather than left as they were. An unrecognized radio used to inherit the last one's autoPowerSet, which both reported its wattage and suppressed the prompt asking the user to set one. --- lib/providers/app_state_provider.dart | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a4381b49..37be7697 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2965,6 +2965,58 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Connection // ============================================ + /// Derive the reported power from the radio that is connecting, before /auth + /// is told about it. + /// + /// _postConnectionSetup does this too, but it runs after + /// MeshCoreConnection.connect() returns, and /auth is Step 6 *inside* that + /// call. So on the first connect with a different radio the session opened + /// carrying the previous radio's wattage, alongside the new radio's model + /// string. It looked fixed on the next connect only because the preference + /// had been corrected by then (#426). + /// + /// Precedence matches _postConnectionSetup: the model's rating, then the + /// user's saved override for this radio. With neither, the power is not + /// configured, and saying so is what makes the app ask instead of quietly + /// reporting whatever the last radio used. + void _applyConnectingDevicePower(String? deviceName) { + final model = _meshCoreConnection?.deviceModel; + final overrideKey = _isAnonymousRenamed ? _originalDeviceName : deviceName; + final saved = + overrideKey == null ? null : _devicePowerOverrides[overrideKey]; + + if (saved != null) { + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + debugLog('[MODEL] Reporting saved override for "$overrideKey": ' + '${saved['powerLevel']}W'); + } else if (model != null) { + _preferences = _preferences.copyWith( + powerLevel: model.power, + txPower: model.txPower, + autoPowerSet: true, + powerLevelSet: false, + ); + debugLog( + '[MODEL] Reporting ${model.power}W for ${model.shortName} at auth'); + } else { + // Unrecognized radio with nothing saved. Carrying the previous device's + // flags here would both report its wattage and suppress the prompt that + // asks the user to set one. + _preferences = _preferences.copyWith( + autoPowerSet: false, + powerLevelSet: false, + ); + debugWarn('[MODEL] Unrecognized radio and no saved power, asking the ' + 'user rather than reusing the last one'); + } + notifyListeners(); + } + /// Creates the two-stage auth callback for MeshCoreConnection Step 6. /// Shared by all transport types (BLE, TCP, USB Serial). Future?> Function() _createAuthCallback() { @@ -3050,6 +3102,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } + // The radio is identified by now, so report ITS power, not whatever the + // last one left in preferences (#426). + _applyConnectingDevicePower(deviceName); + // Stage 1: Try existing public_key authentication debugLog( '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); From ebf649ed66e7ead70bdeea39c18b6ff1d075f24b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 24 Aug 2026 13:47:12 -0400 Subject: [PATCH 334/360] Say why the download button won't go instead of just greying it out --- lib/screens/offline_maps_screen.dart | 36 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index f1bf8c8a..66e2cc7c 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -922,11 +922,13 @@ class _DownloadRegionPage extends StatefulWidget { class _DownloadRegionPageState extends State<_DownloadRegionPage> { final _nameController = TextEditingController(); + final _nameFocus = FocusNode(); String _selectedStyle = 'Liberty'; double _minZoom = 6; double _maxZoom = 14; bool _submitting = false; String? _error; + String? _nameError; // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); @@ -967,6 +969,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { @override void dispose() { _nameController.dispose(); + _nameFocus.dispose(); super.dispose(); } @@ -985,10 +988,10 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { return LatLngBounds(southwest: sw, northeast: ne); } - bool get _canSubmit => - _nameController.text.trim().isNotEmpty && - _selectedBounds != null && - !_submitting; + // Stays live whenever a download isn't already starting. Gating this on a + // name/bounds being present left the button greyed out with nothing saying + // why, so users read it as broken; `_startDownload` names what's missing. + bool get _canSubmit => !_submitting; int get _estimatedTiles { final bounds = _selectedBounds; @@ -1152,15 +1155,23 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { // Region name TextField( controller: _nameController, + focusNode: _nameFocus, decoration: InputDecoration( - labelText: 'Area Name', + labelText: 'Area Name *', hintText: 'e.g. Downtown Vancouver', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8)), isDense: true, prefixIcon: const Icon(Icons.label_outline, size: 20), + errorText: _nameError, ), - onChanged: (_) => setState(() {}), + onChanged: (value) { + setState(() { + if (_nameError != null && value.trim().isNotEmpty) { + _nameError = null; + } + }); + }, ), const SizedBox(height: 12), @@ -1592,11 +1603,20 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { } Future _startDownload() async { + // Both of these used to be silent early returns behind a disabled button, + // so a user with an unnamed area had no way to tell what was wrong. final bounds = _selectedBounds; - if (bounds == null) return; + if (bounds == null) { + setState(() => _error = 'Tap the map to place an area first.'); + return; + } final name = _nameController.text.trim(); - if (name.isEmpty) return; + if (name.isEmpty) { + setState(() => _nameError = 'Enter a name for this area'); + _nameFocus.requestFocus(); + return; + } final service = context.read(); final styleUrl = _downloadStyles[_selectedStyle]!; From e16f14c7e5077035b7515aea587e0bfe490d788d Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 24 Aug 2026 20:44:53 -0700 Subject: [PATCH 335/360] Harden Apple external surfaces and Siri responses --- DEVELOPMENT.md | 68 +++ ios/ExportOptionsUpload.plist | 4 +- .../MeshMapperReadIntents.swift | 19 +- ios/Runner/AppDelegate.swift | 11 +- ...ConnectLastMeshMapperCompanionIntent.swift | 5 +- .../MeshMapperMutationAppShortcuts.swift | 11 + .../AppIntents/MeshMapperSessionMode.swift | 15 +- .../AppIntents/SendManualPingIntent.swift | 5 +- .../StartMeshMapperSessionIntent.swift | 6 +- .../StopMeshMapperSessionIntent.swift | 21 +- ios/RunnerTests/AppIntentLogicTests.swift | 6 +- .../AppIntents/HeardRepeaterEntity.swift | 26 +- .../AppIntents/MeshMapperSiriSnapshot.swift | 10 + .../MeshMapperSiriSnapshotStore.swift | 13 +- ios/Shared/AppIntents/RepeaterEntity.swift | 20 +- lib/providers/app_state_provider.dart | 293 ++++++++--- .../app_intent_bridge_service.dart | 142 +++--- .../last_companion_connection.dart | 10 +- .../app_intents/siri_snapshot_builder.dart | 162 +++--- .../external_command_models.dart | 200 +++++++- .../external_session_commands.dart | 138 ++++- .../external_surface_color.dart | 32 ++ .../external_surface_publisher.dart | 352 +++++++++++++ .../geo/external_surface_geo_builder.dart | 471 ++++++++++++++++++ .../geo/external_surface_geo_models.dart | 182 +++++++ .../live_activity/live_activity_service.dart | 184 ++----- lib/services/watch/watch_bridge_service.dart | 213 +++----- lib/services/watch/watch_color.dart | 30 +- lib/services/watch/watch_geo_builder.dart | 461 +---------------- lib/services/watch/watch_models.dart | 315 +++--------- .../app_intent_bridge_service_test.dart | 119 ++++- .../last_companion_connection_test.dart | 23 +- .../siri_snapshot_builder_test.dart | 62 +++ .../external_session_commands_test.dart | 171 ++++++- .../watch/watch_wire_contract_test.dart | 120 ++--- 35 files changed, 2515 insertions(+), 1405 deletions(-) create mode 100644 lib/services/external_surfaces/external_surface_color.dart create mode 100644 lib/services/external_surfaces/external_surface_publisher.dart create mode 100644 lib/services/external_surfaces/geo/external_surface_geo_builder.dart create mode 100644 lib/services/external_surfaces/geo/external_surface_geo_models.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 27bb2a4a..5023e179 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -76,6 +76,13 @@ MESHMAPPER_API_KEY= ./Build.sh ./upload_ios.sh ``` +The upload export uses explicit App Store profiles from +`ios/ExportOptionsUpload.plist`. The Runner profile must include the App Group +entitlement, and the App Intents extension needs its own profile with the same +group. When either target's capabilities change, regenerate and reinstall the +named profiles before exporting; an existing profile is not automatically +updated by adding the developer-portal capability. + ### Debug Logging - Web: Add `?debug=1` to URL to enable debug logging in browser console - Mobile: Debug logging enabled in debug builds via `kDebugMode`; disabled in release builds @@ -709,6 +716,67 @@ or `"unsupported"`. `false` means *not right now* — authorization is off, or can never show one, and Dart stops asking for the rest of the process rather than running a guaranteed-fail retry loop all session. +**App Intents, Siri, and future Apple surfaces.** Mutation intents live in the +Runner process because session and connection changes must pass through the +same phone-owned admission path as the watch. Read-only intents use a separate +extension and the bounded App Group snapshot in `ios/Shared/AppIntents/`; they +must not launch Flutter. Despite its historical `Siri` type names and +`siri-snapshot.json` filename, that Foundation-only snapshot is the reusable +low-frequency contract for future native glance surfaces. + +Keep future targets separated by lifecycle: + +- A widget may read the App Group snapshot directly and add only optional, + bounded fields to wire version 1. A meaning change or removal requires a + coordinated version bump. +- The last valid snapshot deliberately survives Runner termination. Read + intents qualify it by age; process teardown is not proof that a background + session stopped, and `dispose()` is not a reliable iOS lifecycle callback. +- Adopt `IndexedEntity` only with an explicit owner that calls + `indexAppEntities` and removes stale entries. Conformance alone does not put + repeaters in Spotlight. +- A CarPlay map scene needs its own native scene/target, entitlement and + foreground lifecycle. It may bootstrap from the shared snapshot, but live + location/map updates need a dedicated bridge rather than polling the Siri + file or importing `SiriIntentCoordinator`. +- Every added target gets its own bundle ID, App Group entitlement and explicit + App Store provisioning-profile entry. Do not put target-specific frameworks + or lifecycle into the shared snapshot model. +- Native controls express intent; Dart remains the owner of connection, + session and radio-policy admission. + +The snapshot contains at most 64 recent observations and 64 repeaters. Recent +observations are ranked and truncated before catalogue identity resolution, so +large histories do not multiply the resolution work. A cheap scalar/revision +preflight key suppresses rebuilds when provider notifications do not change +the native projection. Preserve both bounds and the preflight path when adding +fields for another Apple surface. + +The built-in voice phrases are refreshed at app launch. After installing an +update, open MeshMapper once, then use any of these forms (the app name is part +of every registered phrase): + +- `Siri, reconnect MeshMapper` or `Siri, connect MeshMapper to the last device`. +- `Siri, start MeshMapper` defaults to Passive Discovery. +- `Siri, start a Passive Discovery session in MeshMapper`. +- `Siri, start Active mode in MeshMapper`. +- `Siri, start Hybrid mapping with MeshMapper`. +- `Siri, stop MeshMapper`. +- `Siri, what is MeshMapper doing?` or `Siri, get MeshMapper status`. +- `Siri, what has MeshMapper heard?` or `Siri, recent repeaters in MeshMapper`. +- `Siri, find a repeater in MeshMapper` (Siri asks which repeater when needed). + +Mutation intents return their completion message both as Siri dialog and as a +text output. A user-created Shortcut can pass that Result to a `Speak Text` +action when explicit audio is required. Direct Siri invocations normally speak +the dialog, but iOS still honors the system Siri Responses setting; select +Prefer Spoken Responses when voice feedback is required even in Silent mode. + +Connect and Start require device authentication and may bring the app forward. +Connect reuses the last remembered BLE/TCP companion; USB still requires an +in-app selection. Starting does not silently change companions or reconnect — +ask to connect first when MeshMapper is disconnected. + ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` - RX Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (write to device) diff --git a/ios/ExportOptionsUpload.plist b/ios/ExportOptionsUpload.plist index c9e0de24..2c453464 100644 --- a/ios/ExportOptionsUpload.plist +++ b/ios/ExportOptionsUpload.plist @@ -8,7 +8,7 @@ DQC6TNKG5P signingStyle @@ -23,6 +23,8 @@ MeshMapper LiveActivity AppStore net.meshmapper.app.watchkitapp MeshMapper Watch AppStore + net.meshmapper.app.appintents + MeshMapper AppIntents AppStore stripSwiftSymbols diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift index 6c75bb1b..a78569b5 100644 --- a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -101,8 +101,11 @@ struct GetMeshMapperStatusIntent: AppIntent { } private static func relativeAge(_ age: TimeInterval) -> String { - if age < 60 { return "\(Int(age)) seconds ago" } - return "\(Int(age / 60)) minutes ago" + let seconds = max(Int(age), 0) + if seconds < 60 { return "\(seconds) seconds ago" } + if seconds < 60 * 60 { return "\(seconds / 60) minutes ago" } + if seconds < 24 * 60 * 60 { return "\(seconds / (60 * 60)) hours ago" } + return "\(seconds / (24 * 60 * 60)) days ago" } } @@ -123,7 +126,7 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { @Parameter(title: "Sort By", default: .mostRecent) var sort: RepeaterSort - @Parameter(title: "Maximum Results", default: 3) + @Parameter(title: "Maximum Results", default: 3, inclusiveRange: (1, 5)) var limit: Int func perform() async throws -> some IntentResult & ReturnsValue<[HeardRepeaterEntity]> & ProvidesDialog { @@ -150,10 +153,16 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { } } + // The parameter range guides Siri/Shortcuts, while the clamp preserves + // safety for an older saved Shortcut carrying an out-of-range value. let requestedLimit = min(max(limit, 1), 5) let selected = Array(observations.prefix(requestedLimit)) - let entities = selected.enumerated().map { - HeardRepeaterEntity(observation: $0.element, index: $0.offset) + let repeaterById = Dictionary( + snapshot.repeaters.map { ($0.id, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + let entities = selected.map { + HeardRepeaterEntity(observation: $0, repeaterById: repeaterById) } guard !selected.isEmpty else { return .result( diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 81bcdced..c40e78d0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,3 +1,4 @@ +import AppIntents import Flutter import MapLibre import UIKit @@ -84,6 +85,13 @@ class IOSMapOfflineBridge { GeneratedPluginRegistrant.register(with: self) + // App Shortcut metadata is extracted at build time, but asking the system + // to refresh parameters on launch makes newly added Siri phrases and enum + // synonyms available promptly after an app update. + if #available(iOS 26.0, *) { + MeshMapperMutationAppShortcuts.updateAppShortcutParameters() + } + // Register background service SwiftFlutterBackgroundServicePlugin.taskIdentifier = "net.meshmapper.app.background" @@ -167,9 +175,6 @@ class IOSMapOfflineBridge { details: nil )) } - case "clearSnapshot": - self.siriSnapshotStore.clear() - result(nil) default: result(FlutterMethodNotImplemented) } diff --git a/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift b/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift index 72945913..461843ca 100644 --- a/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift +++ b/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift @@ -12,11 +12,12 @@ struct ConnectLastMeshMapperCompanionIntent: AppIntent { static var supportedModes: IntentModes { .foreground(.dynamic) } @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { let result = try await SiriIntentCoordinator.shared.execute( SiriCommand(kind: .connectLastCompanion) ) - return .result(dialog: "\(result.message ?? Self.fallback(for: result))") + let message = result.message ?? Self.fallback(for: result) + return .result(value: message, dialog: "\(message)") } private static func fallback(for result: SiriCommandResult) -> String { diff --git a/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift b/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift index 4ef8f99b..f284c12c 100644 --- a/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift +++ b/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift @@ -8,6 +8,10 @@ struct MeshMapperMutationAppShortcuts: AppShortcutsProvider { phrases: [ "Connect \(.applicationName) to its last companion", "Reconnect \(.applicationName)", + "Connect \(.applicationName)", + "Connect \(.applicationName) to the last device", + "Connect to the last device in \(.applicationName)", + "Reconnect the last device in \(.applicationName)", ], shortTitle: "Connect Companion", systemImageName: "link" @@ -18,6 +22,11 @@ struct MeshMapperMutationAppShortcuts: AppShortcutsProvider { "Start \(.applicationName)", "Start a session in \(.applicationName)", "Start mapping with \(.applicationName)", + "Start \(\.$mode) in \(.applicationName)", + "Start \(\.$mode) mode in \(.applicationName)", + "Start a \(\.$mode) session in \(.applicationName)", + "Start \(.applicationName) in \(\.$mode) mode", + "Start \(\.$mode) mapping with \(.applicationName)", ], shortTitle: "Start Session", systemImageName: "antenna.radiowaves.left.and.right" @@ -27,6 +36,8 @@ struct MeshMapperMutationAppShortcuts: AppShortcutsProvider { phrases: [ "Stop \(.applicationName)", "Stop the session in \(.applicationName)", + "Stop mapping with \(.applicationName)", + "End the session in \(.applicationName)", ], shortTitle: "Stop Session", systemImageName: "stop.fill" diff --git a/ios/Runner/AppIntents/MeshMapperSessionMode.swift b/ios/Runner/AppIntents/MeshMapperSessionMode.swift index a94fa960..e25270c8 100644 --- a/ios/Runner/AppIntents/MeshMapperSessionMode.swift +++ b/ios/Runner/AppIntents/MeshMapperSessionMode.swift @@ -10,9 +10,18 @@ enum MeshMapperSessionMode: String, AppEnum { name: "MeshMapper Mode" ) static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ - .passive: "Passive Discovery", - .active: "Active", - .hybrid: "Hybrid", + .passive: DisplayRepresentation( + title: "Passive Discovery", + synonyms: ["Passive", "Listen Only", "Receive Only"] + ), + .active: DisplayRepresentation( + title: "Active", + synonyms: ["Active Mapping"] + ), + .hybrid: DisplayRepresentation( + title: "Hybrid", + synonyms: ["Hybrid Mapping"] + ), ] var displayName: String { diff --git a/ios/Runner/AppIntents/SendManualPingIntent.swift b/ios/Runner/AppIntents/SendManualPingIntent.swift index 01a93d59..34ae3e09 100644 --- a/ios/Runner/AppIntents/SendManualPingIntent.swift +++ b/ios/Runner/AppIntents/SendManualPingIntent.swift @@ -9,7 +9,7 @@ struct SendManualPingIntent: AppIntent { static var supportedModes: IntentModes { .foreground(.dynamic) } @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { try await requestConfirmation( conditions: [], actionName: .send, @@ -18,6 +18,7 @@ struct SendManualPingIntent: AppIntent { let result = try await SiriIntentCoordinator.shared.execute( SiriCommand(kind: .manualPing) ) - return .result(dialog: "\(result.message ?? "MeshMapper couldn't send a ping.")") + let message = result.message ?? "MeshMapper couldn't send a ping." + return .result(value: message, dialog: "\(message)") } } diff --git a/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift index 38f90d2f..080ba1b3 100644 --- a/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift +++ b/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift @@ -15,12 +15,14 @@ struct StartMeshMapperSessionIntent: AppIntent { var mode: MeshMapperSessionMode @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { let result = try await SiriIntentCoordinator.shared.execute( SiriCommand(kind: .startSession, mode: mode.rawValue) ) + let message = result.message ?? Self.fallback(for: result, mode: mode) return .result( - dialog: "\(result.message ?? Self.fallback(for: result, mode: mode))" + value: message, + dialog: "\(message)" ) } diff --git a/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift index 52d5dbe5..c5e313eb 100644 --- a/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift +++ b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift @@ -9,15 +9,22 @@ struct StopMeshMapperSessionIntent: AppIntent { } @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { - guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read(), - snapshot.session.active || snapshot.session.starting - else { - return .result(dialog: "MeshMapper is already stopped.") + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + // The snapshot is an optimization, never authority for the safe-direction + // Stop command. A missing, stale, corrupt, or newer snapshot must not keep + // a live radio session running. Bind to an ID only when the cache says a + // session exists; otherwise let Dart resolve the current session. + let snapshot = try? MeshMapperSiriSnapshotStore.shared.read() + let sessionId: String? + if let snapshot, snapshot.session.active || snapshot.session.starting { + sessionId = snapshot.session.id + } else { + sessionId = nil } let result = try await SiriIntentCoordinator.shared.execute( - SiriCommand(kind: .stopSession, sessionId: snapshot.session.id) + SiriCommand(kind: .stopSession, sessionId: sessionId) ) - return .result(dialog: "\(result.message ?? "MeshMapper couldn't stop.")") + let message = result.message ?? "MeshMapper couldn't stop." + return .result(value: message, dialog: "\(message)") } } diff --git a/ios/RunnerTests/AppIntentLogicTests.swift b/ios/RunnerTests/AppIntentLogicTests.swift index dafc67ce..d026faa1 100644 --- a/ios/RunnerTests/AppIntentLogicTests.swift +++ b/ios/RunnerTests/AppIntentLogicTests.swift @@ -55,7 +55,7 @@ final class AppIntentLogicTests: XCTestCase { "displayHexId": "A1B2C3D4", "name": "Ridge", "observedAtMs": 1787529590000, - "kind": "rx", + "kind": "passiveRx", "direct": true, "hopCount": 0, "snr": 7.5, @@ -88,6 +88,10 @@ final class AppIntentLogicTests: XCTestCase { XCTAssertEqual(snapshot.session.id, "session-1") XCTAssertEqual(snapshot.recentHeard.first?.entityId, "repeater-1") XCTAssertEqual(snapshot.recentHeard.first?.repeaterLat, 37.1) + XCTAssertEqual( + snapshot.recentHeard.first?.stableEntityIdentifier, + "repeater-1|1787529590000|passiveRx|direct|0" + ) XCTAssertFalse(json.contains("userLat")) XCTAssertFalse(json.contains("userLon")) } diff --git a/ios/Shared/AppIntents/HeardRepeaterEntity.swift b/ios/Shared/AppIntents/HeardRepeaterEntity.swift index 878d881c..be4e28ba 100644 --- a/ios/Shared/AppIntents/HeardRepeaterEntity.swift +++ b/ios/Shared/AppIntents/HeardRepeaterEntity.swift @@ -18,11 +18,15 @@ struct HeardRepeaterEntity: AppEntity { let kind: String let direct: Bool - init(observation: MeshMapperSiriObservation, index: Int) { - id = "\(observation.entityId ?? observation.displayHexId)|\(observation.observedAtMs)|\(observation.kind)|\(index)" + init( + observation: MeshMapperSiriObservation, + repeaterById: [String: MeshMapperSiriRepeater] + ) { + // Derived only from observation content so sorting, filtering, or limiting + // cannot change the ID a saved Shortcut later asks EntityQuery to resolve. + id = observation.stableEntityIdentifier if let entityId = observation.entityId, - let snapshot = try? MeshMapperSiriSnapshotStore.shared.read(), - let repeaterSnapshot = snapshot.repeaters.first(where: { $0.id == entityId }) { + let repeaterSnapshot = repeaterById[entityId] { repeater = RepeaterEntity(snapshot: repeaterSnapshot) } else { repeater = nil @@ -57,9 +61,15 @@ struct HeardRepeaterEntityQuery: EntityQuery { } private func observations() throws -> [HeardRepeaterEntity] { - try MeshMapperSiriSnapshotStore.shared.read()? - .recentHeard - .enumerated() - .map { HeardRepeaterEntity(observation: $0.element, index: $0.offset) } ?? [] + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read() else { + return [] + } + let repeaterById = Dictionary( + snapshot.repeaters.map { ($0.id, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + return snapshot.recentHeard.map { + HeardRepeaterEntity(observation: $0, repeaterById: repeaterById) + } } } diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift index 9d6aea76..f875f09c 100644 --- a/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift @@ -67,6 +67,16 @@ struct MeshMapperSiriObservation: Codable, Sendable { var observedAt: Date { Date(timeIntervalSince1970: Double(observedAtMs) / 1_000) } + + var stableEntityIdentifier: String { + [ + entityId ?? displayHexId, + String(observedAtMs), + kind, + direct ? "direct" : "routed", + String(hopCount), + ].joined(separator: "|") + } } struct MeshMapperSiriRepeater: Codable, Sendable { diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift index a1410445..cf752afe 100644 --- a/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift @@ -22,6 +22,13 @@ final class MeshMapperSiriSnapshotStore { static let appGroupIdentifier = "group.net.meshmapper.app.shared" static let supportedVersion = 1 + // The historical Siri name and filename are a v1 wire-compatibility detail, + // not an ownership boundary. This is deliberately a Foundation-only, + // low-frequency projection of phone-owned state so future native surfaces + // such as widgets or a CarPlay scene can share it without importing Flutter + // or routing reads through SiriIntentCoordinator. Additive optional fields + // stay at version 1; bump only for a meaning change or removal. + private let fileManager: FileManager private let decoder = JSONDecoder() private let encoder = JSONEncoder() @@ -36,6 +43,8 @@ final class MeshMapperSiriSnapshotStore { .containerURL( forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier )? + // Keep the installed v1 filename even if the Swift types are generalized + // later; changing it would strand an extension during an app update. .appendingPathComponent("siri-snapshot.json", isDirectory: false) } @@ -77,8 +86,4 @@ final class MeshMapperSiriSnapshotStore { return snapshot } - func clear() { - guard let snapshotURL else { return } - try? fileManager.removeItem(at: snapshotURL) - } } diff --git a/ios/Shared/AppIntents/RepeaterEntity.swift b/ios/Shared/AppIntents/RepeaterEntity.swift index 1bab0ab3..d2a2f6ab 100644 --- a/ios/Shared/AppIntents/RepeaterEntity.swift +++ b/ios/Shared/AppIntents/RepeaterEntity.swift @@ -1,9 +1,8 @@ import AppIntents -import CoreSpotlight import Foundation @available(iOS 26.0, *) -struct RepeaterEntity: AppEntity, IndexedEntity { +struct RepeaterEntity: AppEntity { static var typeDisplayRepresentation = TypeDisplayRepresentation( name: "MeshMapper Repeater", numericFormat: "\(placeholder: .int) repeaters" @@ -39,19 +38,6 @@ struct RepeaterEntity: AppEntity, IndexedEntity { ) } - var attributeSet: CSSearchableItemAttributeSet { - let attributes = defaultAttributeSet - attributes.title = name - attributes.contentDescription = [zoneCode, hexId] - .compactMap { $0 } - .joined(separator: " · ") - attributes.keywords = [name, hexId, zoneCode].compactMap { $0 } - if let latitude, let longitude { - attributes.latitude = latitude as NSNumber - attributes.longitude = longitude as NSNumber - } - return attributes - } } @available(iOS 26.0, *) @@ -63,7 +49,9 @@ struct RepeaterEntityQuery: EntityStringQuery { func entities(matching string: String) async throws -> [RepeaterEntity] { let needle = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard !needle.isEmpty else { return try catalog() } + guard !needle.isEmpty else { + return Array(try catalog().filter(\.isActive).prefix(20)) + } return try catalog().filter { $0.name.localizedCaseInsensitiveContains(needle) || $0.hexId.localizedCaseInsensitiveContains(needle) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index aa31bee0..0d31f414 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -54,10 +54,10 @@ import '../services/app_intents/siri_snapshot_builder.dart'; import '../services/app_intents/siri_snapshot_models.dart'; import '../services/external_commands/external_command_models.dart'; import '../services/external_commands/external_session_commands.dart'; +import '../services/external_surfaces/geo/external_surface_geo_builder.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 '../services/portal_account_service.dart'; @@ -189,7 +189,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// 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; + ExternalCommandReason? _lastSessionCheckFailureReason; bool _liveActivitySessionActive = false; bool _liveActivityManualSession = false; String? _liveActivitySessionId; @@ -286,6 +286,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _rxLogEntries = []; final List _discLogEntries = []; final List _traceLogEntries = []; + int _siriObservationRevision = 0; // Top repeaters overlay — updated live on each ping event List<({String repeaterId, double snr, OverlayPingType type})> @@ -533,6 +534,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Repeater markers state List _repeaters = []; + int _siriRepeaterCatalogRevision = 0; bool _repeatersLoaded = false; String? _repeatersLoadedForIata; @@ -1411,7 +1413,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void _scheduleLiveActivitySync({bool immediate = false}) { if (_isDisposed) return; // Siri is local App Group state and must not depend on Watch availability. - _appIntentBridge.schedule(_buildSiriSnapshotMap, immediate: immediate); + _appIntentBridge.schedule( + _buildSiriSnapshotMap, + preflightKeyBuilder: _buildSiriSnapshotPreflightKey, + immediate: immediate, + ); // The watch mirrors state even with no session running — otherwise you // could never start one from the wrist. _scheduleWatchSync(immediate: immediate); @@ -1576,7 +1582,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // taken from the first row. The RX slot's hash can be a different width // than the top rows', so a single-length index silently dropped the odd // row's name and distance while every other row resolved normally. - final repeaterByHex = WatchGeoBuilder.resolveOverlayRepeaters( + final repeaterByHex = ExternalSurfaceGeoBuilder.resolveOverlayRepeaters( repeaters: _repeaters, displayIds: [ ...top.map((row) => row.repeaterId), @@ -1584,7 +1590,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ], identities: _overlayIdentityById, ); - final heard = WatchGeoBuilder.buildHeard( + final heard = ExternalSurfaceGeoBuilder.buildHeard( top: top, rxSlot: rxSlot, repeaterByHex: repeaterByHex, @@ -1614,13 +1620,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchGeo( you: position, - pings: WatchGeoBuilder.buildPings( + pings: ExternalSurfaceGeoBuilder.buildPings( txPings: _txPings, rxPings: _rxPings, discLogEntries: _discLogEntries, traceLogEntries: _traceLogEntries, ), - repeaters: WatchGeoBuilder.buildRepeaters( + repeaters: ExternalSurfaceGeoBuilder.buildRepeaters( repeaters: _repeaters, heardThisCycle: heardIds, lat: ranking?.lat, @@ -1628,7 +1634,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), heard: heard, linkedRepeaterIds: [ - ...WatchGeoBuilder.resolveUniqueHexPrefixes( + ...ExternalSurfaceGeoBuilder.resolveUniqueHexPrefixes( repeaters: _repeaters, prefixes: heardIds, ).keys, @@ -1705,7 +1711,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return resolved; } - ({bool allowed, String? reason}) get _manualPingAvailability { + ({bool allowed, ExternalCommandReason? 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. @@ -1745,29 +1751,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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; + final ExternalCommandReason? reason; if (allowed) { reason = null; } else if (!isConnected) { - reason = 'Not connected'; + reason = ExternalCommandReason.notConnected; } else if (!hasGpsLock) { - reason = 'No GPS fix'; + reason = const ExternalCommandReason.other('No GPS fix'); } else if (txBlockedByOffline) { - reason = 'Offline Mode'; + reason = ExternalCommandReason.offlineMode; } else if (txNotAllowed) { - reason = 'Passive Only'; + reason = ExternalCommandReason.passiveOnly; } else if (!floodAllowed) { - reason = 'Flood Traffic Off'; + reason = ExternalCommandReason.floodTrafficOff; } else if (manualPingValidation == PingValidation.manualCooldownActive || cooldownActive || manualCooldownActive || rxWindowActive || discoveryWindowActive) { - reason = 'Cooling down'; + reason = ExternalCommandReason.coolingDown; } else if (!canPingManual) { - reason = manualPingValidation.message; + reason = _externalReasonForPingValidation(manualPingValidation); } else { - reason = 'Another operation is in progress'; + reason = ExternalCommandReason.anotherOperationInProgress; } return (allowed: allowed, reason: reason); @@ -1836,14 +1842,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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), + ? passiveStart.reason?.compactText + : (manualPing.reason?.code == ExternalCommandReasonCode.coolingDown + ? null + : manualPing.reason?.compactText), ); } /// 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( + WatchColor? _resolveWatchPingColor() => + ExternalSurfaceGeoBuilder.latestPingColor( txPings: _txPings, rxPings: _rxPings, discLogEntries: _discLogEntries, @@ -1910,10 +1919,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? 'Already running' : _autoPingStarting ? 'Already starting' - : passive.reason, + : passive.reason?.compactText, canStop: _autoPingEnabled, canManualPing: manualPing.allowed, - manualPingBlockedReason: manualPing.reason, + manualPingBlockedReason: manualPing.reason?.compactText, manualCooldownEndsAt: _manualPingCooldownTimer.isRunning ? _manualPingCooldownTimer.endTime : null, @@ -1923,6 +1932,56 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ).toMap(); } + /// Cheap semantic projection used to avoid rebuilding the bounded App Group + /// snapshot on every provider notification. Log/catalog revisions cover the + /// expensive collections; the remaining values mirror every scalar emitted + /// by [_buildSiriSnapshotMap]. A five-minute bucket lets time-based repeater + /// staleness advance without restoring the old continuous rebuild loop. + String _buildSiriSnapshotPreflightKey() { + final phase = _resolveWatchPhase(); + final passive = sessionStartAvailability(AutoMode.passive); + final active = sessionStartAvailability(AutoMode.active); + final hybrid = sessionStartAvailability(AutoMode.hybrid); + final manualPing = _manualPingAvailability; + final fiveMinuteBucket = DateTime.now().millisecondsSinceEpoch ~/ + const Duration(minutes: 5).inMilliseconds; + return jsonEncode([ + _siriObservationRevision, + _siriRepeaterCatalogRevision, + _hopBytes, + fiveMinuteBucket, + isConnected, + displayDeviceName, + _currentBatteryPercent, + _gpsStatus.name, + _liveActivitySessionId, + _autoPingEnabled, + _autoPingStarting, + _autoMode.name, + phase.phase.name, + phase.title, + phase.detail, + phase.endsAt?.millisecondsSinceEpoch, + zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + _pingStats.txCount, + _pingStats.rxCount, + _pingStats.discCount, + _pingStats.traceCount, + _queueSize, + passive.allowed, + passive.reason?.compactText, + active.allowed, + active.reason?.compactText, + hybrid.allowed, + hybrid.reason?.compactText, + manualPing.allowed, + manualPing.reason?.compactText, + _manualPingCooldownTimer.isRunning + ? _manualPingCooldownTimer.endTime?.millisecondsSinceEpoch + : null, + ]); + } + /// Whether [mode] may begin now, with the reason the wearer should see. /// /// This is the sole provider-side start gate. One caller publishes wrist @@ -1951,8 +2010,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { txBlockedByOffline: offlineMode && isConnected, txNotAllowed: isConnected && !txAllowed, floodTrafficEnabled: floodTrafficEnabled, - transmitValidationReason: - validation == PingValidation.valid ? null : validation.message, + transmitValidationReason: validation == PingValidation.valid + ? null + : _externalReasonForPingValidation(validation), ); } @@ -1963,7 +2023,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isDisposed) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'App closing', + reason: ExternalCommandReason.appClosing, ); } @@ -1982,7 +2042,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (mode == null) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'Unsupported start mode', + reason: ExternalCommandReason.other( + 'Unsupported start mode', + ), ); } final availability = @@ -1990,7 +2052,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!availability.allowed) { return ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: availability.reason ?? 'Could not start', + reason: availability.reason ?? ExternalCommandReason.couldNotStart, mode: mode, ); } @@ -2004,7 +2066,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!availability.allowed) { return ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: availability.reason ?? 'Ping unavailable', + reason: availability.reason ?? + const ExternalCommandReason.other('Ping unavailable'), ); } return transition; @@ -2044,7 +2107,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return ExternalCommandCompletion( success: true, disposition: ExternalCommandDisposition.admitted, - message: 'MeshMapper started in ${mode.displayName} mode.', + message: ExternalCommandReason.other( + 'MeshMapper started in ${mode.displayName} mode.', + ), sessionId: _liveActivitySessionId, mode: mode, ); @@ -2055,15 +2120,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return const ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: 'Could not stop', + message: ExternalCommandReason.couldNotStop, ); } final stopping = _autoPingEnabled || isPendingDisable; return ExternalCommandCompletion( success: true, disposition: ExternalCommandDisposition.admitted, - message: - stopping ? 'MeshMapper is stopping.' : 'MeshMapper stopped.', + message: ExternalCommandReason.other( + stopping ? 'MeshMapper is stopping.' : 'MeshMapper stopped.', + ), sessionId: stopping ? _liveActivitySessionId : null, ); @@ -2073,13 +2139,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: _lastSessionCheckFailureReason ?? 'Ping failed', + message: _lastSessionCheckFailureReason ?? + ExternalCommandReason.pingFailed, ); } return const ExternalCommandCompletion( success: true, disposition: ExternalCommandDisposition.admitted, - message: 'MeshMapper sent a manual ping.', + message: ExternalCommandReason.other( + 'MeshMapper sent a manual ping.', + ), ); } } catch (error) { @@ -2090,8 +2159,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { message: command.kind == ExternalSessionCommandKind.startSession ? _externalStartFailureReason(admission.mode) : command.kind == ExternalSessionCommandKind.stopSession - ? 'Could not stop' - : (_lastSessionCheckFailureReason ?? 'Ping failed'), + ? ExternalCommandReason.couldNotStop + : _lastSessionCheckFailureReason ?? + ExternalCommandReason.pingFailed, mode: admission.mode, ); } @@ -2108,22 +2178,37 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return const ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: 'Unsupported command', + message: ExternalCommandReason.other('Unsupported command'), ); } final admission = admitExternalSessionCommand(sessionCommand); - return executeExternalSessionCommand(sessionCommand, admission); + final completion = + await executeExternalSessionCommand(sessionCommand, admission); + return externalCommandCompletionForVoice( + command: sessionCommand, + completion: completion, + ); } Future _connectToLastCompanion( AppIntentCommand command, ) async { - await _rememberedDeviceReady.future; + try { + await _rememberedDeviceReady.future.timeout(const Duration(seconds: 10)); + } on TimeoutException { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + 'MeshMapper is still starting. Open the app and try again.', + ), + ); + } if (_isDisposed) { return const ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: 'App closing', + message: ExternalCommandReason.appClosing, ); } @@ -2144,23 +2229,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!admission.shouldExecute) { - final message = switch (admission.reason) { - 'No remembered companion' => + final message = switch (admission.reason?.code) { + ExternalCommandReasonCode.noRememberedCompanion => 'MeshMapper has no remembered companion. Connect one in the app first.', - 'Already connected' => + ExternalCommandReasonCode.alreadyConnected => 'MeshMapper is already connected to ${remembered!.displayName}.', - 'Another companion is connected' => + ExternalCommandReasonCode.anotherCompanionConnected => 'MeshMapper is already connected to ${displayDeviceName ?? connectedDevice?.name ?? 'another companion'}.', - 'Already connecting' => + ExternalCommandReasonCode.alreadyConnecting => 'MeshMapper is already connecting. Try again shortly.', - 'User interaction required' => + ExternalCommandReasonCode.userInteractionRequired => 'The last companion uses USB and must be selected in MeshMapper.', - _ => admission.reason ?? 'MeshMapper could not connect.', + _ => admission.reason?.compactText ?? 'MeshMapper could not connect.', }; return ExternalCommandCompletion( success: admission.disposition == ExternalCommandDisposition.noOp, disposition: admission.disposition, - message: message, + message: ExternalCommandReason.other(message), ); } @@ -2172,22 +2257,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return ExternalCommandCompletion( success: true, disposition: ExternalCommandDisposition.admitted, - message: 'MeshMapper connected to ${remembered.displayName}.', + message: ExternalCommandReason.other( + 'MeshMapper connected to ${remembered.displayName}.', + ), ); } return ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: _connectionError ?? - 'MeshMapper could not connect to ${remembered!.displayName}.', + message: ExternalCommandReason.other( + _connectionError ?? + 'MeshMapper could not connect to ${remembered!.displayName}.', + ), ); } catch (error) { debugError('[SIRI] Connect to last companion failed: $error'); return ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: _connectionError ?? - 'MeshMapper could not connect to ${remembered!.displayName}.', + message: ExternalCommandReason.other( + _connectionError ?? + 'MeshMapper could not connect to ${remembered!.displayName}.', + ), ); } } @@ -2199,14 +2290,39 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ExternalSessionMode.hybrid => AutoMode.hybrid, }; - String _externalStartFailureReason(ExternalSessionMode? mode) { + ExternalCommandReason _externalStartFailureReason( + ExternalSessionMode? mode, + ) { final sessionReason = _lastSessionCheckFailureReason; if (sessionReason != null) return sessionReason; if (mode != ExternalSessionMode.passive && _cooldownTimer.isRunning) { - return 'Cooling down'; - } - return 'Could not start'; - } + return ExternalCommandReason.coolingDown; + } + return ExternalCommandReason.couldNotStart; + } + + ExternalCommandReason _externalReasonForPingValidation( + PingValidation validation, + ) => + switch (validation) { + PingValidation.notConnected => + ExternalCommandReason.notConnectedToDevice, + PingValidation.externalAntennaRequired => + ExternalCommandReason.selectAntennaOptionBeforePinging, + PingValidation.powerLevelRequired => + ExternalCommandReason.selectPowerLevelUnknownDevice, + PingValidation.noGpsLock => ExternalCommandReason.waitingForGpsLock, + PingValidation.gpsDataStale => ExternalCommandReason.gpsDataStale, + PingValidation.gpsInaccurate => ExternalCommandReason.gpsAccuracyLow, + PingValidation.cooldownActive => ExternalCommandReason.waitFiveSeconds, + PingValidation.manualCooldownActive => + ExternalCommandReason.waitFifteenSeconds, + PingValidation.txNotAllowed => ExternalCommandReason.zoneAtCapacity, + PingValidation.valid || + PingValidation.outsideGeofence || + PingValidation.tooCloseToLastPing => + ExternalCommandReason.other(validation.message), + }; /// Decides whether an intent from the wrist may begin. /// @@ -2241,11 +2357,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (requested.refusal != null) return requested.refusal; final mode = switch (requested.mode) { - WatchStartMode.passive => AutoMode.passive, - WatchStartMode.hybrid => AutoMode.hybrid, - null => _resolvedWatchSessionMode, + WatchStartMode.passive => AutoMode.passive.name, + WatchStartMode.hybrid => AutoMode.hybrid.name, + null => resolveLegacyWatchStartMode( + currentMode: _resolvedWatchSessionMode.name, + isConnected: isConnected, + txAllowed: txAllowed, + ), }; - return _admitAndRunWatchCommand(command, mode: mode.name); + return _admitAndRunWatchCommand(command, mode: mode); case WatchCommandKind.stopSession: return _admitAndRunWatchCommand(command); @@ -2276,13 +2396,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); final admission = admitExternalSessionCommand(external); if (admission.disposition == ExternalCommandDisposition.refused) { - return admission.reason; + return admission.reason?.compactText; } if (admission.shouldExecute) { unawaited(executeExternalSessionCommand(external, admission).then( (completion) { if (!completion.success) { - _emitWatchFailure(completion.message ?? 'Command failed'); + _emitWatchFailure( + completion.message?.compactText ?? 'Command failed', + ); } }, )); @@ -2609,8 +2731,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: id, name: _resolveRepeaterDisplayName(id), snr: repeater.snr, - typeColor: WatchGeoBuilder.overlayTypeColor(repeater.type), - snrColor: WatchGeoBuilder.snrColor(repeater.snr), + typeColor: ExternalSurfaceGeoBuilder.overlayTypeColor(repeater.type), + snrColor: ExternalSurfaceGeoBuilder.snrColor(repeater.snr), ); } } @@ -2624,8 +2746,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: id, name: _resolveRepeaterDisplayName(id), snr: rx.snr, - typeColor: WatchGeoBuilder.overlayTypeColor(OverlayPingType.rx), - snrColor: WatchGeoBuilder.snrColor(rx.snr), + typeColor: + ExternalSurfaceGeoBuilder.overlayTypeColor(OverlayPingType.rx), + snrColor: ExternalSurfaceGeoBuilder.snrColor(rx.snr), ); } } @@ -2693,12 +2816,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final identity = _overlayIdentityById[displayId]; final match = (identity == null ? null - : WatchGeoBuilder.resolveRepeater( + : ExternalSurfaceGeoBuilder.resolveRepeater( repeaters: _repeaters, id: identity, hopBytes: _hopBytes, )) ?? - WatchGeoBuilder.resolveRepeater( + ExternalSurfaceGeoBuilder.resolveRepeater( repeaters: _repeaters, id: displayId, hopBytes: _hopBytes, @@ -4223,6 +4346,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: [], )); if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); + _siriObservationRevision++; _notifyMapNow(); }; @@ -4242,6 +4366,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: ping.longitude, )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + _siriObservationRevision++; _updateRxOverlaySlot(ping.repeaterId, ping.snr); _notifyMapThrottled(); @@ -4309,6 +4434,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { multiHopEvents: lastEntry.multiHopEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; + _siriObservationRevision++; debugLog( '[APP] Updated TxLogEntry with ${existingEvents.length} direct, ' '${lastEntry.multiHopEvents.length} multi-hop events (real-time)'); @@ -4382,6 +4508,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: lastEntry.events, multiHopEvents: multiHopEvents, ); + _siriObservationRevision++; _updateLiveActivityRepeaters([ ...lastEntry.events @@ -4595,6 +4722,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { localRssi: result.localRssi, success: true, ); + _siriObservationRevision++; _notifyMapNow(); } } @@ -4955,6 +5083,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Add to RX log entries _rxLogEntries.add(rxLogEntry); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + _siriObservationRevision++; debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); @@ -5885,17 +6014,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } - String _sessionCheckFailureMessage(String? reason, String? message) { + ExternalCommandReason _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'; + if (reason == 'zone_full') return ExternalCommandReason.passiveOnly; final serverMessage = message?.trim(); if (serverMessage != null && serverMessage.isNotEmpty) { - return serverMessage; + return ExternalCommandReason.other(serverMessage); } - return _getErrorMessage(reason, null); + return ExternalCommandReason.other(_getErrorMessage(reason, null)); } /// Set the target repeater ID for targeted mode @@ -6126,6 +6258,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxPings.clear(); _discLogEntries.clear(); _traceLogEntries.clear(); + _siriObservationRevision++; _clearOverlayState(); _pingService?.resetStats(); _notifyMapNow(); @@ -6137,6 +6270,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogEntries.clear(); _discLogEntries.clear(); _traceLogEntries.clear(); + _siriObservationRevision++; _errorLogEntries.clear(); _clearOverlayState(); _notifyMapNow(); @@ -6148,6 +6282,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } + _siriObservationRevision++; debugLog( '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); _notifyMapNow(); @@ -6159,6 +6294,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_traceLogEntries.length > _maxLogEntries) { _traceLogEntries.removeLast(); } + _siriObservationRevision++; debugLog( '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); @@ -7908,7 +8044,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final staleHours = result['stale_repeater_hours']; if (staleHours is int && staleHours > 0) { - Repeater.staleHoursFallback = staleHours; + if (Repeater.staleHoursFallback != staleHours) { + Repeater.staleHoursFallback = staleHours; + _siriRepeaterCatalogRevision++; + } debugLog('[GEOFENCE] Zone stale repeater threshold: ${staleHours}h'); } @@ -7932,6 +8071,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Clear repeaters when exiting zone _repeaters = []; + _siriRepeaterCatalogRevision++; _repeatersLoaded = false; _repeatersLoadedForIata = null; } @@ -8643,6 +8783,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final fetchedRepeaters = await _apiService.fetchRepeaters(iata); if (fetchedRepeaters.isNotEmpty) { _repeaters = fetchedRepeaters; + _siriRepeaterCatalogRevision++; _repeatersLoaded = true; _repeatersLoadedForIata = iata; debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); diff --git a/lib/services/app_intents/app_intent_bridge_service.dart b/lib/services/app_intents/app_intent_bridge_service.dart index 08e31d5a..1a413b8b 100644 --- a/lib/services/app_intents/app_intent_bridge_service.dart +++ b/lib/services/app_intents/app_intent_bridge_service.dart @@ -6,26 +6,49 @@ import 'package:flutter/services.dart'; import '../../utils/debug_logger_io.dart'; import '../external_commands/external_command_models.dart'; +import '../external_surfaces/external_surface_publisher.dart'; import 'app_intent_commands.dart'; typedef AppIntentCommandHandler = Future Function( AppIntentCommand command, ); typedef AppIntentSnapshotBuilder = Map? Function(); +typedef AppIntentPreflightKeyBuilder = String Function(); /// Independent Flutter bridge for Siri/App Intents. /// -/// It deliberately has no WatchConnectivity concepts. Native reads are served -/// from the App Group snapshot; only mutations cross this channel into Dart. +/// It deliberately has no WatchConnectivity, widget, or CarPlay concepts. +/// Native reads are served from the Foundation-only App Group snapshot; only +/// App Intent mutations cross this channel into Dart. Future native surfaces +/// can consume that bounded projection while retaining their own lifecycle and +/// command transport. class AppIntentBridgeService { AppIntentBridgeService({ @visibleForTesting MethodChannel? channel, @visibleForTesting Duration debounceDelay = defaultDebounceDelay, @visibleForTesting Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, - }) : _channel = channel ?? const MethodChannel(_channelName), - _debounceDelay = debounceDelay, - _minimumNonUrgentInterval = minimumNonUrgentInterval; + }) : _channel = channel ?? const MethodChannel(_channelName) { + _publisher = + ExternalSurfacePublisher, Map>( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform, + preflightPolicy: ExternalSurfacePreflightPolicy.deduplicateAfterPublish, + payloadBuilder: (snapshot) => snapshot, + fingerprintBuilder: (snapshot) { + final semantic = Map.from(snapshot) + ..remove('updatedAtMs'); + return jsonEncode(semantic); + }, + urgencyKeyBuilder: (_) => null, + publish: (publication) async => await publishSnapshot(publication.payload) + ? const ExternalSurfacePublishResult.published() + : const ExternalSurfacePublishResult.rejected(), + restartDebounce: false, + immediateBypassesMinimumInterval: true, + ); + } static const String _channelName = 'meshmapper/app_intents'; static const int _commandQueueDepth = 8; @@ -40,16 +63,12 @@ class AppIntentBridgeService { } final MethodChannel _channel; - final Duration _debounceDelay; - final Duration _minimumNonUrgentInterval; + late final ExternalSurfacePublisher, + Map> _publisher; AppIntentCommandHandler? _commandHandler; final Map _commandOutcomes = {}; + final Map> _inFlightCommands = {}; bool _disposed = false; - Timer? _scheduledUpdate; - AppIntentSnapshotBuilder? _pendingSnapshotBuilder; - String? _lastFingerprint; - DateTime? _lastPublishedAt; - Future _operationChain = Future.value(); bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; @@ -73,21 +92,42 @@ class AppIntentBridgeService { final previous = _commandOutcomes[command.id]; if (previous != null) return previous.toMap(commandId: command.id); + final inFlight = _inFlightCommands[command.id]; + if (inFlight != null) { + final completion = await inFlight; + return completion.toMap(commandId: command.id); + } final handler = _commandHandler; if (handler == null) { return _refusal('App not ready').toMap(commandId: command.id); } + // Normalize handler failures inside the shared future so every concurrent + // delivery of this UUID receives the same refusal instead of the owner + // catching an error while a duplicate sees a PlatformException. + final execution = _executeCommand(handler, command); + _inFlightCommands[command.id] = execution; try { - final completion = await handler(command); + final completion = await execution; _remember(command.id, completion); return completion.toMap(commandId: command.id); + } finally { + if (identical(_inFlightCommands[command.id], execution)) { + _inFlightCommands.remove(command.id); + } + } + } + + Future _executeCommand( + AppIntentCommandHandler handler, + AppIntentCommand command, + ) async { + try { + return await handler(command); } catch (error) { debugError('[SIRI] ${command.kind.name} failed: $error'); - final completion = _refusal('Command failed'); - _remember(command.id, completion); - return completion.toMap(commandId: command.id); + return _refusal('Command failed'); } } @@ -108,66 +148,15 @@ class AppIntentBridgeService { void schedule( AppIntentSnapshotBuilder builder, { + required AppIntentPreflightKeyBuilder preflightKeyBuilder, bool immediate = false, }) { if (!isSupportedPlatform || _disposed) return; - _pendingSnapshotBuilder = builder; - if (immediate) { - _scheduledUpdate?.cancel(); - _scheduledUpdate = null; - _queueFlush(); - return; - } - _scheduledUpdate ??= Timer(_debounceDelay, () { - _scheduledUpdate = null; - final lastPublishedAt = _lastPublishedAt; - final remaining = lastPublishedAt == null - ? Duration.zero - : _minimumNonUrgentInterval - - DateTime.now().difference(lastPublishedAt); - if (remaining > Duration.zero) { - _scheduledUpdate = Timer(remaining, () { - _scheduledUpdate = null; - _queueFlush(); - }); - } else { - _queueFlush(); - } - }); - } - - void _queueFlush() { - _operationChain = _operationChain.then((_) => _flush()); - } - - Future _flush() async { - if (_disposed) return; - final builder = _pendingSnapshotBuilder; - _pendingSnapshotBuilder = null; - if (builder == null) return; - final snapshot = builder(); - if (snapshot == null) return; - - final semantic = Map.from(snapshot)..remove('updatedAtMs'); - final fingerprint = jsonEncode(semantic); - if (fingerprint == _lastFingerprint) return; - if (await publishSnapshot(snapshot)) { - _lastFingerprint = fingerprint; - _lastPublishedAt = DateTime.now(); - } - } - - Future clearSnapshot() async { - if (!isSupportedPlatform || _disposed) return; - try { - await _channel.invokeMethod('clearSnapshot'); - } on MissingPluginException { - // Older native builds have no App Intent bridge. - } on PlatformException catch (error) { - debugError( - '[SIRI] Snapshot clear failed: ${error.code}: ${error.message}', - ); - } + _publisher.schedule( + builder, + preflightKeyBuilder: preflightKeyBuilder, + immediate: immediate, + ); } void _remember(String id, ExternalCommandCompletion completion) { @@ -181,7 +170,7 @@ class AppIntentBridgeService { ExternalCommandCompletion( success: false, disposition: ExternalCommandDisposition.refused, - message: message, + message: ExternalCommandReason.other(message), ); void dispose() { @@ -189,9 +178,8 @@ class AppIntentBridgeService { _disposed = true; _commandHandler = null; _commandOutcomes.clear(); - _scheduledUpdate?.cancel(); - _scheduledUpdate = null; - _pendingSnapshotBuilder = null; + _inFlightCommands.clear(); + _publisher.dispose(); if (isSupportedPlatform) _channel.setMethodCallHandler(null); } } diff --git a/lib/services/app_intents/last_companion_connection.dart b/lib/services/app_intents/last_companion_connection.dart index 1ce679f9..f0a6a1bd 100644 --- a/lib/services/app_intents/last_companion_connection.dart +++ b/lib/services/app_intents/last_companion_connection.dart @@ -24,7 +24,7 @@ ExternalCommandAdmission resolveLastCompanionConnection({ if (!hasRememberedCompanion) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'No remembered companion', + reason: ExternalCommandReason.noRememberedCompanion, ); } if (isConnected) { @@ -33,20 +33,20 @@ ExternalCommandAdmission resolveLastCompanionConnection({ ? ExternalCommandDisposition.noOp : ExternalCommandDisposition.refused, reason: isConnectedToRememberedCompanion - ? 'Already connected' - : 'Another companion is connected', + ? ExternalCommandReason.alreadyConnected + : ExternalCommandReason.anotherCompanionConnected, ); } if (isConnecting) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'Already connecting', + reason: ExternalCommandReason.alreadyConnecting, ); } if (!canReconnectWithoutUserInput) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'User interaction required', + reason: ExternalCommandReason.userInteractionRequired, ); } return const ExternalCommandAdmission( diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart index 6926617c..47c276a0 100644 --- a/lib/services/app_intents/siri_snapshot_builder.dart +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -1,6 +1,6 @@ import '../../models/log_entry.dart'; import '../../models/repeater.dart'; -import '../watch/watch_geo_builder.dart'; +import '../external_surfaces/geo/external_surface_geo_builder.dart'; import '../watch/watch_models.dart'; import 'siri_snapshot_models.dart'; @@ -8,6 +8,7 @@ class SiriSnapshotBuilder { const SiriSnapshotBuilder._(); static const int maximumObservations = 64; + static const int maximumRepeaters = 64; static const Duration maximumObservationAge = Duration(hours: 2); static List buildRecentHeard({ @@ -21,29 +22,18 @@ class SiriSnapshotBuilder { DateTime? now, }) { final cutoff = (now ?? DateTime.now()).subtract(maximumObservationAge); - final observations = []; + final candidates = <_SiriObservationCandidate>[]; - SiriRepeaterObservation observation({ - required String displayId, - String? identity, - required DateTime observedAt, - required SiriObservationKind kind, - required bool direct, - required int hopCount, - required double latitude, - required double longitude, - double? snr, - int? rssi, - }) { - final normalizedId = displayId.toUpperCase(); - final repeater = (identity == null + SiriRepeaterObservation resolve(_SiriObservationCandidate candidate) { + final normalizedId = candidate.displayId.toUpperCase(); + final repeater = (candidate.identity == null ? null - : WatchGeoBuilder.resolveRepeater( + : ExternalSurfaceGeoBuilder.resolveRepeater( repeaters: repeaters, - id: identity, + id: candidate.identity!, hopBytes: hopBytes, )) ?? - WatchGeoBuilder.resolveRepeater( + ExternalSurfaceGeoBuilder.resolveRepeater( repeaters: repeaters, id: normalizedId, hopBytes: hopBytes, @@ -51,30 +41,42 @@ class SiriSnapshotBuilder { final resolved = repeater != null; final hasDistance = resolved && repeater.hasLocation && - latitude.isFinite && - longitude.isFinite; + repeater.lat.isFinite && + repeater.lon.isFinite && + candidate.latitude.isFinite && + candidate.longitude.isFinite; return SiriRepeaterObservation( entityId: resolved ? '${repeater.iata ?? zoneCode ?? 'GLOBAL'}|${repeater.id}' : null, displayHexId: normalizedId, name: resolved && repeater.name != 'Unknown' ? repeater.name : null, - observedAt: observedAt, - kind: kind, - direct: direct, - hopCount: hopCount, - snr: snr, - rssi: rssi, + observedAt: candidate.observedAt, + kind: candidate.kind, + direct: candidate.direct, + hopCount: candidate.hopCount, + snr: candidate.snr?.isFinite == true ? candidate.snr : null, + rssi: candidate.rssi, distanceM: hasDistance ? WatchWire.distanceMeters( - latitude, - longitude, + candidate.latitude, + candidate.longitude, repeater.lat, repeater.lon, ) : null, - repeaterLat: resolved && repeater.hasLocation ? repeater.lat : null, - repeaterLon: resolved && repeater.hasLocation ? repeater.lon : null, + repeaterLat: resolved && + repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite + ? repeater.lat + : null, + repeaterLon: resolved && + repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite + ? repeater.lon + : null, resolved: resolved, ); } @@ -82,7 +84,7 @@ class SiriSnapshotBuilder { for (final entry in txEntries) { if (entry.timestamp.isBefore(cutoff)) continue; for (final event in entry.events) { - observations.add(observation( + candidates.add(_SiriObservationCandidate( displayId: event.repeaterId, observedAt: entry.timestamp, kind: SiriObservationKind.txEcho, @@ -95,7 +97,7 @@ class SiriSnapshotBuilder { )); } for (final event in entry.multiHopEvents) { - observations.add(observation( + candidates.add(_SiriObservationCandidate( displayId: event.repeaterId, observedAt: entry.timestamp, kind: SiriObservationKind.txEcho, @@ -111,7 +113,7 @@ class SiriSnapshotBuilder { for (final entry in rxEntries) { if (entry.timestamp.isBefore(cutoff)) continue; - observations.add(observation( + candidates.add(_SiriObservationCandidate( displayId: entry.repeaterId, observedAt: entry.timestamp, kind: SiriObservationKind.passiveRx, @@ -128,7 +130,7 @@ class SiriSnapshotBuilder { if (entry.timestamp.isBefore(cutoff)) continue; for (final node in entry.discoveredNodes .where((node) => node.nodeType == 'REPEATER')) { - observations.add(observation( + candidates.add(_SiriObservationCandidate( displayId: node.repeaterId, identity: node.pubkeyHex, observedAt: entry.timestamp, @@ -145,7 +147,7 @@ class SiriSnapshotBuilder { for (final entry in traceEntries.where((entry) => entry.success)) { if (entry.timestamp.isBefore(cutoff)) continue; - observations.add(observation( + candidates.add(_SiriObservationCandidate( displayId: entry.targetRepeaterId, identity: entry.targetRepeaterId, observedAt: entry.timestamp, @@ -159,32 +161,72 @@ class SiriSnapshotBuilder { )); } - observations.sort((a, b) => b.observedAt.compareTo(a.observedAt)); - return observations.length > maximumObservations - ? observations.sublist(0, maximumObservations) - : observations; + // Identity resolution scans the zone catalogue. Rank and bound the cheap + // raw candidates first so a busy two-hour log pays that cost at most 64 + // times rather than once per observation across all four histories. + candidates.sort((a, b) => b.observedAt.compareTo(a.observedAt)); + return candidates.take(maximumObservations).map(resolve).toList( + growable: false, + ); } static List buildRepeaterCatalog( List repeaters, - ) => - repeaters - .map( - (repeater) => SiriRepeaterEntitySnapshot( - id: '${repeater.iata ?? 'GLOBAL'}|${repeater.id}', - name: repeater.name, - hexId: repeater.hexId.toUpperCase(), - zoneCode: repeater.iata, - isActive: repeater.isActive, - isNew: repeater.isNew, - serverLastHeard: repeater.lastHeard == 0 - ? null - : DateTime.fromMillisecondsSinceEpoch( - repeater.lastHeard * 1000, - ), - latitude: repeater.hasLocation ? repeater.lat : null, - longitude: repeater.hasLocation ? repeater.lon : null, - ), - ) - .toList(growable: false); + ) { + final ranked = List.of(repeaters) + ..sort((a, b) { + final active = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); + if (active != 0) return active; + final heard = b.lastHeard.compareTo(a.lastHeard); + return heard != 0 ? heard : a.id.compareTo(b.id); + }); + return ranked.take(maximumRepeaters).map( + (repeater) { + final hasFiniteLocation = repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite; + return SiriRepeaterEntitySnapshot( + id: '${repeater.iata ?? 'GLOBAL'}|${repeater.id}', + name: repeater.name, + hexId: repeater.hexId.toUpperCase(), + zoneCode: repeater.iata, + isActive: repeater.isActive, + isNew: repeater.isNew, + serverLastHeard: repeater.lastHeard == 0 + ? null + : DateTime.fromMillisecondsSinceEpoch( + repeater.lastHeard * 1000, + ), + latitude: hasFiniteLocation ? repeater.lat : null, + longitude: hasFiniteLocation ? repeater.lon : null, + ); + }, + ).toList(growable: false); + } +} + +class _SiriObservationCandidate { + const _SiriObservationCandidate({ + required this.displayId, + this.identity, + required this.observedAt, + required this.kind, + required this.direct, + required this.hopCount, + required this.latitude, + required this.longitude, + this.snr, + this.rssi, + }); + + final String displayId; + final String? identity; + final DateTime observedAt; + final SiriObservationKind kind; + final bool direct; + final int hopCount; + final double latitude; + final double longitude; + final double? snr; + final int? rssi; } diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart index 4f6b2c21..1be41d5a 100644 --- a/lib/services/external_commands/external_command_models.dart +++ b/lib/services/external_commands/external_command_models.dart @@ -54,6 +54,200 @@ enum ExternalCommandDisposition { refused; } +/// Stable refusal identities shared by compact and spoken presentations. +enum ExternalCommandReasonCode { + notConnected, + notConnectedToDevice, + stillStopping, + traceSessionActive, + alreadyStarting, + selectAntennaOption, + selectAntennaOptionBeforePinging, + selectPowerLevel, + selectPowerLevelUnknownDevice, + offlineMode, + passiveOnly, + zoneAtCapacity, + floodTrafficOff, + coolingDown, + waitFiveSeconds, + waitFifteenSeconds, + pingInProgress, + listeningForPingResponse, + waitingForGpsLock, + gpsDataStale, + gpsAccuracyLow, + anotherOperationInProgress, + stillStartingTryStopAgain, + sessionAlreadyEnded, + couldNotStart, + couldNotStop, + pingFailed, + appClosing, + commandExpired, + noRememberedCompanion, + alreadyConnected, + anotherCompanionConnected, + alreadyConnecting, + userInteractionRequired, + other, +} + +/// A typed reason with the exact compact text rendered by small surfaces. +/// +/// [ExternalCommandReasonCode.other] preserves success text and free-form +/// provider/server failures without making those deeper layers adopt this enum. +@immutable +class ExternalCommandReason { + const ExternalCommandReason.known(this.code) + : assert(code != ExternalCommandReasonCode.other), + rawText = null; + + const ExternalCommandReason.other(this.rawText) + : code = ExternalCommandReasonCode.other; + + static const notConnected = + ExternalCommandReason.known(ExternalCommandReasonCode.notConnected); + static const notConnectedToDevice = ExternalCommandReason.known( + ExternalCommandReasonCode.notConnectedToDevice, + ); + static const stillStopping = + ExternalCommandReason.known(ExternalCommandReasonCode.stillStopping); + static const traceSessionActive = ExternalCommandReason.known( + ExternalCommandReasonCode.traceSessionActive, + ); + static const alreadyStarting = + ExternalCommandReason.known(ExternalCommandReasonCode.alreadyStarting); + static const selectAntennaOption = ExternalCommandReason.known( + ExternalCommandReasonCode.selectAntennaOption, + ); + static const selectAntennaOptionBeforePinging = ExternalCommandReason.known( + ExternalCommandReasonCode.selectAntennaOptionBeforePinging, + ); + static const selectPowerLevel = + ExternalCommandReason.known(ExternalCommandReasonCode.selectPowerLevel); + static const selectPowerLevelUnknownDevice = ExternalCommandReason.known( + ExternalCommandReasonCode.selectPowerLevelUnknownDevice, + ); + static const offlineMode = + ExternalCommandReason.known(ExternalCommandReasonCode.offlineMode); + static const passiveOnly = + ExternalCommandReason.known(ExternalCommandReasonCode.passiveOnly); + static const zoneAtCapacity = + ExternalCommandReason.known(ExternalCommandReasonCode.zoneAtCapacity); + static const floodTrafficOff = + ExternalCommandReason.known(ExternalCommandReasonCode.floodTrafficOff); + static const coolingDown = + ExternalCommandReason.known(ExternalCommandReasonCode.coolingDown); + static const waitFiveSeconds = + ExternalCommandReason.known(ExternalCommandReasonCode.waitFiveSeconds); + static const waitFifteenSeconds = ExternalCommandReason.known( + ExternalCommandReasonCode.waitFifteenSeconds, + ); + static const pingInProgress = + ExternalCommandReason.known(ExternalCommandReasonCode.pingInProgress); + static const listeningForPingResponse = ExternalCommandReason.known( + ExternalCommandReasonCode.listeningForPingResponse, + ); + static const waitingForGpsLock = ExternalCommandReason.known( + ExternalCommandReasonCode.waitingForGpsLock, + ); + static const gpsDataStale = + ExternalCommandReason.known(ExternalCommandReasonCode.gpsDataStale); + static const gpsAccuracyLow = + ExternalCommandReason.known(ExternalCommandReasonCode.gpsAccuracyLow); + static const anotherOperationInProgress = ExternalCommandReason.known( + ExternalCommandReasonCode.anotherOperationInProgress, + ); + static const stillStartingTryStopAgain = ExternalCommandReason.known( + ExternalCommandReasonCode.stillStartingTryStopAgain, + ); + static const sessionAlreadyEnded = ExternalCommandReason.known( + ExternalCommandReasonCode.sessionAlreadyEnded, + ); + static const couldNotStart = + ExternalCommandReason.known(ExternalCommandReasonCode.couldNotStart); + static const couldNotStop = + ExternalCommandReason.known(ExternalCommandReasonCode.couldNotStop); + static const pingFailed = + ExternalCommandReason.known(ExternalCommandReasonCode.pingFailed); + static const appClosing = + ExternalCommandReason.known(ExternalCommandReasonCode.appClosing); + static const commandExpired = + ExternalCommandReason.known(ExternalCommandReasonCode.commandExpired); + static const noRememberedCompanion = ExternalCommandReason.known( + ExternalCommandReasonCode.noRememberedCompanion, + ); + static const alreadyConnected = + ExternalCommandReason.known(ExternalCommandReasonCode.alreadyConnected); + static const anotherCompanionConnected = ExternalCommandReason.known( + ExternalCommandReasonCode.anotherCompanionConnected, + ); + static const alreadyConnecting = + ExternalCommandReason.known(ExternalCommandReasonCode.alreadyConnecting); + static const userInteractionRequired = ExternalCommandReason.known( + ExternalCommandReasonCode.userInteractionRequired, + ); + + final ExternalCommandReasonCode code; + final String? rawText; + + String get compactText => switch (code) { + ExternalCommandReasonCode.notConnected => 'Not connected', + ExternalCommandReasonCode.notConnectedToDevice => + 'Not connected to device', + ExternalCommandReasonCode.stillStopping => 'Still stopping', + ExternalCommandReasonCode.traceSessionActive => 'Trace session active', + ExternalCommandReasonCode.alreadyStarting => 'Already starting', + ExternalCommandReasonCode.selectAntennaOption => + 'Select antenna option', + ExternalCommandReasonCode.selectAntennaOptionBeforePinging => + 'Select antenna option before pinging', + ExternalCommandReasonCode.selectPowerLevel => 'Select power level', + ExternalCommandReasonCode.selectPowerLevelUnknownDevice => + 'Select power level (unknown device)', + ExternalCommandReasonCode.offlineMode => 'Offline Mode', + ExternalCommandReasonCode.passiveOnly => 'Passive Only', + ExternalCommandReasonCode.zoneAtCapacity => + 'Zone at TX capacity (Passive Only)', + ExternalCommandReasonCode.floodTrafficOff => 'Flood Traffic Off', + ExternalCommandReasonCode.coolingDown => 'Cooling down', + ExternalCommandReasonCode.waitFiveSeconds => + 'Wait 5 seconds between pings', + ExternalCommandReasonCode.waitFifteenSeconds => + 'Wait 15 seconds between manual pings', + ExternalCommandReasonCode.pingInProgress => 'Ping in progress', + ExternalCommandReasonCode.listeningForPingResponse => + 'Listening for ping response', + ExternalCommandReasonCode.waitingForGpsLock => 'Waiting for GPS lock', + ExternalCommandReasonCode.gpsDataStale => + 'GPS data too old (> 60 seconds)', + ExternalCommandReasonCode.gpsAccuracyLow => + 'GPS accuracy too low (> 100 meters)', + ExternalCommandReasonCode.anotherOperationInProgress => + 'Another operation is in progress', + ExternalCommandReasonCode.stillStartingTryStopAgain => + 'Still starting — try Stop again', + ExternalCommandReasonCode.sessionAlreadyEnded => + 'That session already ended', + ExternalCommandReasonCode.couldNotStart => 'Could not start', + ExternalCommandReasonCode.couldNotStop => 'Could not stop', + ExternalCommandReasonCode.pingFailed => 'Ping failed', + ExternalCommandReasonCode.appClosing => 'App closing', + ExternalCommandReasonCode.commandExpired => + 'Command arrived too late to run safely', + ExternalCommandReasonCode.noRememberedCompanion => + 'No remembered companion', + ExternalCommandReasonCode.alreadyConnected => 'Already connected', + ExternalCommandReasonCode.anotherCompanionConnected => + 'Another companion is connected', + ExternalCommandReasonCode.alreadyConnecting => 'Already connecting', + ExternalCommandReasonCode.userInteractionRequired => + 'User interaction required', + ExternalCommandReasonCode.other => rawText ?? '', + }; +} + @immutable class ExternalSessionCommand { const ExternalSessionCommand({ @@ -115,7 +309,7 @@ class ExternalCommandAdmission { }); final ExternalCommandDisposition disposition; - final String? reason; + final ExternalCommandReason? reason; final ExternalSessionMode? mode; bool get shouldExecute => disposition == ExternalCommandDisposition.admitted; @@ -133,7 +327,7 @@ class ExternalCommandCompletion { final bool success; final ExternalCommandDisposition disposition; - final String? message; + final ExternalCommandReason? message; final String? sessionId; final ExternalSessionMode? mode; @@ -141,7 +335,7 @@ class ExternalCommandCompletion { if (commandId != null) 'id': commandId, 'success': success, 'disposition': disposition.name, - 'message': message, + 'message': message?.compactText, 'sessionId': sessionId, 'mode': mode?.name, }; diff --git a/lib/services/external_commands/external_session_commands.dart b/lib/services/external_commands/external_session_commands.dart index 0ce25843..e41a6831 100644 --- a/lib/services/external_commands/external_session_commands.dart +++ b/lib/services/external_commands/external_session_commands.dart @@ -2,22 +2,24 @@ import 'external_command_models.dart'; const Duration maximumExternalCommandAge = Duration(seconds: 30); const Duration externalCommandFutureTolerance = Duration(seconds: 5); +String get externalCommandExpiredReason => + ExternalCommandReason.commandExpired.compactText; -String? externalCommandTimestampRefusal( +ExternalCommandReason? externalCommandTimestampRefusal( DateTime issuedAt, { DateTime? now, }) { final age = (now ?? DateTime.now()).difference(issuedAt); if (age > maximumExternalCommandAge || age < -externalCommandFutureTolerance) { - return 'Took too long to reach iPhone'; + return ExternalCommandReason.commandExpired; } return null; } /// Reject commands whose delayed execution could transmit from the wrong /// location. Stop is deliberately exempt and is instead bound to a session ID. -String? externalCommandAgeRefusal( +ExternalCommandReason? externalCommandAgeRefusal( ExternalSessionCommand command, { DateTime? now, }) { @@ -25,6 +27,116 @@ String? externalCommandAgeRefusal( return externalCommandTimestampRefusal(command.issuedAt, now: now); } +/// Expands compact admission labels into complete, actionable voice responses. +/// +/// Watch surfaces intentionally retain the short reason stored on the shared +/// admission result. Siri calls this only at its presentation boundary, so the +/// policy decision stays transport-neutral while a spoken refusal explains +/// what happened and, where possible, what the person can do next. +ExternalCommandCompletion externalCommandCompletionForVoice({ + required ExternalSessionCommand command, + required ExternalCommandCompletion completion, +}) { + final message = _externalCommandVoiceMessage( + command: command, + reason: completion.message, + ); + if (message == completion.message?.compactText) return completion; + return ExternalCommandCompletion( + success: completion.success, + disposition: completion.disposition, + message: ExternalCommandReason.other(message), + sessionId: completion.sessionId, + mode: completion.mode, + ); +} + +String _externalCommandVoiceMessage({ + required ExternalSessionCommand command, + required ExternalCommandReason? reason, +}) { + final compact = reason?.compactText.trim(); + if (compact == null || compact.isEmpty) { + return switch (command.kind) { + ExternalSessionCommandKind.startSession => + "MeshMapper couldn't start the requested session.", + ExternalSessionCommandKind.stopSession => + "MeshMapper couldn't stop the current session.", + ExternalSessionCommandKind.manualPing => + "MeshMapper couldn't send the manual ping.", + }; + } + + final mode = ExternalSessionMode.fromWire(command.mode ?? ''); + final modeName = mode?.displayName ?? 'requested'; + final isStart = command.kind == ExternalSessionCommandKind.startSession; + + return switch (reason!.code) { + ExternalCommandReasonCode.notConnected || + ExternalCommandReasonCode.notConnectedToDevice => + "MeshMapper isn't connected. Reconnect it, then try again.", + ExternalCommandReasonCode.stillStopping => + 'MeshMapper is still stopping. Try again shortly.', + ExternalCommandReasonCode.traceSessionActive => + 'Stop the active Trace session before starting $modeName mode.', + ExternalCommandReasonCode.alreadyStarting => + 'MeshMapper is already starting a session.', + ExternalCommandReasonCode.selectAntennaOption || + ExternalCommandReasonCode.selectAntennaOptionBeforePinging => + 'Select an antenna option in MeshMapper, then try again.', + ExternalCommandReasonCode.selectPowerLevel || + ExternalCommandReasonCode.selectPowerLevelUnknownDevice => + 'Select a power level in MeshMapper, then try again.', + ExternalCommandReasonCode.offlineMode => isStart + ? "$modeName mode isn't available in Offline Mode. Start Passive Discovery instead." + : "Manual pings aren't available in Offline Mode. Turn it off and try again.", + ExternalCommandReasonCode.passiveOnly || + ExternalCommandReasonCode.zoneAtCapacity => + isStart + ? "$modeName mode isn't available in this region. Start Passive Discovery instead." + : "Manual pings aren't available in this region. Passive Discovery still works.", + ExternalCommandReasonCode.floodTrafficOff => isStart + ? '$modeName mode requires Flood Traffic. Enable it or start Passive Discovery.' + : 'Manual pings require Flood Traffic. Enable it and try again.', + ExternalCommandReasonCode.coolingDown || + ExternalCommandReasonCode.waitFiveSeconds || + ExternalCommandReasonCode.waitFifteenSeconds => + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReasonCode.pingInProgress => + 'A ping is already in progress. Try again when it finishes.', + ExternalCommandReasonCode.listeningForPingResponse => + 'MeshMapper is listening for a response. Try again shortly.', + ExternalCommandReasonCode.waitingForGpsLock => + 'MeshMapper is waiting for a GPS fix. Try again shortly.', + ExternalCommandReasonCode.gpsDataStale => + 'MeshMapper needs a current GPS position. Let location refresh, then retry.', + ExternalCommandReasonCode.gpsAccuracyLow => + 'GPS accuracy is too low. Try again when it improves.', + ExternalCommandReasonCode.anotherOperationInProgress => + 'Another radio operation is in progress. Try again shortly.', + ExternalCommandReasonCode.stillStartingTryStopAgain => + 'MeshMapper is still starting. Try Stop again shortly.', + ExternalCommandReasonCode.sessionAlreadyEnded => + 'That session already ended. There is nothing to stop.', + ExternalCommandReasonCode.couldNotStart => + "MeshMapper couldn't start $modeName mode.", + ExternalCommandReasonCode.couldNotStop => + "MeshMapper couldn't stop. Check the app for details.", + ExternalCommandReasonCode.pingFailed => + "MeshMapper couldn't send the manual ping. Open the app for details.", + ExternalCommandReasonCode.appClosing => + 'MeshMapper is closing. Open it and try again.', + ExternalCommandReasonCode.commandExpired || + ExternalCommandReasonCode.noRememberedCompanion || + ExternalCommandReasonCode.alreadyConnected || + ExternalCommandReasonCode.anotherCompanionConnected || + ExternalCommandReasonCode.alreadyConnecting || + ExternalCommandReasonCode.userInteractionRequired || + ExternalCommandReasonCode.other => + compact, + }; +} + ExternalCommandAdmission resolveExternalSessionTransition({ required ExternalSessionCommand command, required bool isSessionActive, @@ -46,13 +158,17 @@ ExternalCommandAdmission resolveExternalSessionTransition({ if (isSessionActive) { return ExternalCommandAdmission( disposition: ExternalCommandDisposition.noOp, - reason: 'MeshMapper is already running in $currentMode mode.', + reason: ExternalCommandReason.other( + 'MeshMapper is already running in $currentMode mode.', + ), ); } if (isSessionStarting) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.noOp, - reason: 'MeshMapper is already starting.', + reason: ExternalCommandReason.other( + 'MeshMapper is already starting.', + ), ); } final rawMode = command.mode ?? @@ -60,14 +176,14 @@ ExternalCommandAdmission resolveExternalSessionTransition({ if (rawMode == null) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'No start mode was provided', + reason: ExternalCommandReason.other('No start mode was provided'), ); } final mode = ExternalSessionMode.fromWire(rawMode); if (mode == null) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'Unsupported start mode', + reason: ExternalCommandReason.other('Unsupported start mode'), ); } return ExternalCommandAdmission( @@ -79,19 +195,21 @@ ExternalCommandAdmission resolveExternalSessionTransition({ if (isSessionStarting && !isSessionActive) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'Still starting — try Stop again', + reason: ExternalCommandReason.stillStartingTryStopAgain, ); } if (!isSessionActive) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.noOp, - reason: "There isn't a MeshMapper session running.", + reason: ExternalCommandReason.other( + "There isn't a MeshMapper session running.", + ), ); } if (command.sessionId != null && command.sessionId != currentSessionId) { return const ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, - reason: 'That session already ended', + reason: ExternalCommandReason.sessionAlreadyEnded, ); } return ExternalCommandAdmission( diff --git a/lib/services/external_surfaces/external_surface_color.dart b/lib/services/external_surfaces/external_surface_color.dart new file mode 100644 index 00000000..a889148a --- /dev/null +++ b/lib/services/external_surfaces/external_surface_color.dart @@ -0,0 +1,32 @@ +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 ExternalSurfaceColor { + const ExternalSurfaceColor(this.r, this.g, this.b); + + factory ExternalSurfaceColor.fromColor(Color color) => ExternalSurfaceColor( + (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 ExternalSurfaceColor && + other.r == r && + other.g == g && + other.b == b; + + @override + int get hashCode => Object.hash(r, g, b); +} diff --git a/lib/services/external_surfaces/external_surface_publisher.dart b/lib/services/external_surfaces/external_surface_publisher.dart new file mode 100644 index 00000000..172da170 --- /dev/null +++ b/lib/services/external_surfaces/external_surface_publisher.dart @@ -0,0 +1,352 @@ +import 'dart:async'; + +enum ExternalSurfacePreflightPolicy { + deduplicateAfterPublish, + throttleAgainstPublishedUrgency, + throttleAgainstLastBuild, +} + +enum ExternalSurfacePublishDisposition { + published, + rejected, + disable, +} + +class ExternalSurfacePublishResult { + const ExternalSurfacePublishResult._( + this.disposition, { + this.retryAfter, + }); + + const ExternalSurfacePublishResult.published() + : this._(ExternalSurfacePublishDisposition.published); + + const ExternalSurfacePublishResult.rejected({Duration? retryAfter}) + : this._( + ExternalSurfacePublishDisposition.rejected, + retryAfter: retryAfter, + ); + + const ExternalSurfacePublishResult.disable() + : this._(ExternalSurfacePublishDisposition.disable); + + final ExternalSurfacePublishDisposition disposition; + final Duration? retryAfter; +} + +class ExternalSurfacePublication { + const ExternalSurfacePublication({ + required this.snapshot, + required this.payload, + required this.fingerprint, + required this.urgent, + required this.forceDelivery, + }); + + final TSnapshot snapshot; + final TPayload payload; + final String fingerprint; + final bool urgent; + final bool forceDelivery; +} + +typedef ExternalSurfaceSnapshotBuilder = TSnapshot? Function(); +typedef ExternalSurfacePreflightKeyBuilder = Object? Function(); +typedef ExternalSurfacePayloadBuilder = TPayload Function( + TSnapshot snapshot, +); +typedef ExternalSurfaceFingerprintBuilder = String Function( + TPayload payload, +); +typedef ExternalSurfaceUrgencyKeyBuilder = Object? Function( + TSnapshot snapshot, +); +typedef ExternalSurfacePublish + = Future Function( + ExternalSurfacePublication publication, +); +typedef ExternalSurfaceClear = Future Function({ + required bool immediate, +}); +typedef ExternalSurfaceCandidateGate = bool Function( + ExternalSurfacePublication publication, +); + +/// Shared debounce, preflight, dedupe, throttle, and serialization pipeline for +/// state projected onto an external surface. +/// +/// The owner supplies domain serialization and transport callbacks. This class +/// deliberately knows nothing about WatchConnectivity, ActivityKit, App Groups, +/// or the shape of their payloads. +class ExternalSurfacePublisher { + ExternalSurfacePublisher({ + required Duration debounceDelay, + required Duration minimumNonUrgentInterval, + required bool Function() isEnabled, + required ExternalSurfacePreflightPolicy preflightPolicy, + required ExternalSurfacePayloadBuilder payloadBuilder, + required ExternalSurfaceFingerprintBuilder fingerprintBuilder, + required ExternalSurfaceUrgencyKeyBuilder urgencyKeyBuilder, + required ExternalSurfacePublish publish, + ExternalSurfaceClear? clear, + ExternalSurfaceCandidateGate? candidateGate, + void Function(Object error)? onQueueError, + bool restartDebounce = true, + bool immediateBypassesMinimumInterval = false, + }) : _debounceDelay = debounceDelay, + _minimumNonUrgentInterval = minimumNonUrgentInterval, + _isEnabled = isEnabled, + _preflightPolicy = preflightPolicy, + _payloadBuilder = payloadBuilder, + _fingerprintBuilder = fingerprintBuilder, + _urgencyKeyBuilder = urgencyKeyBuilder, + _publish = publish, + _clear = clear, + _candidateGate = candidateGate, + _onQueueError = onQueueError, + _restartDebounce = restartDebounce, + _immediateBypassesMinimumInterval = immediateBypassesMinimumInterval; + + final Duration _debounceDelay; + final Duration _minimumNonUrgentInterval; + final bool Function() _isEnabled; + final ExternalSurfacePreflightPolicy _preflightPolicy; + final ExternalSurfacePayloadBuilder _payloadBuilder; + final ExternalSurfaceFingerprintBuilder _fingerprintBuilder; + final ExternalSurfaceUrgencyKeyBuilder _urgencyKeyBuilder; + final ExternalSurfacePublish _publish; + final ExternalSurfaceClear? _clear; + final ExternalSurfaceCandidateGate? _candidateGate; + final void Function(Object error)? _onQueueError; + final bool _restartDebounce; + final bool _immediateBypassesMinimumInterval; + + Timer? _scheduledUpdate; + ExternalSurfaceSnapshotBuilder? _pendingSnapshotBuilder; + ExternalSurfacePreflightKeyBuilder? _pendingPreflightKeyBuilder; + String? _lastFingerprint; + Object? _lastPublishedUrgencyKey; + Object? _lastBuiltPreflightKey; + DateTime? _lastBuiltAt; + DateTime? _lastPublishedAt; + Future _operationChain = Future.value(); + bool _forceDelivery = false; + bool _bypassBuildFloor = false; + bool _didReconcileEmptyState = false; + bool _disabled = false; + bool _disposed = false; + + bool get hasPublishedFingerprint => _lastFingerprint != null; + + void schedule( + ExternalSurfaceSnapshotBuilder snapshotBuilder, { + required ExternalSurfacePreflightKeyBuilder preflightKeyBuilder, + bool immediate = false, + bool forceDelivery = false, + }) { + if (_disposed || _disabled || !_isEnabled()) return; + + _pendingSnapshotBuilder = snapshotBuilder; + _pendingPreflightKeyBuilder = preflightKeyBuilder; + if (forceDelivery) _forceDelivery = true; + if (_restartDebounce) { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + } + + if (immediate) { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _enqueueFlush( + bypassMinimumInterval: _immediateBypassesMinimumInterval, + ); + return; + } + + _scheduledUpdate ??= Timer(_debounceDelay, _enqueueFlush); + } + + /// Re-attempt the latest pending projection after an owner-specific backoff. + void retryAfter(Duration delay, {bool bypassBuildFloor = true}) { + if (_disposed || _disabled || !_isEnabled()) return; + if (bypassBuildFloor) _bypassBuildFloor = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = Timer(delay, _enqueueFlush); + } + + /// Makes the next queued build independent of the ordinary pre-build floor. + void bypassNextBuildFloor() { + if (_disposed || _disabled) return; + _bypassBuildFloor = true; + } + + void _enqueueFlush({bool bypassMinimumInterval = false}) { + _operationChain = _operationChain + .then( + (_) => _flush(bypassMinimumInterval: bypassMinimumInterval), + ) + .catchError((Object error) { + _onQueueError?.call(error); + }); + } + + Future _flush({required bool bypassMinimumInterval}) async { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + if (_disposed || _disabled || !_isEnabled()) return; + + final snapshotBuilder = _pendingSnapshotBuilder; + final preflightKeyBuilder = _pendingPreflightKeyBuilder; + if (snapshotBuilder == null || preflightKeyBuilder == null) return; + + final preflightKey = preflightKeyBuilder(); + final floorAnchor = _preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish + ? _lastPublishedAt + : _lastBuiltAt; + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish && + !bypassMinimumInterval && + _deferUntilFloor(floorAnchor)) { + return; + } + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish && + _lastFingerprint != null && + preflightKey == _lastBuiltPreflightKey) { + _clearPendingBuilders(); + return; + } + + final comparisonKey = _preflightPolicy == + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency + ? _lastPublishedUrgencyKey + : _lastBuiltPreflightKey; + final predictedUrgent = preflightKey != comparisonKey; + final buildFloorApplies = _preflightPolicy != + ExternalSurfacePreflightPolicy.deduplicateAfterPublish && + !_bypassBuildFloor && + !predictedUrgent; + if (buildFloorApplies && _deferUntilFloor(floorAnchor)) return; + + _bypassBuildFloor = false; + if (_preflightPolicy != + ExternalSurfacePreflightPolicy.deduplicateAfterPublish) { + _lastBuiltAt = DateTime.now(); + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.throttleAgainstLastBuild) { + _lastBuiltPreflightKey = preflightKey; + } + } + + final snapshot = snapshotBuilder(); + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish) { + _clearPendingBuilders(); + } + if (snapshot == null) { + await _clearEmptyState(); + return; + } + + final payload = _payloadBuilder(snapshot); + final fingerprint = _fingerprintBuilder(payload); + final forceDelivery = _forceDelivery; + if (!forceDelivery && fingerprint == _lastFingerprint) { + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish) { + _lastBuiltPreflightKey = preflightKey; + } + return; + } + + final urgencyKey = _urgencyKeyBuilder(snapshot); + final urgent = urgencyKey != _lastPublishedUrgencyKey; + final publication = ExternalSurfacePublication( + snapshot: snapshot, + payload: payload, + fingerprint: fingerprint, + urgent: urgent, + forceDelivery: forceDelivery, + ); + if (_candidateGate != null && !_candidateGate(publication)) return; + + if (!urgent && + !bypassMinimumInterval && + _deferUntilFloor(_lastPublishedAt)) { + return; + } + + final result = await _publish(publication); + switch (result.disposition) { + case ExternalSurfacePublishDisposition.published: + _forceDelivery = false; + _lastFingerprint = fingerprint; + _lastPublishedUrgencyKey = urgencyKey; + _lastBuiltPreflightKey = preflightKey; + _lastPublishedAt = DateTime.now(); + _didReconcileEmptyState = false; + break; + case ExternalSurfacePublishDisposition.rejected: + final retryAfter = result.retryAfter; + if (retryAfter != null) this.retryAfter(retryAfter); + break; + case ExternalSurfacePublishDisposition.disable: + disable(); + break; + } + } + + bool _deferUntilFloor(DateTime? anchor) { + if (anchor == null) return false; + final elapsed = DateTime.now().difference(anchor); + if (elapsed >= _minimumNonUrgentInterval) return false; + _scheduledUpdate?.cancel(); + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return true; + } + + Future _clearEmptyState() async { + final clear = _clear; + if (clear == null) return; + if (_lastFingerprint == null && _didReconcileEmptyState) return; + await clear(immediate: _lastFingerprint == null); + _didReconcileEmptyState = true; + resetPublishedState(reconciledEmptyState: true); + } + + void resetPublishedState({bool reconciledEmptyState = false}) { + _lastFingerprint = null; + _lastPublishedUrgencyKey = null; + _lastBuiltPreflightKey = null; + _lastBuiltAt = null; + _lastPublishedAt = null; + _didReconcileEmptyState = reconciledEmptyState; + } + + void disable() { + if (_disabled) return; + _disabled = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _clearPendingBuilders(); + _forceDelivery = false; + _bypassBuildFloor = false; + } + + void _clearPendingBuilders() { + _pendingSnapshotBuilder = null; + _pendingPreflightKeyBuilder = null; + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _clearPendingBuilders(); + } +} diff --git a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart new file mode 100644 index 00000000..19cd168b --- /dev/null +++ b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart @@ -0,0 +1,471 @@ +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 '../external_surface_color.dart'; +import 'external_surface_geo_models.dart'; + +/// Pure builders for geography shared by native external surfaces. +/// +/// 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 ExternalSurfaceGeoBuilder { + ExternalSurfaceGeoBuilder._(); + + /// Colour for a ping marker, matching the iOS map's `_coverageStatusColor`. + static ExternalSurfaceColor pingColor(String kind, bool success) { + switch (kind) { + case 'tx': + return ExternalSurfaceColor.fromColor( + success ? PingColors.txSuccess : PingColors.txFail, + ); + case 'rx': + return ExternalSurfaceColor.fromColor(PingColors.rx); + case 'disc': + return ExternalSurfaceColor.fromColor( + success ? PingColors.discSuccess : PingColors.discFail, + ); + case 'trace': + return ExternalSurfaceColor.fromColor( + success ? PingColors.traceSuccess : PingColors.noResponse, + ); + default: + return ExternalSurfaceColor.fromColor(PingColors.noResponse); + } + } + + static ExternalSurfaceColor _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 ExternalSurfaceColor? latestPingColor({ + required List txPings, + required List rxPings, + required List discLogEntries, + required List traceLogEntries, + }) { + DateTime? latestAt; + ExternalSurfaceColor? latestColor; + + void consider(DateTime at, ExternalSurfaceColor 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 ExternalSurfaceColor repeaterColor(Repeater repeater) { + if (repeater.isDead) { + return ExternalSurfaceColor.fromColor(PingColors.repeaterDead); + } + if (repeater.isNew) { + return ExternalSurfaceColor.fromColor(PingColors.repeaterNew); + } + return ExternalSurfaceColor.fromColor(PingColors.repeaterActive); + } + + /// Most recent pings, newest first, capped at [ExternalSurfaceGeoWire.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 = ExternalSurfaceGeoWire.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(ExternalSurfacePing( + 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(ExternalSurfacePing( + 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(ExternalSurfacePing( + 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(ExternalSurfacePing( + 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 [ExternalSurfaceGeoWire.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 = ExternalSurfaceGeoWire.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: ExternalSurfaceGeoWire.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) => ExternalSurfaceRepeater( + 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; + } + + /// Resolve one identifier to a repeater, or null when it is not unambiguous. + /// + /// Tries exact identity first — the catalogue's short ID, its hex ID, or the + /// hex truncated to the zone's hop width, which is the form an overlay row is + /// labelled with — and only then a unique prefix relation. + /// + /// **A repeater with no `hexId` is never a prefix candidate.** The API omits + /// `hex_id` often enough that `Repeater.fromJson` defaults it to the empty + /// string, and `id.startsWith('')` is true for every id, so a single such + /// entry silently made every prefix lookup ambiguous and every name vanish. + static Repeater? resolveRepeater({ + required List repeaters, + required String id, + int? hopBytes, + }) { + final needle = id.toUpperCase(); + if (needle.isEmpty) return null; + + final exact = repeaters.where((repeater) { + return repeater.id.toUpperCase() == needle || + repeater.hexId.toUpperCase() == needle || + repeater.displayHexId(overrideHopBytes: hopBytes).toUpperCase() == + needle; + }).toList(growable: false); + if (exact.length == 1) return exact.single; + if (exact.isNotEmpty || needle.length < 2) return null; + + final prefixed = repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + if (hexId.isEmpty) return false; + return hexId.startsWith(needle) || needle.startsWith(hexId); + }).toList(growable: false); + return prefixed.length == 1 ? prefixed.single : null; + } + + /// Resolve overlay display hashes to repeaters, preferring the fuller + /// identity the ping actually carried. + /// + /// A path hash is 1–3 bytes, so in a busy zone it routinely matches several + /// repeaters and [resolveUniqueHexPrefixes] correctly refuses to name any of + /// them. But the phone often knows exactly who answered: a discovery response + /// carries the responder's full 64-character public key, and a trace carries + /// its 4-byte target. Resolving from the truncated hash threw that away, and + /// left the wrist reading "Name unresolved" for a node the phone could have + /// named precisely. + /// + /// [identities] maps a display hash to the longest identity known for the row + /// shown under it. It must describe the **current** rows only: a hash that + /// meant one repeater in a discovery response says nothing about who a later + /// TX echo under the same hash was, and carrying it over would turn a refusal + /// to guess into a confident wrong answer. + /// + /// Uniqueness is still required. A longer identity makes a collision + /// vanishingly unlikely rather than impossible, and a confidently wrong name + /// stays worse than none. + static Map resolveOverlayRepeaters({ + required List repeaters, + required Iterable displayIds, + Map identities = const {}, + }) { + final resolved = {}; + final unresolved = []; + + for (final rawId in displayIds) { + final displayId = rawId.toUpperCase(); + if (displayId.isEmpty) continue; + + final identity = identities[displayId]?.toUpperCase(); + if (identity == null || identity.isEmpty) { + unresolved.add(displayId); + continue; + } + + // Both values are the leading hex of the same public key, so whichever + // is shorter is a prefix of the other. + final matches = repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + if (hexId.isEmpty) return false; + return hexId.startsWith(identity) || identity.startsWith(hexId); + }).toList(growable: false); + + if (matches.length == 1) { + resolved[displayId] = matches.single; + } else { + // No match at all means the catalogue simply has no hex for this + // repeater; more than one means the fuller identity did not help. + // Either way the ordinary prefix rules get their turn. + unresolved.add(displayId); + } + } + + resolved.addAll(resolveUniqueHexPrefixes( + repeaters: repeaters, + prefixes: unresolved, + )); + return resolved; + } + + /// Dot colour for an overlay row, mirroring `_overlayTypeColor` on the map. + static ExternalSurfaceColor overlayTypeColor(OverlayPingType type) => + switch (type) { + OverlayPingType.tx => + ExternalSurfaceColor.fromColor(PingColors.txSuccess), + OverlayPingType.disc => + ExternalSurfaceColor.fromColor(PingColors.discSuccess), + OverlayPingType.trace => + ExternalSurfaceColor.fromColor(PingColors.traceSuccess), + OverlayPingType.rx => ExternalSurfaceColor.fromColor(PingColors.rx), + }; + + /// SNR quality colour shared by every native glance surface. + static ExternalSurfaceColor snrColor(double snr) => + ExternalSurfaceColor.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 > ExternalSurfaceGeoWire.maxHeard + ? rows.sublist(0, ExternalSurfaceGeoWire.maxHeard) + : rows; + } + + static ExternalSurfaceHeardNode _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 = ExternalSurfaceGeoWire.distanceMeters( + lat, lon, repeater.lat, repeater.lon); + } + + return ExternalSurfaceHeardNode( + 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/external_surfaces/geo/external_surface_geo_models.dart b/lib/services/external_surfaces/geo/external_surface_geo_models.dart new file mode 100644 index 00000000..3204ec68 --- /dev/null +++ b/lib/services/external_surfaces/geo/external_surface_geo_models.dart @@ -0,0 +1,182 @@ +import 'dart:math' as math; + +import '../external_surface_color.dart'; + +class ExternalSurfaceGeoWire { + ExternalSurfaceGeoWire._(); + + static const int maxPings = 60; + static const int maxRepeaters = 20; + static const int maxHeard = 4; + static const double minMoveMeters = 15.0; + + 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. + 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; +} + +class ExternalSurfacePosition { + const ExternalSurfacePosition({ + 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 ExternalSurfacePing { + const ExternalSurfacePing({ + 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; + final String kind; + final ExternalSurfaceColor color; + final DateTime at; + + Map toMap() => { + 'id': id, + 'lat': lat, + 'lon': lon, + 'kind': kind, + 'color': color.toMap(), + 'atMs': at.millisecondsSinceEpoch.toDouble(), + }; +} + +class ExternalSurfaceRepeater { + const ExternalSurfaceRepeater({ + 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 ExternalSurfaceColor color; + final bool heardThisCycle; + + Map toMap() => { + 'id': id, + 'hexId': hexId, + 'name': name, + 'lat': lat, + 'lon': lon, + 'color': color.toMap(), + 'heardThisCycle': heardThisCycle, + }; +} + +class ExternalSurfaceHeardNode { + const ExternalSurfaceHeardNode({ + required this.id, + required this.typeColor, + required this.at, + this.name, + this.snr, + this.distanceM, + this.snrColor, + }); + + final String id; + final String? name; + final double? snr; + final DateTime at; + final double? distanceM; + final ExternalSurfaceColor? snrColor; + final ExternalSurfaceColor typeColor; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr, + 'atMs': at.millisecondsSinceEpoch.toDouble(), + 'distanceM': distanceM, + 'snrColor': snrColor?.toMap(), + 'typeColor': typeColor.toMap(), + }; +} + +class ExternalSurfaceGeo { + const ExternalSurfaceGeo({ + required this.pings, + required this.repeaters, + required this.heard, + required this.linkedRepeaterIds, + this.you, + }); + + final ExternalSurfacePosition? 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((ping) => ping.toMap()).toList() + : const [], + 'repeaters': includeMapDetail + ? repeaters.map((repeater) => repeater.toMap()).toList() + : const [], + 'heard': heard.map((node) => node.toMap()).toList(), + 'linkedRepeaterIds': includeMapDetail ? linkedRepeaterIds : const [], + }; +} diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart index 8feddb31..da9e8d8f 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../utils/debug_logger_io.dart'; +import '../external_surfaces/external_surface_publisher.dart'; import 'live_activity_models.dart'; typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function(); @@ -27,8 +28,27 @@ class LiveActivityService { @visibleForTesting Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'), - _unavailableRetryDelay = unavailableRetryDelay, - _minimumNonUrgentInterval = minimumNonUrgentInterval; + _unavailableRetryDelay = unavailableRetryDelay { + _publisher = + ExternalSurfacePublisher>( + debounceDelay: _debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform && !_hostCannotHostActivities, + preflightPolicy: ExternalSurfacePreflightPolicy.throttleAgainstLastBuild, + payloadBuilder: (snapshot) => snapshot.toMap(), + fingerprintBuilder: (payload) { + final fingerprint = Map.from(payload) + ..remove('updatedAt'); + return jsonEncode(fingerprint); + }, + urgencyKeyBuilder: (snapshot) => snapshot.urgencyKey, + publish: _publishSnapshot, + clear: _endCurrentActivity, + onQueueError: (error) { + debugError('[LIVE ACTIVITY] Update queue failed: $error'); + }, + ); + } static const Duration _debounceDelay = Duration(milliseconds: 200); @@ -58,30 +78,11 @@ class LiveActivityService { final MethodChannel _channel; final Duration _unavailableRetryDelay; - final Duration _minimumNonUrgentInterval; - - Timer? _scheduledUpdate; - LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; - LiveActivityUrgencyKeyBuilder? _pendingUrgencyKeyBuilder; - DateTime? _lastBuiltAt; - String? _lastPayload; - String? _lastUrgencyKey; - - /// The preflight key as of the last build, kept apart from [_lastUrgencyKey] - /// because the two are different projections: this one omits the ping colour - /// so it can be produced without walking a ping history. - String? _lastPreflightUrgencyKey; - DateTime? _lastSentAt; + late final ExternalSurfacePublisher> _publisher; String? _unavailableSessionId; DateTime? _unavailableRetryAt; - - /// Set when a flush was deferred for a reason unrelated to how much changed — - /// ActivityKit reporting itself unavailable, or refusing a `request` from the - /// background. Those retries must not be swallowed by the build floor below, - /// which would otherwise reason "nothing urgent moved" and never re-ask. - bool _bypassBuildFloor = false; bool _disposed = false; - bool _didReconcileNativeState = false; /// Set once native reports a condition that cannot change while this process /// lives: an OS without ActivityKit, or a host with no such channel at all. @@ -91,7 +92,6 @@ class LiveActivityService { /// channel round trip apiece, on devices that were never going to show /// anything. bool _hostCannotHostActivities = false; - Future _operationChain = Future.value(); bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; @@ -102,69 +102,18 @@ class LiveActivityService { bool immediate = false, }) { if (_disposed || !isSupportedPlatform || _hostCannotHostActivities) return; - - _pendingSnapshotBuilder = snapshotBuilder; - _pendingUrgencyKeyBuilder = urgencyKeyBuilder; - _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'); - }, + _publisher.schedule( + snapshotBuilder, + preflightKeyBuilder: urgencyKeyBuilder, + immediate: immediate, ); } - Future _flush() async { - _scheduledUpdate?.cancel(); - _scheduledUpdate = null; - - if (_disposed || !isSupportedPlatform || _hostCannotHostActivities) return; - - // The 500 ms countdown listenable drives this at ~2 Hz for a whole session, - // and building a snapshot resolves the latest ping colour by walking the - // TX, RX, discovery and trace logs — up to 500 entries each — only for the - // result to be discarded as a duplicate. So decide whether this flush can - // wait *before* constructing or encoding anything, from a key that reads - // scalars off the provider. Mirrors `WatchBridgeService._flush`. - final pendingUrgencyKey = _pendingUrgencyKeyBuilder?.call(); - final endAlreadyDelivered = - pendingUrgencyKey == null && _lastPayload == null; - if (endAlreadyDelivered && _didReconcileNativeState) return; - - final predictedUrgent = pendingUrgencyKey != _lastPreflightUrgencyKey; - final lastBuiltAt = _lastBuiltAt; - if (!_bypassBuildFloor && !predictedUrgent && lastBuiltAt != null) { - final elapsed = DateTime.now().difference(lastBuiltAt); - if (elapsed < _minimumNonUrgentInterval) { - // The deferral keeps any outstanding bypass, so a retry that is waiting - // on this floor still gets its turn rather than being dropped. - _scheduledUpdate = Timer( - _minimumNonUrgentInterval - elapsed, - _enqueueFlush, - ); - return; - } - } - - _bypassBuildFloor = false; - _lastBuiltAt = DateTime.now(); - _lastPreflightUrgencyKey = pendingUrgencyKey; - final snapshot = _pendingSnapshotBuilder?.call(); - if (snapshot == null) { - if (_lastPayload == null && _didReconcileNativeState) return; - await _endCurrentActivity(immediate: _lastPayload == null); - return; - } - + Future _publishSnapshot( + ExternalSurfacePublication> + publication, + ) async { + final snapshot = publication.snapshot; if (_unavailableSessionId == snapshot.sessionId) { final retryAt = _unavailableRetryAt; final now = DateTime.now(); @@ -172,9 +121,9 @@ class LiveActivityService { // 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. - _bypassBuildFloor = true; - _scheduledUpdate = Timer(retryAt.difference(now), _enqueueFlush); - return; + return ExternalSurfacePublishResult.rejected( + retryAfter: retryAt.difference(now), + ); } _unavailableSessionId = null; _unavailableRetryAt = null; @@ -183,61 +132,39 @@ class LiveActivityService { _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; + final result = + await _channel.invokeMethod('sync', publication.payload); if (result == unsupportedHost) { _stopForUnsupportedHost('iOS 16.2 is required for Live Activities'); - return; + return const ExternalSurfacePublishResult.disable(); } if (result == false) { _unavailableSessionId = snapshot.sessionId; _unavailableRetryAt = DateTime.now().add(_unavailableRetryDelay); - _bypassBuildFloor = true; - _scheduledUpdate = Timer(_unavailableRetryDelay, _enqueueFlush); debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); - return; + return ExternalSurfacePublishResult.rejected( + retryAfter: _unavailableRetryDelay, + ); } _unavailableSessionId = null; _unavailableRetryAt = null; - _lastPayload = encoded; - _lastUrgencyKey = snapshot.urgencyKey; - _lastSentAt = DateTime.now(); + return const ExternalSurfacePublishResult.published(); } on MissingPluginException { // A host with no such channel — a non-iOS test host, or an iOS project // generated before this feature existed. Neither gains one at runtime, // so this is the same permanent condition as an OS below 16.2. _stopForUnsupportedHost('no Live Activity channel on this host'); + return const ExternalSurfacePublishResult.disable(); } on PlatformException catch (error) { debugError( '[LIVE ACTIVITY] ActivityKit sync failed: ' '${error.code}: ${error.message}', ); + return const ExternalSurfacePublishResult.rejected(); } catch (error) { debugError('[LIVE ACTIVITY] Unexpected sync failure: $error'); + return const ExternalSurfacePublishResult.rejected(); } } @@ -246,14 +173,9 @@ class LiveActivityService { void _stopForUnsupportedHost(String reason) { if (_hostCannotHostActivities) return; _hostCannotHostActivities = true; - _didReconcileNativeState = true; - _scheduledUpdate?.cancel(); - _scheduledUpdate = null; - _pendingSnapshotBuilder = null; - _pendingUrgencyKeyBuilder = null; + _publisher.disable(); _unavailableSessionId = null; _unavailableRetryAt = null; - _bypassBuildFloor = false; debugLog('[LIVE ACTIVITY] Disabled for this session: $reason'); } @@ -273,24 +195,14 @@ class LiveActivityService { } catch (error) { debugError('[LIVE ACTIVITY] Unexpected end failure: $error'); } finally { - _didReconcileNativeState = true; - _lastPayload = null; - _lastUrgencyKey = null; - _lastPreflightUrgencyKey = null; - _lastSentAt = null; - _lastBuiltAt = null; _unavailableSessionId = null; _unavailableRetryAt = null; - _bypassBuildFloor = false; } } void dispose() { _disposed = true; - _scheduledUpdate?.cancel(); - _scheduledUpdate = null; - _pendingSnapshotBuilder = null; - _pendingUrgencyKeyBuilder = null; + _publisher.dispose(); _unavailableRetryAt = null; } } diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 63d176ed..94ce7304 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../utils/debug_logger_io.dart'; +import '../external_commands/external_session_commands.dart'; +import '../external_surfaces/external_surface_publisher.dart'; import 'watch_models.dart'; typedef WatchSnapshotBuilder = WatchSnapshot? Function(); @@ -71,9 +73,28 @@ class WatchBridgeService { @visibleForTesting Duration mapGeoClaimFreshFor = defaultMapGeoClaimFreshFor, }) : _channel = channel ?? const MethodChannel(_channelName), - _debounceDelay = debounceDelay, - _minimumNonUrgentInterval = minimumNonUrgentInterval, - _mapGeoClaimFreshFor = mapGeoClaimFreshFor; + _mapGeoClaimFreshFor = mapGeoClaimFreshFor { + _publisher = ExternalSurfacePublisher>( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => canSync, + preflightPolicy: + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency, + payloadBuilder: (snapshot) => snapshot.toMap(), + fingerprintBuilder: (payload) { + final fingerprint = Map.from(payload) + ..remove('updatedAtMs'); + return jsonEncode(fingerprint); + }, + urgencyKeyBuilder: (snapshot) => snapshot.urgencyKey, + publish: _publishSnapshot, + clear: _clear, + candidateGate: _passesMovementGate, + onQueueError: (error) { + debugError('[WATCH] Update queue failed: $error'); + }, + ); + } static const String _channelName = 'meshmapper/watch'; @@ -118,46 +139,29 @@ class WatchBridgeService { /// covered at all. [LiveActivityService] already took its intervals this way; /// this closes the gap between the two. /// - /// Deliberately *not* injectable: [_maximumCommandAge] and [_clockTolerance] - /// are compared against timestamps a test can choose freely, so shortening - /// them would buy nothing and would let a test pass against a window the - /// wire does not actually use. + /// Deliberately *not* injectable: [maximumExternalCommandAge] and + /// [externalCommandFutureTolerance] are compared against timestamps a test + /// can choose freely, so shortening them would buy nothing and would let a + /// test pass against a window the wire does not actually use. static const Duration defaultDebounceDelay = Duration(milliseconds: 200); static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 2); static const Duration defaultMapGeoClaimFreshFor = Duration(minutes: 10); - static const Duration _maximumCommandAge = Duration(seconds: 30); - - /// Residual slack after the watch's own clock-offset correction, not the - /// whole budget for two devices disagreeing about the time. A watch that has - /// measured the offset sends it with every command, so what has to fit inside - /// this is transit and measurement error — not the skew itself. - static const Duration _clockTolerance = Duration(seconds: 5); - final MethodChannel _channel; - final Duration _debounceDelay; - final Duration _minimumNonUrgentInterval; final Duration _mapGeoClaimFreshFor; + late final ExternalSurfacePublisher> + _publisher; - Timer? _scheduledUpdate; - WatchSnapshotBuilder? _pendingSnapshotBuilder; - WatchUrgencyKeyBuilder? _pendingUrgencyKeyBuilder; WatchCommandHandler? _commandHandler; WatchCommandRefusalHandler? _commandRefusalHandler; WatchAvailabilityHandler? _availabilityHandler; - 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. /// @@ -171,7 +175,6 @@ class WatchBridgeService { /// /// 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; @@ -181,7 +184,6 @@ class WatchBridgeService { ValueNotifier(const WatchDiagnosticStatus()); DateTime? _mapGeoSuppressedAt; double? _lastMapGeoClaimIssuedAtMs; - Future _operationChain = Future.value(); /// Outcome of every command already handled, keyed by ID, so redelivery /// cannot fire a second transmit — and is answered with what actually @@ -273,7 +275,7 @@ class WatchBridgeService { // been able to measure it — from a live `sendMessage`, whose transit is // milliseconds. Correcting by it means the age below is a real elapsed // time rather than an elapsed time plus however far the clocks disagree, - // which used to turn any skew past `_clockTolerance` into "every command + // which used to turn any skew past the future tolerance into "every command // refused" instead of a slightly wider margin. // // Absent from older watch builds, and from a watch that has not yet seen a @@ -287,8 +289,8 @@ class WatchBridgeService { final requestedMapGeo = args['mapGeoNeeded']; final mapGeoNeeded = requestedMapGeo is bool ? requestedMapGeo : null; final freshMapGeoSuppression = ageMs != null && - ageMs >= -_clockTolerance.inMilliseconds && - ageMs <= _maximumCommandAge.inMilliseconds; + ageMs >= -externalCommandFutureTolerance.inMilliseconds && + ageMs <= maximumExternalCommandAge.inMilliseconds; final latestMapGeoClaim = _lastMapGeoClaimIssuedAtMs; final suppressionIsNewest = issuedAtMs != null && (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim); @@ -323,9 +325,9 @@ class WatchBridgeService { // 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'; + if (ageMs! > maximumExternalCommandAge.inMilliseconds || + ageMs < -externalCommandFutureTolerance.inMilliseconds) { + final reason = externalCommandExpiredReason; // 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. @@ -425,12 +427,9 @@ class WatchBridgeService { if (!available || refreshNativeState) { _mapGeoSuppressedAt = null; _lastMapGeoClaimIssuedAtMs = null; - _lastPayload = null; + _publisher.resetPublishedState(); _lastPayloadWithoutFix = null; _lastSentFix = null; - _lastUrgencyKey = null; - _lastSentAt = null; - _lastBuiltAt = null; } _availabilityHandler?.call(available); } @@ -475,74 +474,17 @@ class WatchBridgeService { 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'); - }, + _publisher.schedule( + snapshotBuilder, + preflightKeyBuilder: urgencyKeyBuilder, + immediate: immediate, + forceDelivery: forceDelivery, ); } - 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; - + bool _passesMovementGate( + ExternalSurfacePublication> publication, + ) { // 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. @@ -553,35 +495,25 @@ class WatchBridgeService { // 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 fingerprint = Map.from(publication.payload) + ..remove('updatedAtMs'); final fix = _fixOf(fingerprint); final withoutFix = _encodeWithoutFix(fingerprint); - if (!force && + if (!publication.forceDelivery && 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; - } + return false; } + return true; + } + Future _publishSnapshot( + ExternalSurfacePublication> publication, + ) async { try { final delivered = await _channel.invokeMethod('sync', { - 'payload': payload, - 'urgent': urgent, + 'payload': publication.payload, + 'urgent': publication.urgent, }); if (delivered != true) { _lastSendDelivered = false; @@ -591,35 +523,32 @@ class WatchBridgeService { // failed, so a transient context error cannot permanently close the // gate while the watch is actually still installed. await _refreshAvailability(); - return; + return const ExternalSurfacePublishResult.rejected(); } - _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 fingerprint = Map.from(publication.payload) + ..remove('updatedAtMs'); + _lastPayloadWithoutFix = _encodeWithoutFix(fingerprint); + _lastSentFix = _fixOf(fingerprint); final sentAt = DateTime.now(); - _lastSentAt = sentAt; _lastSuccessfulSendAt = sentAt; _lastSendDelivered = true; _publishDiagnostics(); + return const ExternalSurfacePublishResult.published(); } on MissingPluginException { // Expected on non-iOS hosts and in tests. + return const ExternalSurfacePublishResult.rejected(); } on PlatformException catch (error) { debugError('[WATCH] Sync failed: ${error.code}: ${error.message}'); + return const ExternalSurfacePublishResult.rejected(); } catch (error) { debugError('[WATCH] Unexpected sync failure: $error'); + return const ExternalSurfacePublishResult.rejected(); } } - Future _clear() async { + Future _clear({required bool immediate}) async { try { await _channel.invokeMethod('clear'); } on MissingPluginException { @@ -629,13 +558,8 @@ class WatchBridgeService { } catch (error) { debugError('[WATCH] Unexpected clear failure: $error'); } finally { - _didReconcileNativeState = true; - _lastPayload = null; _lastPayloadWithoutFix = null; _lastSentFix = null; - _lastUrgencyKey = null; - _lastSentAt = null; - _lastBuiltAt = null; } } @@ -685,10 +609,7 @@ class WatchBridgeService { void dispose() { _disposed = true; - _scheduledUpdate?.cancel(); - _scheduledUpdate = null; - _pendingSnapshotBuilder = null; - _pendingUrgencyKeyBuilder = null; + _publisher.dispose(); _commandHandler = null; _commandRefusalHandler = null; _availabilityHandler = null; diff --git a/lib/services/watch/watch_color.dart b/lib/services/watch/watch_color.dart index 6c62c4a2..f57ff876 100644 --- a/lib/services/watch/watch_color.dart +++ b/lib/services/watch/watch_color.dart @@ -1,29 +1,3 @@ -import 'dart:ui' show Color; +import '../external_surfaces/external_surface_color.dart'; -/// 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); -} +typedef WatchColor = ExternalSurfaceColor; diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 01595f65..750b28c4 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -1,460 +1,3 @@ -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'; +import '../external_surfaces/geo/external_surface_geo_builder.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; - } - - /// Resolve one identifier to a repeater, or null when it is not unambiguous. - /// - /// Tries exact identity first — the catalogue's short ID, its hex ID, or the - /// hex truncated to the zone's hop width, which is the form an overlay row is - /// labelled with — and only then a unique prefix relation. - /// - /// **A repeater with no `hexId` is never a prefix candidate.** The API omits - /// `hex_id` often enough that `Repeater.fromJson` defaults it to the empty - /// string, and `id.startsWith('')` is true for every id, so a single such - /// entry silently made every prefix lookup ambiguous and every name vanish. - static Repeater? resolveRepeater({ - required List repeaters, - required String id, - int? hopBytes, - }) { - final needle = id.toUpperCase(); - if (needle.isEmpty) return null; - - final exact = repeaters.where((repeater) { - return repeater.id.toUpperCase() == needle || - repeater.hexId.toUpperCase() == needle || - repeater.displayHexId(overrideHopBytes: hopBytes).toUpperCase() == - needle; - }).toList(growable: false); - if (exact.length == 1) return exact.single; - if (exact.isNotEmpty || needle.length < 2) return null; - - final prefixed = repeaters.where((repeater) { - final hexId = repeater.hexId.toUpperCase(); - if (hexId.isEmpty) return false; - return hexId.startsWith(needle) || needle.startsWith(hexId); - }).toList(growable: false); - return prefixed.length == 1 ? prefixed.single : null; - } - - /// Resolve overlay display hashes to repeaters, preferring the fuller - /// identity the ping actually carried. - /// - /// A path hash is 1–3 bytes, so in a busy zone it routinely matches several - /// repeaters and [resolveUniqueHexPrefixes] correctly refuses to name any of - /// them. But the phone often knows exactly who answered: a discovery response - /// carries the responder's full 64-character public key, and a trace carries - /// its 4-byte target. Resolving from the truncated hash threw that away, and - /// left the wrist reading "Name unresolved" for a node the phone could have - /// named precisely. - /// - /// [identities] maps a display hash to the longest identity known for the row - /// shown under it. It must describe the **current** rows only: a hash that - /// meant one repeater in a discovery response says nothing about who a later - /// TX echo under the same hash was, and carrying it over would turn a refusal - /// to guess into a confident wrong answer. - /// - /// Uniqueness is still required. A longer identity makes a collision - /// vanishingly unlikely rather than impossible, and a confidently wrong name - /// stays worse than none. - static Map resolveOverlayRepeaters({ - required List repeaters, - required Iterable displayIds, - Map identities = const {}, - }) { - final resolved = {}; - final unresolved = []; - - for (final rawId in displayIds) { - final displayId = rawId.toUpperCase(); - if (displayId.isEmpty) continue; - - final identity = identities[displayId]?.toUpperCase(); - if (identity == null || identity.isEmpty) { - unresolved.add(displayId); - continue; - } - - // Both values are the leading hex of the same public key, so whichever - // is shorter is a prefix of the other. - final matches = repeaters.where((repeater) { - final hexId = repeater.hexId.toUpperCase(); - if (hexId.isEmpty) return false; - return hexId.startsWith(identity) || identity.startsWith(hexId); - }).toList(growable: false); - - if (matches.length == 1) { - resolved[displayId] = matches.single; - } else { - // No match at all means the catalogue simply has no hex for this - // repeater; more than one means the fuller identity did not help. - // Either way the ordinary prefix rules get their turn. - unresolved.add(displayId); - } - } - - resolved.addAll(resolveUniqueHexPrefixes( - repeaters: repeaters, - prefixes: unresolved, - )); - 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; - } -} +typedef WatchGeoBuilder = ExternalSurfaceGeoBuilder; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 1c29b2ee..40a0fb19 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -1,5 +1,5 @@ -import 'dart:math' as math; - +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/geo/external_surface_geo_models.dart'; import '../live_activity/live_activity_models.dart'; import 'watch_color.dart'; @@ -33,11 +33,11 @@ class WatchWire { /// strand compatible pairs without preventing a bad decode. static const int version = 2; - static const int maxPings = 60; - static const int maxRepeaters = 20; + static const int maxPings = ExternalSurfaceGeoWire.maxPings; + static const int maxRepeaters = ExternalSurfaceGeoWire.maxRepeaters; /// Three top-SNR rows plus the RX slot, matching `_buildTopRepeatersOverlay`. - static const int maxHeard = 4; + static const int maxHeard = ExternalSurfaceGeoWire.maxHeard; /// How long a one-shot cue keeps riding outgoing snapshots. /// @@ -51,7 +51,7 @@ class WatchWire { /// 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; + static const double minMoveMeters = ExternalSurfaceGeoWire.minMoveMeters; /// True when the fix moved far enough to be worth an update on its own. /// @@ -65,8 +65,13 @@ class WatchWire { required double lon, double thresholdMeters = minMoveMeters, }) { - if (lastLat == null || lastLon == null) return true; - return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; + return ExternalSurfaceGeoWire.movedEnough( + lastLat: lastLat, + lastLon: lastLon, + lat: lat, + lon: lon, + thresholdMeters: thresholdMeters, + ); } /// Great-circle distance in metres. @@ -79,18 +84,8 @@ class WatchWire { 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)); + return ExternalSurfaceGeoWire.distanceMeters(lat1, lon1, lat2, lon2); } - - static double _toRadians(double degrees) => degrees * math.pi / 180.0; } /// Start modes the phone may explicitly offer to the wrist. @@ -110,165 +105,11 @@ enum WatchStartMode { } } -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 [], - }; -} +typedef WatchPosition = ExternalSurfacePosition; +typedef WatchPing = ExternalSurfacePing; +typedef WatchRepeater = ExternalSurfaceRepeater; +typedef WatchHeardNode = ExternalSurfaceHeardNode; +typedef WatchGeo = ExternalSurfaceGeo; /// What the wrist may do right now. /// @@ -547,6 +388,25 @@ WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ return (mode: mode, refusal: null); } +/// Mode used when an older watch sends the pre-mode-picker generic Start. +/// +/// Trace is deliberately absent from the shared external-command model: it +/// requires a phone-selected target. Falling back to Passive keeps an older +/// watch usable after the phone last ran Trace without silently targeting a +/// stale repeater. Other established modes retain the legacy behaviour. +String resolveLegacyWatchStartMode({ + required String currentMode, + required bool isConnected, + required bool txAllowed, +}) { + if (!isConnected || !txAllowed || currentMode == 'targeted') { + return 'passive'; + } + return const {'active', 'passive', 'hybrid'}.contains(currentMode) + ? currentMode + : 'passive'; +} + /// Whether Settings should offer the watch diagnostics screen. /// /// The screen explains why the phone is or is not talking to a watch, so the @@ -607,9 +467,10 @@ List resolveAvailableWatchStartModes({ WatchStartMode.hybrid, ]; -typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); - -typedef SessionStartAvailability = ({bool allowed, String? reason}); +typedef SessionStartAvailability = ({ + bool allowed, + ExternalCommandReason? reason, +}); /// One start-admission rule shared by the wrist snapshot and command handler. /// @@ -642,38 +503,55 @@ SessionStartAvailability resolveSessionStartAvailability({ required bool txBlockedByOffline, required bool txNotAllowed, required bool floodTrafficEnabled, - required String? transmitValidationReason, + required ExternalCommandReason? transmitValidationReason, }) { - if (!isConnected) return (allowed: false, reason: 'Not connected'); - if (isPendingDisable) return (allowed: false, reason: 'Still stopping'); + if (!isConnected) { + return (allowed: false, reason: ExternalCommandReason.notConnected); + } + if (isPendingDisable) { + return (allowed: false, reason: ExternalCommandReason.stillStopping); + } if (isTargetedRunning) { - return (allowed: false, reason: 'Trace session active'); + return (allowed: false, reason: ExternalCommandReason.traceSessionActive); + } + if (isAutoStarting) { + return (allowed: false, reason: ExternalCommandReason.alreadyStarting); } - if (isAutoStarting) return (allowed: false, reason: 'Already starting'); if (!antennaConfigured) { - return (allowed: false, reason: 'Select antenna option'); + return (allowed: false, reason: ExternalCommandReason.selectAntennaOption); } if (!powerConfigured) { - return (allowed: false, reason: 'Select power level'); + return (allowed: false, reason: ExternalCommandReason.selectPowerLevel); } // Timing guards: every mode reaches the radio, so every mode waits. - if (cooldownActive) return (allowed: false, reason: 'Cooling down'); - if (isPingSending) return (allowed: false, reason: 'Ping in progress'); + if (cooldownActive) { + return (allowed: false, reason: ExternalCommandReason.coolingDown); + } + if (isPingSending) { + return (allowed: false, reason: ExternalCommandReason.pingInProgress); + } if (rxWindowActive) { - return (allowed: false, reason: 'Listening for ping response'); + return ( + allowed: false, + reason: ExternalCommandReason.listeningForPingResponse, + ); } if (!isTransmitMode) return (allowed: true, reason: null); - if (txBlockedByOffline) return (allowed: false, reason: 'Offline Mode'); - if (txNotAllowed) return (allowed: false, reason: 'Passive Only'); + if (txBlockedByOffline) { + return (allowed: false, reason: ExternalCommandReason.offlineMode); + } + if (txNotAllowed) { + return (allowed: false, reason: ExternalCommandReason.passiveOnly); + } // True whether the wearer turned flood traffic off or a regional admin did; // the phone's own gate is the same single effective value, and the wearer's // next step — look at the phone, where Settings says which it was — is the // same either way. if (!floodTrafficEnabled) { - return (allowed: false, reason: 'Flood Traffic Off'); + return (allowed: false, reason: ExternalCommandReason.floodTrafficOff); } if (transmitValidationReason != null) { return (allowed: false, reason: transmitValidationReason); @@ -681,55 +559,6 @@ SessionStartAvailability resolveSessionStartAvailability({ 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. -/// -/// [requestedSessionId] is the session the wrist meant, and is compared against -/// [currentSessionId] before a stop is admitted. Both are needed because a stop -/// is deliberately exempt from the transmit-age window: it can arrive -/// arbitrarily late, and "stop the session" is only unambiguous while there is -/// one session it could mean. A null request is an older watch build and keeps -/// the previous behaviour. -WatchCommandAdmission resolveWatchSessionCommandAdmission({ - required WatchCommandKind kind, - required bool isSessionActive, - required bool isSessionStarting, - String? requestedSessionId, - String? currentSessionId, -}) { - 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', - ); - } - // Checked only once there is a session to stop. With none running the - // stop stays the harmless no-op it has always been, rather than becoming - // a refusal for a session that is already over. - if (isSessionActive && - requestedSessionId != null && - requestedSessionId != currentSessionId) { - return ( - shouldRun: false, - refusal: 'That session already ended', - ); - } - 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. diff --git a/test/services/app_intents/app_intent_bridge_service_test.dart b/test/services/app_intents/app_intent_bridge_service_test.dart index ce4599d5..62ebad3c 100644 --- a/test/services/app_intents/app_intent_bridge_service_test.dart +++ b/test/services/app_intents/app_intent_bridge_service_test.dart @@ -14,19 +14,15 @@ void main() { late MethodChannel channel; late AppIntentBridgeService bridge; late List> published; - var clearCalls = 0; setUp(() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; channel = const MethodChannel('meshmapper/app_intents_test'); published = []; - clearCalls = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { if (call.method == 'publishSnapshot') { published.add(call.arguments as Map); - } else if (call.method == 'clearSnapshot') { - clearCalls++; } return null; }); @@ -76,7 +72,9 @@ void main() { return const ExternalCommandCompletion( success: true, disposition: ExternalCommandDisposition.admitted, - message: 'MeshMapper started in Passive Discovery mode.', + message: ExternalCommandReason.other( + 'MeshMapper started in Passive Discovery mode.', + ), sessionId: 'session-1', mode: ExternalSessionMode.passive, ); @@ -101,6 +99,51 @@ void main() { expect(calls, 1); }); + test('concurrent duplicate mutations share one in-flight execution', + () async { + var calls = 0; + final released = Completer(); + bridge.attachCommandHandler((command) async { + calls++; + await released.future; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other('Connected'), + ); + }); + + final first = sendCommand('command-concurrent'); + final duplicate = sendCommand('command-concurrent'); + await Future.delayed(Duration.zero); + expect(calls, 1); + + released.complete(); + expect(await duplicate, await first); + expect(calls, 1); + }); + + test('concurrent duplicates share the same handler failure refusal', + () async { + var calls = 0; + final released = Completer(); + bridge.attachCommandHandler((command) async { + calls++; + await released.future; + throw StateError('failed once'); + }); + + final first = sendCommand('command-concurrent-failure'); + final duplicate = sendCommand('command-concurrent-failure'); + await Future.delayed(Duration.zero); + expect(calls, 1); + + released.complete(); + expect(await duplicate, await first); + expect(await first, containsPair('message', 'Command failed')); + expect(calls, 1); + }); + test('wrong-source and malformed commands fail closed', () async { bridge.attachCommandHandler((_) async => throw StateError('not called')); @@ -118,7 +161,9 @@ void main() { return const ExternalCommandCompletion( success: true, disposition: ExternalCommandDisposition.admitted, - message: 'MeshMapper connected to Test Radio.', + message: ExternalCommandReason.other( + 'MeshMapper connected to Test Radio.', + ), ); }); @@ -141,20 +186,70 @@ void main() { 'session': {'active': active}, }; - bridge.schedule(() => snapshot(1), immediate: true); + var builds = 0; + Map build(int updatedAtMs, {bool active = false}) { + builds++; + return snapshot(updatedAtMs, active: active); + } + + bridge.schedule( + () => build(1), + preflightKeyBuilder: () => 'idle', + immediate: true, + ); await Future.delayed(const Duration(milliseconds: 10)); - bridge.schedule(() => snapshot(2), immediate: true); + bridge.schedule( + () => build(2), + preflightKeyBuilder: () => 'idle', + immediate: true, + ); await Future.delayed(const Duration(milliseconds: 10)); - bridge.schedule(() => snapshot(3, active: true), immediate: true); + bridge.schedule( + () => build(3, active: true), + preflightKeyBuilder: () => 'active', + immediate: true, + ); await Future.delayed(const Duration(milliseconds: 10)); + expect(builds, 2, + reason: 'an unchanged preflight must skip the expensive builder'); expect(published, hasLength(2)); expect((published.last['session'] as Map)['active'], isTrue); }); - test('clearSnapshot uses the independent App Intents channel', () async { - await bridge.clearSnapshot(); - expect(clearCalls, 1); + test('preflight dedupe starts only after a snapshot is published', + () async { + var attempts = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'publishSnapshot') return null; + attempts++; + if (attempts == 1) { + throw PlatformException(code: 'not-written'); + } + published.add(call.arguments as Map); + return null; + }); + + var builds = 0; + Map build() { + builds++; + return {'version': 1, 'updatedAtMs': builds}; + } + + for (var delivery = 0; delivery < 3; delivery++) { + bridge.schedule( + build, + preflightKeyBuilder: () => 'unchanged', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + } + + expect(attempts, 2); + expect(builds, 2, + reason: 'a refused first publish must not arm preflight dedupe'); + expect(published, hasLength(1)); }); }); } diff --git a/test/services/app_intents/last_companion_connection_test.dart b/test/services/app_intents/last_companion_connection_test.dart index 14c78610..2606494a 100644 --- a/test/services/app_intents/last_companion_connection_test.dart +++ b/test/services/app_intents/last_companion_connection_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/app_intents/app_intent_commands.dart'; import 'package:mesh_mapper/services/app_intents/last_companion_connection.dart'; import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; void main() { final now = DateTime.fromMillisecondsSinceEpoch(100000); @@ -41,36 +42,40 @@ void main() { ); expect(result.disposition, ExternalCommandDisposition.noOp); - expect(result.reason, 'Already connected'); + expect(result.reason?.compactText, 'Already connected'); }); test('fails closed for missing, busy, switched, and USB companions', () { expect( - resolve(remembered: false).reason, + resolve(remembered: false).reason?.compactText, 'No remembered companion', ); expect( - resolve(connecting: true).reason, + resolve(connecting: true).reason?.compactText, 'Already connecting', ); expect( - resolve(connected: true).reason, + resolve(connected: true).reason?.compactText, 'Another companion is connected', ); expect( - resolve(unattended: false).reason, + resolve(unattended: false).reason?.compactText, 'User interaction required', ); }); test('stale and future connect commands are refused', () { expect( - resolve(issuedAt: now.subtract(const Duration(seconds: 31))).reason, - 'Took too long to reach iPhone', + resolve(issuedAt: now.subtract(const Duration(seconds: 31))) + .reason + ?.compactText, + externalCommandExpiredReason, ); expect( - resolve(issuedAt: now.add(const Duration(seconds: 6))).reason, - 'Took too long to reach iPhone', + resolve(issuedAt: now.add(const Duration(seconds: 6))) + .reason + ?.compactText, + externalCommandExpiredReason, ); }); } diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart index 8e60f4e1..14009062 100644 --- a/test/services/app_intents/siri_snapshot_builder_test.dart +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -295,4 +295,66 @@ void main() { isTrue, ); }); + + test('non-finite radio and repeater values cannot poison the snapshot', () { + const invalidLocation = Repeater( + id: 'invalid-location', + hexId: 'AABBCCDD', + name: 'Invalid Location', + lat: double.infinity, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + rx: [ + RxLogEntry( + timestamp: now, + repeaterId: 'AABBCCDD', + snr: double.nan, + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ], + repeaters: const [invalidLocation], + ); + + expect(observations.single.snr, isNull); + expect(observations.single.distanceM, isNull); + expect(observations.single.repeaterLat, isNull); + expect(observations.single.repeaterLon, isNull); + + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog( + const [invalidLocation], + ); + expect(catalog.single.latitude, isNull); + expect(catalog.single.longitude, isNull); + }); + + test('repeater catalogue is active-first, recent-first, and bounded', () { + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final repeaters = List.generate( + SiriSnapshotBuilder.maximumRepeaters + 10, + (index) => Repeater( + id: 'repeater-$index', + hexId: index.toRadixString(16).padLeft(8, '0'), + name: 'Repeater $index', + lat: 47.0 + index / 1000, + lon: -122.0, + lastHeard: nowSeconds - index, + enabled: 1, + iata: 'SEA', + staleTime: index == 7 ? nowSeconds + 3600 : nowSeconds - 1, + ), + ); + + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog(repeaters); + + expect(catalog, hasLength(SiriSnapshotBuilder.maximumRepeaters)); + expect(catalog.first.id, 'SEA|repeater-7'); + expect(catalog[1].id, 'SEA|repeater-0'); + }); } diff --git a/test/services/external_commands/external_session_commands_test.dart b/test/services/external_commands/external_session_commands_test.dart index d78e29bc..90c4f10f 100644 --- a/test/services/external_commands/external_session_commands_test.dart +++ b/test/services/external_commands/external_session_commands_test.dart @@ -101,20 +101,159 @@ void main() { } final trace = resolve(command(mode: 'targeted')); expect(trace.disposition, ExternalCommandDisposition.refused); - expect(trace.reason, 'Unsupported start mode'); + expect(trace.reason?.compactText, 'Unsupported start mode'); + }); + + test('voice refusal expands Passive Only with mode and next step', () { + final requested = command(mode: 'hybrid'); + final completion = externalCommandCompletionForVoice( + command: requested, + completion: const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.passiveOnly, + sessionId: 'session-1', + mode: ExternalSessionMode.hybrid, + ), + ); + + expect( + completion.message?.compactText, + "Hybrid mode isn't available in this region. Start Passive Discovery instead.", + ); + expect(completion.sessionId, 'session-1'); + expect(completion.mode, ExternalSessionMode.hybrid); + }); + + test('voice presentation expands common compact admission labels', () { + final requested = command(mode: 'active'); + final expected = { + ExternalCommandReason.notConnected: + "MeshMapper isn't connected. Reconnect it, then try again.", + ExternalCommandReason.notConnectedToDevice: + "MeshMapper isn't connected. Reconnect it, then try again.", + ExternalCommandReason.stillStopping: + 'MeshMapper is still stopping. Try again shortly.', + ExternalCommandReason.traceSessionActive: + 'Stop the active Trace session before starting Active mode.', + ExternalCommandReason.alreadyStarting: + 'MeshMapper is already starting a session.', + ExternalCommandReason.coolingDown: + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReason.waitFiveSeconds: + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReason.waitFifteenSeconds: + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReason.waitingForGpsLock: + 'MeshMapper is waiting for a GPS fix. Try again shortly.', + ExternalCommandReason.selectAntennaOption: + 'Select an antenna option in MeshMapper, then try again.', + ExternalCommandReason.selectAntennaOptionBeforePinging: + 'Select an antenna option in MeshMapper, then try again.', + ExternalCommandReason.selectPowerLevel: + 'Select a power level in MeshMapper, then try again.', + ExternalCommandReason.selectPowerLevelUnknownDevice: + 'Select a power level in MeshMapper, then try again.', + ExternalCommandReason.offlineMode: + "Active mode isn't available in Offline Mode. Start Passive Discovery instead.", + ExternalCommandReason.passiveOnly: + "Active mode isn't available in this region. Start Passive Discovery instead.", + ExternalCommandReason.zoneAtCapacity: + "Active mode isn't available in this region. Start Passive Discovery instead.", + ExternalCommandReason.floodTrafficOff: + 'Active mode requires Flood Traffic. Enable it or start Passive Discovery.', + ExternalCommandReason.pingInProgress: + 'A ping is already in progress. Try again when it finishes.', + ExternalCommandReason.listeningForPingResponse: + 'MeshMapper is listening for a response. Try again shortly.', + ExternalCommandReason.gpsDataStale: + 'MeshMapper needs a current GPS position. Let location refresh, then retry.', + ExternalCommandReason.gpsAccuracyLow: + 'GPS accuracy is too low. Try again when it improves.', + ExternalCommandReason.anotherOperationInProgress: + 'Another radio operation is in progress. Try again shortly.', + ExternalCommandReason.stillStartingTryStopAgain: + 'MeshMapper is still starting. Try Stop again shortly.', + ExternalCommandReason.sessionAlreadyEnded: + 'That session already ended. There is nothing to stop.', + ExternalCommandReason.couldNotStart: + "MeshMapper couldn't start Active mode.", + ExternalCommandReason.couldNotStop: + "MeshMapper couldn't stop. Check the app for details.", + ExternalCommandReason.pingFailed: + "MeshMapper couldn't send the manual ping. Open the app for details.", + ExternalCommandReason.appClosing: + 'MeshMapper is closing. Open it and try again.', + }; + + for (final entry in expected.entries) { + final completion = externalCommandCompletionForVoice( + command: requested, + completion: ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: entry.key, + ), + ); + expect( + completion.message?.compactText, + entry.value, + reason: entry.key.compactText, + ); + } + }); + + test('voice presentation preserves already complete responses', () { + final requested = command(mode: 'passive'); + const original = ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper started in Passive Discovery mode.', + ), + ); + + expect( + externalCommandCompletionForVoice( + command: requested, + completion: original, + ), + same(original), + ); }); test('duplicate Start and idle Stop are first-class no-ops', () { final duplicate = resolve(command(), active: true); + final duplicateStarting = resolve(command(), starting: true); final idleStop = resolve(command( kind: ExternalSessionCommandKind.stopSession, mode: null, )); + final stoppingWhileStarting = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + ), + starting: true, + ); expect(duplicate.disposition, ExternalCommandDisposition.noOp); - expect(duplicate.reason, contains('already running')); + expect(duplicate.reason?.compactText, contains('already running')); + expect(duplicateStarting.disposition, ExternalCommandDisposition.noOp); + expect( + duplicateStarting.reason?.compactText, + contains('already starting'), + ); expect(idleStop.disposition, ExternalCommandDisposition.noOp); - expect(idleStop.reason, contains("isn't")); + expect(idleStop.reason?.compactText, contains("isn't")); + expect( + stoppingWhileStarting.disposition, + ExternalCommandDisposition.refused, + ); + expect( + stoppingWhileStarting.reason?.compactText, + 'Still starting — try Stop again', + ); }); test('Stop cannot terminate a newer session', () { @@ -129,7 +268,27 @@ void main() { ); expect(admission.disposition, ExternalCommandDisposition.refused); - expect(admission.reason, 'That session already ended'); + expect(admission.reason?.compactText, 'That session already ended'); + + final sameSession = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + sessionId: 'session-b', + ), + active: true, + currentSessionId: 'session-b', + ); + final olderWatch = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + ), + active: true, + currentSessionId: 'session-b', + ); + expect(sameSession.disposition, ExternalCommandDisposition.admitted); + expect(olderWatch.disposition, ExternalCommandDisposition.admitted); }); test('Stop is age exempt but Start and Manual Ping expire', () { @@ -150,8 +309,8 @@ void main() { active: true, ); - expect(start.reason, 'Took too long to reach iPhone'); - expect(ping.reason, 'Took too long to reach iPhone'); + expect(start.reason?.compactText, externalCommandExpiredReason); + expect(ping.reason?.compactText, externalCommandExpiredReason); expect(stop.disposition, ExternalCommandDisposition.admitted); }); diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 4378e11c..bc842fe0 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -4,6 +4,8 @@ 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/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.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'; @@ -496,7 +498,7 @@ void main() { bool txBlockedByOffline = false, bool txNotAllowed = false, bool floodTrafficEnabled = true, - String? transmitValidationReason, + ExternalCommandReason? transmitValidationReason, }) => resolveSessionStartAvailability( isTransmitMode: isTransmitMode, @@ -525,77 +527,6 @@ void main() { expect(WatchCommandKind.fromWire('selfDestruct'), isNull); }); - test('a queued Stop cannot end a session it never saw', () { - // Stop is exempt from the transmit-age window, so it can arrive - // arbitrarily late — the phone out of range, the watch suspended mid - // transfer. That exemption assumed one session was as good as another. - // A Stop queued against A, delivered after A ended and B began, used to - // stop B silently, and the wearer found out by noticing that recording - // had halted. - final wrongSession = resolveWatchSessionCommandAdmission( - kind: WatchCommandKind.stopSession, - isSessionActive: true, - isSessionStarting: false, - requestedSessionId: 'session-a', - currentSessionId: 'session-b', - ); - expect(wrongSession.shouldRun, isFalse); - expect(wrongSession.refusal, 'That session already ended'); - - final sameSession = resolveWatchSessionCommandAdmission( - kind: WatchCommandKind.stopSession, - isSessionActive: true, - isSessionStarting: false, - requestedSessionId: 'session-a', - currentSessionId: 'session-a', - ); - expect(sameSession, (shouldRun: true, refusal: null)); - - // An older watch build names no session. It is admitted exactly as it - // was before the field existed, because refusing it would strand a - // wearer whose only Stop button the phone had stopped honouring. - final olderBuild = resolveWatchSessionCommandAdmission( - kind: WatchCommandKind.stopSession, - isSessionActive: true, - isSessionStarting: false, - currentSessionId: 'session-b', - ); - expect(olderBuild, (shouldRun: true, refusal: null)); - }); - - test('a Stop with nothing running stays a harmless no-op', () { - // Checked after the active-session gate on purpose. With no session, a - // mismatched id must not turn a silent no-op into a refusal for - // something that is already over — the wearer asked for stopped, and - // stopped is what they have. - final stop = resolveWatchSessionCommandAdmission( - kind: WatchCommandKind.stopSession, - isSessionActive: false, - isSessionStarting: false, - requestedSessionId: 'session-a', - currentSessionId: 'idle', - ); - - expect(stop, (shouldRun: false, refusal: null)); - }); - - 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; @@ -640,6 +571,33 @@ void main() { expect(result, (mode: null, refusal: null)); }); + test('an old generic Start cannot inherit unsupported Trace mode', () { + expect( + resolveLegacyWatchStartMode( + currentMode: 'targeted', + isConnected: true, + txAllowed: true, + ), + 'passive', + ); + expect( + resolveLegacyWatchStartMode( + currentMode: 'hybrid', + isConnected: true, + txAllowed: true, + ), + 'hybrid', + ); + expect( + resolveLegacyWatchStartMode( + currentMode: 'active', + isConnected: true, + txAllowed: false, + ), + 'passive', + ); + }); + test('diagnostics stay reachable exactly when they are needed', () { bool show({ bool isSupportedPlatform = true, @@ -767,7 +725,7 @@ void main() { final hybrid = startAvailability(isTransmitMode: true, floodTrafficEnabled: false); expect(hybrid.allowed, isFalse); - expect(hybrid.reason, 'Flood Traffic Off'); + expect(hybrid.reason?.compactText, 'Flood Traffic Off'); // Passive monitoring is not flood traffic. The phone's Passive button // sits outside the flood gate, so the wrist's must too — withdrawing it @@ -793,13 +751,13 @@ void main() { 'Ping in progress': startAvailability(isPingSending: true), 'Listening for ping response': startAvailability(rxWindowActive: true), 'Waiting for GPS lock': startAvailability( - transmitValidationReason: 'Waiting for GPS lock', + transmitValidationReason: ExternalCommandReason.waitingForGpsLock, ), }; for (final entry in blocked.entries) { expect(entry.value.allowed, isFalse, reason: entry.key); - expect(entry.value.reason, entry.key); + expect(entry.value.reason?.compactText, entry.key); } }); @@ -809,7 +767,7 @@ void main() { isTransmitMode: false, txBlockedByOffline: true, txNotAllowed: true, - transmitValidationReason: 'Waiting for GPS lock', + transmitValidationReason: ExternalCommandReason.waitingForGpsLock, ); expect(passive, (allowed: true, reason: null)); @@ -831,7 +789,7 @@ void main() { for (final entry in blocked.entries) { expect(entry.value.allowed, isFalse, reason: entry.key); - expect(entry.value.reason, entry.key); + expect(entry.value.reason?.compactText, entry.key); } }); }); @@ -1643,10 +1601,10 @@ void main() { ); expect(reply?['accepted'], isFalse); - expect(reply?['reason'], 'Took too long to reach iPhone'); + expect(reply?['reason'], externalCommandExpiredReason); expect(handled, isEmpty, reason: 'an expired command must never reach admission'); - expect(refusals, ['Took too long to reach iPhone'], + expect(refusals, [externalCommandExpiredReason], reason: 'the wearer is told why the tap did nothing'); }); @@ -1751,9 +1709,9 @@ void main() { ); expect(reply?['accepted'], isFalse); - expect(reply?['reason'], 'Took too long to reach iPhone'); + expect(reply?['reason'], externalCommandExpiredReason); expect(handled, isEmpty); - expect(refusals, ['Took too long to reach iPhone']); + expect(refusals, [externalCommandExpiredReason]); }); test('an older watch build sending no offset behaves exactly as before', From ee94d2ea4639cf4fa6c9ecdf77344766b7b74191 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 25 Aug 2026 16:10:16 -0400 Subject: [PATCH 336/360] Fade the coverage overlay as soon as the opacity slider moves --- DEVELOPMENT.md | 8 +++ lib/providers/app_state_provider.dart | 13 +++-- lib/widgets/map_widget.dart | 81 ++++++++++++++++++++------- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cbb0bb76..40bba7ba 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -159,6 +159,14 @@ the GPS puck from a **direct provider listener** (`_onPositionNotify` → `updateSymbol`) every tick — real-time nav, no widget rebuild. The GPS-info overlay rebuilds only when the map itself does. +**Coverage overlay opacity does NOT bump `mapRevision`.** It is UI-only state, +so bumping the revision would relayout the platform view once per slider step. +`MapWidget` applies it through a **direct provider listener** +(`_onCoverageOpacityNotify`) that pushes the value into the live fill layers via +`setLayerProperties`. A `build()` watcher cannot serve this: the map is behind +the `mapRevision` Selector and never rebuilds on an opacity change, so the value +only reached MapLibre on the next full overlay rebuild. + **The Selector MUST be memoized (identity-stable).** `HomeScreen.build()` uses `context.watch`, so it rebuilds on every notify (incl. the 2 Hz GPS one). provider's `Selector` invalidates its cache whenever `oldWidget != widget` diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 37be7697..7d7184e5 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -7096,11 +7096,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } - /// Set coverage overlay opacity (0.3–1.0) and persist. - /// MapWidget watches `preferences.coverageOverlayOpacity` and applies the - /// new value to the raster layer at runtime via setLayerProperties, so the - /// overlay fades live as the slider moves. Lower bound of 0.3 prevents the - /// overlay from disappearing entirely. + /// Set coverage overlay opacity (0.3 to 1.0) and persist. + /// This is UI-only state and must NOT bump mapRevision: a rebuild per slider + /// step would relayout the map platform view. MapWidget picks the new value + /// up through its direct provider listener (`_onCoverageOpacityNotify`) and + /// pushes it into the live fill layers via setLayerProperties, so the overlay + /// fades without a rebuild. A build() watcher cannot do this, because the map + /// sits behind the mapRevision Selector and never rebuilds here (#434). + /// Lower bound of 0.3 prevents the overlay from disappearing entirely. void setCoverageOverlayOpacity(double opacity) { final clamped = opacity.clamp(0.3, 1.0); _preferences = _preferences.copyWith(coverageOverlayOpacity: clamped); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 743e7980..ec6300fd 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -556,9 +556,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // would otherwise never re-run and the coverage layer would stay missing. String? _lastOverlayZoneCode; // Last coverage overlay opacity we pushed into MapLibre. Compared against - // the current preference in _buildMap to detect slider changes and apply - // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). + // the current preference in _onCoverageOpacityNotify to detect slider + // changes and apply them live via _applyCoverageOverlayOpacity (no layer + // rebuild needed). double? _lastAppliedCoverageOpacity; + // Set while _onCoverageOpacityNotify has an apply in flight. + // _applyCoverageOverlayOpacity only records the new value AFTER both awaited + // setLayerProperties calls, and the listener runs on every provider notify + // (GPS at ~2 Hz, the passive-RX storm at 10-20/sec), so without this a single + // slider step fires duplicate platform calls and duplicate log lines for the + // whole round trip. + bool _coverageOpacityApplyInFlight = false; // Guard flag that coalesces multiple overlay-refresh triggers (zone and // pref changes) in the same frame into a single post-frame callback. // Without this, two watchers can schedule concurrent _refreshCoverageOverlay @@ -827,6 +835,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _handleGpsPosition. GPS notifies no longer bump mapRevision, so the map's // Selector doesn't rebuild on position. _patchProviderRef!.addListener(_onPositionNotify); + // Coverage overlay opacity is UI-only state that must not bump mapRevision + // (a rebuild per slider step would relayout the platform view), so it needs + // a direct listener too. See _onCoverageOpacityNotify. + _patchProviderRef!.addListener(_onCoverageOpacityNotify); } AppStateProvider? _patchProviderRef; @@ -849,6 +861,44 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _handleGpsPosition(appState); } + /// Fires on every provider notify; pushes a changed coverage overlay opacity + /// straight into the live fill layers via the controller, no rebuild. + /// + /// This CANNOT be a build() watcher. The map is isolated behind the memoized + /// mapRevision Selector (home_screen._buildMapSelector) and the opacity + /// preference is UI-only state that deliberately does not bump mapRevision, + /// so build() never re-runs when the slider moves. The value then only + /// reached MapLibre on the next full overlay rebuild (#434). + /// + /// Skipped while ping focus mode is active (focus forces opacity to 0 and + /// _dismissPingFocus restores the preference value directly) and while a + /// tapped cell or an isolated repeater dims the backdrop, or this would + /// un-dim it mid-sheet. + void _onCoverageOpacityNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + if (!_isMapReady || !_styleLoaded) return; + if (_focusedPingLocation != null) return; + if (_coverageDimmedForCell || _coverageDimmedForRepeater) return; + if (_coverageOpacityApplyInFlight) return; + final wanted = appState.preferences.coverageOverlayOpacity; + if (_lastAppliedCoverageOpacity == null || + _lastAppliedCoverageOpacity == wanted) { + return; + } + _coverageOpacityApplyInFlight = true; + _applyCoverageOverlayOpacity(wanted).whenComplete(() { + _coverageOpacityApplyInFlight = false; + if (!mounted) return; + // A slider step that landed mid-flight was dropped by the guard above. + // Re-check so the final value still lands when no further notify follows + // (e.g. sitting in Settings, not wardriving). Gated on the apply having + // actually succeeded: on failure _lastAppliedCoverageOpacity is unchanged + // and re-running would spin forever. + if (_lastAppliedCoverageOpacity == wanted) _onCoverageOpacityNotify(); + }); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { final wasBackground = _appLifecycleState != AppLifecycleState.resumed; @@ -894,6 +944,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.removeObserver(this); _patchProviderRef?.removeListener(_onCoveragePatchNotify); _patchProviderRef?.removeListener(_onPositionNotify); + _patchProviderRef?.removeListener(_onCoverageOpacityNotify); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -2068,24 +2119,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // (Session-patch application: direct provider listener // _onCoveragePatchNotify — see initState for why it's not a build watcher.) - // Detect coverage overlay opacity change (user dragged the slider in - // Settings → General) and push it to the live raster layer without - // rebuilding the whole overlay. Skipped while ping focus mode is active — - // focus forces opacity to 0 and _dismissPingFocus restores the preference - // value directly — and while a tapped cell dims the backdrop (_clearCellHighlight - // restores it on sheet close), or this would un-dim it mid-sheet. - final wantedOpacity = appState.preferences.coverageOverlayOpacity; - if (_isMapReady && - _styleLoaded && - _focusedPingLocation == null && - !_coverageDimmedForCell && - !_coverageDimmedForRepeater && - _lastAppliedCoverageOpacity != null && - _lastAppliedCoverageOpacity != wantedOpacity) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _applyCoverageOverlayOpacity(wantedOpacity); - }); - } + // (Coverage overlay opacity: direct provider listener + // _onCoverageOpacityNotify, for the same reason. Opacity is UI-only state + // that must not bump mapRevision, so a build() watcher here would never + // run when the slider moves.) return Stack( children: [ @@ -3347,6 +3384,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final sourceId = _activeCoverageSourceId; _activeCoverageLayerId = null; _activeCoverageSourceId = null; + // No live layer means nothing is applied. Leaving this set would keep + // _onCoverageOpacityNotify calling into a guaranteed no-op on every notify + // while the overlay is off. _addCoverageOverlay sets it again. + _lastAppliedCoverageOpacity = null; if (layerId != null && sourceId != null) { await _removeCoverageLayerById(layerId, sourceId); } From 698adde477978b159d41c7df0c9081d1c9e32e96 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 25 Aug 2026 16:36:45 -0700 Subject: [PATCH 337/360] Harden App Intent command execution --- DEVELOPMENT.md | 125 ++++++++++++++- .../MeshMapperReadIntents.swift | 133 +++++++++++++--- .../AppIntents/SiriIntentCoordinator.swift | 53 ++++++- ios/RunnerTests/AppIntentLogicTests.swift | 112 +++++++++++++- .../AppIntents/MeshMapperSiriSnapshot.swift | 7 + lib/providers/app_state_provider.dart | 108 +++++++++++-- .../app_intents/app_intent_commands.dart | 14 ++ .../last_companion_connection.dart | 15 +- .../app_intents/siri_snapshot_builder.dart | 18 +++ .../app_intents/siri_snapshot_models.dart | 5 + .../external_command_models.dart | 9 ++ .../external_session_commands.dart | 44 +++++- lib/services/meshcore/connection.dart | 16 ++ lib/services/ping_service.dart | 146 ++++++++++++++++-- .../app_intent_bridge_service_test.dart | 56 +++++++ .../last_companion_connection_test.dart | 43 +++++- .../siri_snapshot_builder_test.dart | 140 +++++++++++++++++ .../siri_snapshot_wire_contract_test.dart | 2 + .../external_session_commands_test.dart | 102 ++++++++++++ 19 files changed, 1090 insertions(+), 58 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5023e179..9265ee3a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -716,7 +716,12 @@ or `"unsupported"`. `false` means *not right now* — authorization is off, or can never show one, and Dart stops asking for the rest of the process rather than running a guaranteed-fail retry loop all session. -**App Intents, Siri, and future Apple surfaces.** Mutation intents live in the +**App Intents, Siri, and future Apple surfaces.** Every App Intent in this app +is iOS 26+: the extension has a 26.0 deployment target and the Runner intent +types are annotated `@available(iOS 26.0, *)`. Devices below that get none of +these Siri actions, and none of the app's other surfaces depend on them. + +Mutation intents live in the Runner process because session and connection changes must pass through the same phone-owned admission path as the watch. Read-only intents use a separate extension and the bounded App Group snapshot in `ios/Shared/AppIntents/`; they @@ -745,7 +750,116 @@ Keep future targets separated by lifecycle: - Native controls express intent; Dart remains the owner of connection, session and radio-policy admission. -The snapshot contains at most 64 recent observations and 64 repeaters. Recent +**One deadline, both sides.** `SiriIntentCoordinator` stops waiting after +`SiriCommand.responseTimeout` (10 s, or 30 s for Connect) and tells the person +it failed. That same instant travels to Dart as `expiresAtMs`, so giving up is +one decision rather than two, and it is rechecked at three points: + +1. Admission refuses an already-expired command outright. +2. `toggleAutoPing`/`sendPing` recheck after the awaited session check, before + any existing mode is torn down. +3. `PingService` rechecks in `sendTxPing` and `_sendDiscoveryRequest` after + **both** of the unbounded waits that precede a transmission, and before any + of the early returns that follow — not just before the BLE call. Nothing + between that check and the wire awaits, so it is the send instant in + wall-clock terms while leaving no `TxPing`/`DiscLogEntry` record and no + consumed wire-tag counter behind for a transmission never made. + + Both details are load-bearing, and each was got wrong once: + + - **Ahead of the early returns.** The `null`-position and "too close to last + discovery" returns call `_scheduleNextDiscovery()` and report success, so a + check below them lets a GPS fix that crossed the deadline start a session + that transmits on the next tick anyway. + - **After the write gate, not just after GPS.** `MeshCoreConnection._write` + parks non-sign frames behind an in-progress `CMD_SIGN` — five seconds per + phase, chunk phase looping — and that wait is unbounded by design ("delayed, + never failed"). It sits *inside* the send call, past everything a caller can + check. `awaitWritableState()` exists so a deadline-carrying caller takes + that wait where abandoning is still free; it is awaited only on that path, + so ordinary pings keep queuing exactly as before. + +Point 3 is the one that matters: Passive and Hybrid await a GPS fix *inside* +`enableAutoPing`, so points 1 and 2 alone would let the radio key up after Siri +had already reported cancellation. When the gate fires there, +`_abandonAutoPingStart()` unwinds the half-started session and `enableAutoPing` +returns false, so the session does not come up either. Active mode's initial +ping is awaited only when a gate is supplied, so the ordinary start path keeps +its existing fire-and-forget timing. + +`PingService.transmitAbortedByDeadline` distinguishes "the caller had already +given up" from the ordinary reasons a send is skipped — cooldown, failed +validation, no GPS. It is reset on entry to every gated path, and both the start +and the manual-ping paths read it so the refusal says the request arrived too +late rather than the generic "couldn't send the ping". + +The gate reaches only the session's *first* transmission; later pings come from +timers and belong to the session, not to the surface that started it. Checks 1 +and 2 also close `externalCommandCommitMargin` early, because work that has not +begun cannot finish inside a deadline that is nearly gone. Stop stays exempt +throughout; stopping is the safe direction, and a safe-direction command must +never be abandoned because a voice request timed out. A surface that sends no +`expiresAtMs`, such as the watch, still falls back to the shared 30-second +`maximumExternalCommandAge`. + +**Connect's 30 seconds is a response deadline, not a cancellation guarantee.** +This is the one mutation where the two differ, and the difference is deliberate. + +Up to the point of dialling, Connect behaves like the rest: +`resolveLastCompanionConnection` checks `expiresAt` ahead of the age rule and +ahead of every state-based refusal, `_connectToLastCompanion` rechecks after the +10-second readiness wait (admission ran before it, and a cold launch can spend +that whole budget), and it requires `externalCommandCommitMargin` before +dialling. Refusing there is free and avoids pointless transport churn. + +Once dialling starts there is no way back: `connectToDevice`/`connectViaTcp` +have no cancellation seam, and a BLE GATT phase alone may run 15 seconds before +protocol setup and authentication. A reconnect begun with ~20 seconds left can +therefore finish after the intent has given up — that is ordinary, not an edge +case, which is why the preflight margin is a sanity check rather than a +guarantee. Such a reconnect is deliberately left connected: it transmits nothing +on the mesh, starts no session, and is what the person asked for; tearing it +down would only make them wait out another cold reconnect. + +Because of that, `SiriCommand.Kind.timeoutMessage` is per-kind, and the rule is +that only a kind which really is cancelled may say so: + +| Kind | On timeout | Why | +| --- | --- | --- | +| Start, Manual Ping | "…the request was cancelled" | True: Dart holds the same deadline and checks it before every RF send. | +| Connect | "…may still be connecting" | No cancellation seam once a transport is dialling. | +| Stop | "…may still be stopping" | Deliberately deadline-exempt, and teardown may queue a pending disable behind an RX window. | + +**Adding a kind means deciding which column it belongs in.** Connect earns the +first wording only if `connectToDevice`/`connectViaTcp` gain a cancellation +seam; Stop only if it is made cancellable, which it should not be. Until then, +do not change either message back. + +**"Current session" needs a session boundary.** The observation history is +bounded at two hours, which routinely spans several sessions. `session.startedAt` +carries the running session's start so the Recent Repeaters intent can filter +against it, and `uniqueRepeatersHeard` counts only observations at or after it; +with nothing running both report empty rather than borrowing the previous +session's results. + +The boundary must be captured *before* the session's first transmission, not +after it: `enableAutoPing()` sends and records the opening discovery before it +returns, so taking the timestamp afterwards would push a Passive or Hybrid +session's own first observation outside its boundary. `toggleAutoPing` therefore +reads the clock before the call and passes it to `_startLiveActivitySession`, +and the filter is inclusive of that instant. A manual ping that is later upgraded +to an automatic mode deliberately keeps its original boundary — that is one +session under one ID, and the manual ping and its RX window are that session's +own results. Any surface that says "current" or "this session" must also +check `updatedAt` against `MeshMapperSnapshotFreshness.currentClaimLimit` — the +snapshot deliberately outlives Runner and can be days old. + +The snapshot contains at most 64 recent observations and 64 repeaters. That +catalogue bound is also the entity-lookup bound: `RepeaterEntityQuery` searches +only the cached active/recent entries, never the full loaded repeater set, which +is why the intent is named "Find Recent MeshMapper Repeater". Widening the +lookup means widening the catalogue or adding a separate compact index — do not +leave a broad name over a narrow index. Recent observations are ranked and truncated before catalogue identity resolution, so large histories do not multiply the resolution work. A cheap scalar/revision preflight key suppresses rebuilds when provider notifications do not change @@ -764,7 +878,8 @@ of every registered phrase): - `Siri, stop MeshMapper`. - `Siri, what is MeshMapper doing?` or `Siri, get MeshMapper status`. - `Siri, what has MeshMapper heard?` or `Siri, recent repeaters in MeshMapper`. -- `Siri, find a repeater in MeshMapper` (Siri asks which repeater when needed). +- `Siri, find a recent repeater in MeshMapper` (Siri asks which repeater when + needed; only active/recent cached repeaters resolve). Mutation intents return their completion message both as Siri dialog and as a text output. A user-created Shortcut can pass that Result to a `Speak Text` @@ -774,7 +889,9 @@ Prefer Spoken Responses when voice feedback is required even in Silent mode. Connect and Start require device authentication and may bring the app forward. Connect reuses the last remembered BLE/TCP companion; USB still requires an -in-app selection. Starting does not silently change companions or reconnect — +in-app selection. Connect can also outlast Siri's 30-second wait — a slow radio +may finish connecting after Siri has stopped listening, which is why its timeout +says the app may still be connecting rather than that the request was cancelled. Starting does not silently change companions or reconnect — ask to connect first when MeshMapper is disconnected. ### BLE Service UUIDs (MeshCore Companion Protocol) diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift index a78569b5..57b18c39 100644 --- a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -1,6 +1,23 @@ import AppIntents import Foundation +/// How the read-only intents decide whether the cached snapshot is recent +/// enough to speak about in the present tense. +/// +/// The App Group file deliberately outlives Runner, so every "currently" or +/// "this session" claim has to be checked against `updatedAt` first. +enum MeshMapperSnapshotFreshness { + static let currentClaimLimit: TimeInterval = 5 * 60 + + static func relativeAge(_ age: TimeInterval) -> String { + let seconds = max(Int(age), 0) + if seconds < 60 { return "\(seconds) seconds ago" } + if seconds < 60 * 60 { return "\(seconds / 60) minutes ago" } + if seconds < 24 * 60 * 60 { return "\(seconds / (60 * 60)) hours ago" } + return "\(seconds / (24 * 60 * 60)) days ago" + } +} + @available(iOS 26.0, *) enum RecentHeardWindow: String, AppEnum { case lastFiveMinutes @@ -18,6 +35,10 @@ enum RecentHeardWindow: String, AppEnum { .currentSession: "Current Session", ] + /// Nil for `.currentSession`, which is bounded by the session's own start + /// rather than by a span. Treating that nil as "no boundary" is what made + /// Current Session silently mean "everything cached"; resolve it against the + /// snapshot instead. var duration: TimeInterval? { switch self { case .lastFiveMinutes: return 5 * 60 @@ -61,7 +82,8 @@ struct GetMeshMapperStatusIntent: AppIntent { } let age = Date().timeIntervalSince(snapshot.updatedAt) - if age > 5 * 60, snapshot.session.active || snapshot.session.starting { + if age > MeshMapperSnapshotFreshness.currentClaimLimit, + snapshot.session.active || snapshot.session.starting { return .result( dialog: "MeshMapper's last session update is too old to report as current. Open the app to refresh it." ) @@ -69,7 +91,7 @@ struct GetMeshMapperStatusIntent: AppIntent { let prefix: String if age >= 30 { - prefix = "As of \(Self.relativeAge(age)), " + prefix = "As of \(MeshMapperSnapshotFreshness.relativeAge(age)), " } else { prefix = "" } @@ -99,14 +121,6 @@ struct GetMeshMapperStatusIntent: AppIntent { private static func modeName(_ mode: String) -> String { mode == "passive" ? "Passive Discovery" : mode.capitalized } - - private static func relativeAge(_ age: TimeInterval) -> String { - let seconds = max(Int(age), 0) - if seconds < 60 { return "\(seconds) seconds ago" } - if seconds < 60 * 60 { return "\(seconds / 60) minutes ago" } - if seconds < 24 * 60 * 60 { return "\(seconds / (60 * 60)) hours ago" } - return "\(seconds / (24 * 60 * 60)) days ago" - } } @available(iOS 26.0, *) @@ -138,10 +152,33 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { } let now = Date() - var observations = snapshot.recentHeard.filter { observation in - guard let duration = timeWindow.duration else { return true } - return now.timeIntervalSince(observation.observedAt) <= duration + // "Current Session" is a session boundary, not the absence of one. The + // cached history spans two hours regardless of how many sessions ran in it, + // so without the session's own start there is nothing to filter against. + let cutoff: Date + if timeWindow == .currentSession { + let age = now.timeIntervalSince(snapshot.updatedAt) + guard age <= MeshMapperSnapshotFreshness.currentClaimLimit else { + return .result( + value: [], + dialog: "MeshMapper's last update is too old to say what the current session has heard. Open the app to refresh it." + ) + } + guard snapshot.session.active || snapshot.session.starting, + let startedAt = snapshot.session.startedAt + else { + return .result( + value: [], + dialog: "No MeshMapper mapping session is running." + ) + } + cutoff = startedAt + } else { + cutoff = timeWindow.duration.map { now.addingTimeInterval(-$0) } + ?? .distantPast } + + var observations = snapshot.recentHeard.filter { $0.observedAt >= cutoff } switch sort { case .mostRecent: observations.sort { $0.observedAtMs > $1.observedAtMs } @@ -165,17 +202,43 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { HeardRepeaterEntity(observation: $0, repeaterById: repeaterById) } guard !selected.isEmpty else { + let scope = timeWindow == .currentSession + ? "yet in this session" + : "in that time window" return .result( value: [], - dialog: "MeshMapper didn't hear any repeaters in that time window." + dialog: "MeshMapper hasn't heard any repeaters \(scope)." ) } - let spoken = selected.prefix(3).map(Self.spokenObservation).joined(separator: ", ") - let lead = selected.count == 1 ? "The result was" : "The results were" - return .result(value: entities, dialog: "\(lead) \(spoken).") + // Naming every result would outstay its welcome when this is heard through + // AirPods with nothing on screen, but silently dropping the rest would make + // the spoken answer disagree with the returned value. Say how many are left. + let spokenCount = min(selected.count, Self.maximumSpokenResults) + let spoken = selected + .prefix(spokenCount) + .map(Self.spokenObservation) + .joined(separator: ", ") + let remaining = selected.count - spokenCount + let lead: String + if selected.count == 1 { + lead = "The result was" + } else if remaining > 0 { + lead = "The first \(spokenCount) were" + } else { + lead = "The results were" + } + let tail: String + switch remaining { + case 0: tail = "" + case 1: tail = " There is 1 more result." + default: tail = " There are \(remaining) more results." + } + return .result(value: entities, dialog: "\(lead) \(spoken).\(tail)") } + private static let maximumSpokenResults = 3 + private static func spokenObservation(_ observation: MeshMapperSiriObservation) -> String { let identity = observation.name ?? "repeater \(observation.displayHexId)" let signal = observation.snr.map { String(format: " at %.1f dB", $0) } ?? "" @@ -193,7 +256,13 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { @available(iOS 26.0, *) struct FindMeshMapperRepeaterIntent: AppIntent { - static let title: LocalizedStringResource = "Find MeshMapper Repeater" + // Named for what it can actually search. The App Group catalog holds the + // active and most recently heard repeaters, not every repeater MeshMapper has + // loaded, so a broader name would fail unpredictably on the ones it omits. + static let title: LocalizedStringResource = "Find Recent MeshMapper Repeater" + static let description = IntentDescription( + "Looks up one of the active or recently heard repeaters MeshMapper has cached, and says how current that reading is." + ) static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } static var supportedModes: IntentModes { .background } @@ -201,11 +270,33 @@ struct FindMeshMapperRepeaterIntent: AppIntent { var repeater: RepeaterEntity func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { - let status = repeater.isActive ? "active" : "stale" + let snapshot = try? MeshMapperSiriSnapshotStore.shared.read() + // Prefer the catalog's own record: a saved Shortcut can carry an entity + // resolved long ago, and its captured state is what would otherwise be + // spoken as "currently". + let current = snapshot?.repeaters.first { $0.id == repeater.id } + let status = (current?.isActive ?? repeater.isActive) ? "active" : "stale" let zone = repeater.zoneCode.map { " in \($0)" } ?? "" + let identity = "\(repeater.name) is repeater \(repeater.hexId)\(zone)" + + // The snapshot deliberately outlives Runner, so it can be days old. Only + // claim the present tense when it is recent enough to mean anything. + guard let snapshot else { + return .result( + value: repeater, + dialog: "\(identity). Open MeshMapper to see whether it's still active." + ) + } + let age = Date().timeIntervalSince(snapshot.updatedAt) + guard age <= MeshMapperSnapshotFreshness.currentClaimLimit else { + return .result( + value: repeater, + dialog: "\(identity). When MeshMapper last updated \(MeshMapperSnapshotFreshness.relativeAge(age)), it was \(status)." + ) + } return .result( value: repeater, - dialog: "\(repeater.name) is repeater \(repeater.hexId)\(zone), and is currently \(status)." + dialog: "\(identity), and is currently \(status)." ) } } @@ -234,7 +325,7 @@ struct MeshMapperReadAppShortcuts: AppShortcutsProvider { AppShortcut( intent: FindMeshMapperRepeaterIntent(), phrases: [ - "Find a repeater in \(.applicationName)", + "Find a recent repeater in \(.applicationName)", ], shortTitle: "Find Repeater", systemImageName: "magnifyingglass" diff --git a/ios/Runner/AppIntents/SiriIntentCoordinator.swift b/ios/Runner/AppIntents/SiriIntentCoordinator.swift index 73d1f182..e8f4e1a1 100644 --- a/ios/Runner/AppIntents/SiriIntentCoordinator.swift +++ b/ios/Runner/AppIntents/SiriIntentCoordinator.swift @@ -3,6 +3,7 @@ import Foundation enum SiriBridgeError: LocalizedError { case appUnavailable + case timedOut(SiriCommand.Kind) case invalidResponse case flutter(String) @@ -10,6 +11,8 @@ enum SiriBridgeError: LocalizedError { switch self { case .appUnavailable: return "MeshMapper did not finish launching in time." + case .timedOut(let kind): + return kind.timeoutMessage case .invalidResponse: return "MeshMapper returned an invalid command result." case .flutter(let message): @@ -24,6 +27,32 @@ struct SiriCommand { case stopSession case manualPing case connectLastCompanion + + /// What to say when the intent stops waiting for this kind. + /// + /// Only say "cancelled" where it is true. Start and Manual Ping are: Dart + /// holds the same deadline and checks it immediately before every RF send, + /// so nothing goes out and no session survives. + /// + /// The other two are best-effort by design and must not claim otherwise. + /// Connecting cannot be called back once a transport is dialling, and a BLE + /// GATT phase alone may run 15 seconds before protocol setup and + /// authentication. Stopping is deliberately exempt from the deadline + /// altogether — a safe-direction command must never be abandoned because a + /// voice request timed out — and its teardown may queue a pending disable + /// behind an RX window. Both overrun as a matter of course rather than + /// exceptionally, so claiming cancellation would be a lie about as often as + /// it was true. + var timeoutMessage: String { + switch self { + case .startSession, .manualPing: + return "MeshMapper took too long, so the request was cancelled." + case .connectLastCompanion: + return "MeshMapper didn't answer in time, and may still be connecting. Check the app in a moment." + case .stopSession: + return "MeshMapper didn't answer in time, and may still be stopping. Check the app in a moment." + } + } } let id = UUID().uuidString @@ -38,12 +67,22 @@ struct SiriCommand { self.sessionId = sessionId } - var dictionary: [String: Any?] { + /// How long the intent waits for the real outcome before telling the person + /// it failed. The same instant is handed to Dart as `expiresAtMs`, so giving + /// up here and giving up there are one decision rather than two — for the + /// session kinds. For `connectLastCompanion` it bounds the *response* only; + /// see `Kind.timeoutMessage` for why, and for what is said instead. + var responseTimeout: TimeInterval { + kind == .connectLastCompanion ? 30 : 10 + } + + func dictionary(expiresAt: Date) -> [String: Any?] { [ "id": id, "source": "siri", "kind": kind.rawValue, "issuedAtMs": Int64(issuedAt.timeIntervalSince1970 * 1_000), + "expiresAtMs": Int64(expiresAt.timeIntervalSince1970 * 1_000), "mode": mode, "sessionId": sessionId, ] @@ -103,14 +142,20 @@ final class SiriIntentCoordinator { return try await withCheckedThrowingContinuation { continuation in var completed = false - let responseTimeout: TimeInterval = command.kind == .connectLastCompanion ? 30 : 10 + // Measured from here, not from the command's creation, because the wait + // for the channel above can consume most of a cold launch on its own. + let responseTimeout = command.responseTimeout + let expiresAt = Date().addingTimeInterval(responseTimeout) let timeout = DispatchWorkItem { guard !completed else { return } completed = true - continuation.resume(throwing: SiriBridgeError.appUnavailable) + continuation.resume(throwing: SiriBridgeError.timedOut(command.kind)) } DispatchQueue.main.asyncAfter(deadline: .now() + responseTimeout, execute: timeout) - channel.invokeMethod("command", arguments: command.dictionary) { response in + channel.invokeMethod( + "command", + arguments: command.dictionary(expiresAt: expiresAt) + ) { response in guard !completed else { return } completed = true timeout.cancel() diff --git a/ios/RunnerTests/AppIntentLogicTests.swift b/ios/RunnerTests/AppIntentLogicTests.swift index d026faa1..0d2a93cb 100644 --- a/ios/RunnerTests/AppIntentLogicTests.swift +++ b/ios/RunnerTests/AppIntentLogicTests.swift @@ -5,7 +5,7 @@ import XCTest final class AppIntentLogicTests: XCTestCase { func testConnectLastCompanionCommandUsesDedicatedWireKind() { let command = SiriCommand(kind: .connectLastCompanion) - let payload = command.dictionary + let payload = command.dictionary(expiresAt: Date()) XCTAssertEqual(payload["source"] as? String, "siri") XCTAssertEqual(payload["kind"] as? String, "connectLastCompanion") @@ -13,6 +13,57 @@ final class AppIntentLogicTests: XCTestCase { XCTAssertNil(payload["sessionId"] as? String) } + func testCommandCarriesTheSameDeadlineTheIntentWaitsOn() { + // Dart must be able to stop for the same reason, and at the same instant, + // that the intent stops waiting — otherwise a slow start reaches the radio + // after the person has already been told it failed. + let command = SiriCommand(kind: .startSession, mode: "active") + let expiresAt = Date().addingTimeInterval(command.responseTimeout) + let payload = command.dictionary(expiresAt: expiresAt) + + XCTAssertEqual(command.responseTimeout, 10) + XCTAssertEqual( + payload["expiresAtMs"] as? Int64, + Int64(expiresAt.timeIntervalSince1970 * 1_000) + ) + } + + func testOnlyCancellableKindsClaimCancellation() { + // Dart holds the same deadline for these two and checks it before every RF + // send, so "cancelled" is literally true for them. + for kind in [SiriCommand.Kind.startSession, .manualPing] { + XCTAssertEqual( + SiriBridgeError.timedOut(kind).errorDescription, + "MeshMapper took too long, so the request was cancelled." + ) + } + + // Connect cannot be called back once a transport is dialling, and Stop is + // deliberately exempt from the deadline and may still be tearing down. + // Neither may claim cancellation. + let connect = + SiriBridgeError.timedOut(.connectLastCompanion).errorDescription ?? "" + XCTAssertEqual( + connect, + "MeshMapper didn't answer in time, and may still be connecting. Check the app in a moment." + ) + + let stop = SiriBridgeError.timedOut(.stopSession).errorDescription ?? "" + XCTAssertEqual( + stop, + "MeshMapper didn't answer in time, and may still be stopping. Check the app in a moment." + ) + + XCTAssertFalse(connect.contains("cancelled")) + XCTAssertFalse(stop.contains("cancelled")) + } + + func testConnectAllowsLongerThanTheOtherMutations() { + XCTAssertEqual(SiriCommand(kind: .connectLastCompanion).responseTimeout, 30) + XCTAssertEqual(SiriCommand(kind: .stopSession).responseTimeout, 10) + XCTAssertEqual(SiriCommand(kind: .manualPing).responseTimeout, 10) + } + func testDecodesVersionOneSnapshotWithoutUserCoordinates() throws { let json = """ { @@ -26,6 +77,7 @@ final class AppIntentLogicTests: XCTestCase { }, "session": { "id": "session-1", + "startedAtMs": 1787529000000, "active": true, "starting": false, "mode": "passive", @@ -86,6 +138,10 @@ final class AppIntentLogicTests: XCTestCase { XCTAssertEqual(snapshot.version, MeshMapperSiriSnapshotStore.supportedVersion) XCTAssertEqual(snapshot.session.id, "session-1") + XCTAssertEqual( + snapshot.session.startedAt, + Date(timeIntervalSince1970: 1_787_529_000) + ) XCTAssertEqual(snapshot.recentHeard.first?.entityId, "repeater-1") XCTAssertEqual(snapshot.recentHeard.first?.repeaterLat, 37.1) XCTAssertEqual( @@ -96,6 +152,59 @@ final class AppIntentLogicTests: XCTestCase { XCTAssertFalse(json.contains("userLon")) } + func testSnapshotFromAnOlderBuildStillDecodesWithoutASessionStart() throws { + // startedAtMs is additive within version 1, so an extension updated ahead + // of the app must keep reading the file the old app left behind. + let json = """ + { + "version": 1, + "updatedAtMs": 1787529600000, + "connection": { + "isConnected": false, + "deviceName": null, + "batteryPercent": null, + "gpsStatus": "unavailable" + }, + "session": { + "id": null, + "active": false, + "starting": false, + "mode": "idle", + "phase": "idle", + "phaseTitle": "Idle", + "phaseDetail": null, + "phaseEndsAtMs": null, + "zoneCode": null, + "txCount": 0, + "rxCount": 0, + "discoveryCount": 0, + "traceCount": 0, + "queueSize": 0, + "uniqueRepeatersHeard": 0 + }, + "controls": { + "availableStartModes": [], + "canStart": false, + "startBlockedReason": "Connect first.", + "canStop": false, + "canManualPing": false, + "manualPingBlockedReason": "Connect first.", + "manualCooldownEndsAtMs": null + }, + "recentHeard": [], + "repeaters": [] + } + """ + + let snapshot = try JSONDecoder().decode( + MeshMapperSiriSnapshot.self, + from: XCTUnwrap(json.data(using: .utf8)) + ) + + XCTAssertNil(snapshot.session.startedAtMs) + XCTAssertNil(snapshot.session.startedAt) + } + func testRejectsUnsupportedSnapshotVersionBeforeWriting() { let snapshot = MeshMapperSiriSnapshot( version: 2, @@ -108,6 +217,7 @@ final class AppIntentLogicTests: XCTestCase { ), session: MeshMapperSiriSession( id: nil, + startedAtMs: nil, active: false, starting: false, mode: "none", diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift index f875f09c..4fb3ffe4 100644 --- a/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift @@ -23,6 +23,9 @@ struct MeshMapperSiriConnection: Codable, Sendable { struct MeshMapperSiriSession: Codable, Sendable { let id: String? + /// Additive and optional, so a snapshot written by an older build still + /// decodes; nil simply means "no session boundary is known". + let startedAtMs: Int64? let active: Bool let starting: Bool let mode: String @@ -37,6 +40,10 @@ struct MeshMapperSiriSession: Codable, Sendable { let traceCount: Int let queueSize: Int let uniqueRepeatersHeard: Int + + var startedAt: Date? { + startedAtMs.map { Date(timeIntervalSince1970: Double($0) / 1_000) } + } } struct MeshMapperSiriControls: Codable, Sendable { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0d31f414..07ef2cca 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -193,6 +193,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _liveActivitySessionActive = false; bool _liveActivityManualSession = false; String? _liveActivitySessionId; + + /// When the current session began, so read-only surfaces can say "this + /// session" and mean it rather than "whatever is in the recent history". + DateTime? _liveActivitySessionStartedAt; DateTime? _liveActivityCycleStartedAt; _LiveActivityOperation? _liveActivityOperation; MeshCoreConnection? _meshCoreConnection; @@ -1356,11 +1360,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _scheduleLiveActivitySync(); } - void _startLiveActivitySession({bool manual = false}) { + void _startLiveActivitySession({bool manual = false, DateTime? startedAt}) { 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. + // The boundary deliberately stays where the manual ping put it: this is + // one session under one ID, and the manual ping and its RX window are + // that session's own results, not a previous session's. if (!manual && _liveActivityManualSession) { _liveActivityManualSession = false; _scheduleLiveActivitySync(immediate: true); @@ -1370,6 +1377,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _liveActivitySessionActive = true; _liveActivityManualSession = manual; _liveActivitySessionId = const Uuid().v4(); + // Callers that transmit before reaching here pass the instant they began, + // so the session's own first observation falls inside its boundary. + _liveActivitySessionStartedAt = startedAt ?? DateTime.now(); _liveActivityCycleStartedAt = _activeLiveActivityCycleStartedAt; _liveActivityOperation = null; _scheduleLiveActivitySync(immediate: true); @@ -1397,6 +1407,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _liveActivityCycleStartedAt = null; _scheduleLiveActivitySync(immediate: true); _liveActivitySessionId = null; + _liveActivitySessionStartedAt = null; } void _markLiveActivityOperation(_LiveActivityOperation operation) { @@ -1882,10 +1893,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!sessionBusy && active.allowed) AutoMode.active.name, if (!sessionBusy && hybrid.allowed) AutoMode.hybrid.name, ]; - final uniqueHeard = recentHeard - .map((item) => item.entityId ?? 'unresolved:${item.displayHexId}') - .toSet() - .length; + final sessionStartedAt = _liveActivitySessionStartedAt; + final uniqueHeard = SiriSnapshotBuilder.countUniqueRepeatersHeard( + recentHeard, + sessionStartedAt, + ); return SiriSnapshot( updatedAt: DateTime.now(), @@ -1897,6 +1909,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), session: SiriSessionSnapshot( id: _liveActivitySessionId, + startedAt: sessionStartedAt, active: _autoPingEnabled, starting: _autoPingStarting, mode: sessionBusy ? _autoMode.name : 'idle', @@ -2091,11 +2104,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _lastSessionCheckFailureReason = null; + // Re-read the clock at the checkpoint rather than capturing a decision + // made at admission, which is what makes this cover the awaited work in + // between. + bool cannotStillCommit() => externalCommandCannotCommit(command.expiresAt); try { switch (command.kind) { case ExternalSessionCommandKind.startSession: final mode = admission.mode!; - final started = await toggleAutoPing(_autoModeFromExternal(mode)); + final started = await toggleAutoPing( + _autoModeFromExternal(mode), + shouldAbortBeforeTransmit: cannotStillCommit, + ); if (!started || !_autoPingEnabled) { return ExternalCommandCompletion( success: false, @@ -2134,7 +2154,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); case ExternalSessionCommandKind.manualPing: - final sent = await sendPing(); + final sent = await sendPing( + shouldAbortBeforeTransmit: cannotStillCommit, + ); if (!sent) { return ExternalCommandCompletion( success: false, @@ -2204,6 +2226,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), ); } + // Admission ran before that wait, so re-check: a cold launch can spend the + // whole readiness budget and leave the caller already gone. + if (externalCommandDeadlineRefusal(command.expiresAt) != null) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + externalCommandExpiredVoiceMessage, + ), + ); + } if (_isDisposed) { return const ExternalCommandCompletion( success: false, @@ -2240,6 +2273,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'MeshMapper is already connecting. Try again shortly.', ExternalCommandReasonCode.userInteractionRequired => 'The last companion uses USB and must be selected in MeshMapper.', + ExternalCommandReasonCode.commandExpired => + externalCommandExpiredVoiceMessage, _ => admission.reason?.compactText ?? 'MeshMapper could not connect.', }; return ExternalCommandCompletion( @@ -2249,6 +2284,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } + // Connecting cannot be cancelled once the transport is dialling, so the + // deadline is enforced by not starting a reconnect that has no room left to + // finish. Refusing here is free; refusing halfway through is not possible. + if (externalCommandCannotCommit(command.expiresAt)) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + externalCommandExpiredVoiceMessage, + ), + ); + } + try { await reconnectToRememberedDevice(); final reconnectedDevice = @@ -5941,7 +5989,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Send a manual TX ping - Future sendPing() async { + Future sendPing({bool Function()? shouldAbortBeforeTransmit}) async { if (_pingService == null) return false; if (_isAutoReconnecting) { debugLog('[PING] Ignoring ping during auto-reconnect'); @@ -5969,6 +6017,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!sessionCheck) return false; } + // The awaited session check can outlast the deadline an external surface + // is holding a person on. This is the last point before RF, so an expired + // request stops here rather than transmitting after Siri said it failed. + if (shouldAbortBeforeTransmit?.call() ?? false) { + _lastSessionCheckFailureReason = ExternalCommandReason.commandExpired; + return false; + } + // Reset idle disconnect timer (user is actively pinging) _startIdleDisconnectTimer(); @@ -5977,7 +6033,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ownsLiveActivity) { _startLiveActivitySession(manual: true); } - final sent = await _pingService!.sendTxPing(manual: true); + final sent = await _pingService!.sendTxPing( + manual: true, + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); + // The gate can also fire inside the send, after its own awaited work. + // Without this the caller hears the generic "couldn't send the ping" + // instead of being told the request simply arrived too late. + if (!sent && _pingService!.transmitAbortedByDeadline) { + _lastSessionCheckFailureReason = ExternalCommandReason.commandExpired; + } keepLiveActivity = sent; return sent; } finally { @@ -6052,7 +6117,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) - Future toggleAutoPing(AutoMode mode) async { + Future toggleAutoPing( + AutoMode mode, { + bool Function()? shouldAbortBeforeTransmit, + }) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; @@ -6147,6 +6215,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return false; } + // The awaited session check can outlast the deadline an external + // surface is holding a person on. Stop here, before any existing mode + // is torn down, so an expired request never starts a session the + // person was already told did not start. + if (shouldAbortBeforeTransmit?.call() ?? false) { + _lastSessionCheckFailureReason = ExternalCommandReason.commandExpired; + return false; + } + // Stop any existing mode first if (_autoPingEnabled) { await _pingService!.forceDisableAutoPing(); @@ -6177,13 +6254,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + // Taken before the call, not after it: enableAutoPing sends and records + // the session's first discovery before returning, and an observation + // the session made must not fall outside the session's own boundary. + final sessionStartedAt = DateTime.now(); final started = await _pingService!.enableAutoPing( passiveMode: isPassive, hybridMode: isHybrid, targetedMode: isTargeted, targetRepeaterId: isTargeted ? _targetRepeaterId : null, + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, ); if (!started) { + if (_pingService!.transmitAbortedByDeadline) { + _lastSessionCheckFailureReason = + ExternalCommandReason.commandExpired; + } // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { debugLog( @@ -6198,7 +6284,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogger?.startWardriving(); _autoPingEnabled = true; _idleAutoStopReference = DateTime.now(); - _startLiveActivitySession(); + _startLiveActivitySession(startedAt: sessionStartedAt); // Start noise floor session for graph tracking final sessionLabel = isPassive diff --git a/lib/services/app_intents/app_intent_commands.dart b/lib/services/app_intents/app_intent_commands.dart index 1ffc9fa0..407796e5 100644 --- a/lib/services/app_intents/app_intent_commands.dart +++ b/lib/services/app_intents/app_intent_commands.dart @@ -34,6 +34,7 @@ class AppIntentCommand { required this.id, required this.kind, required this.issuedAt, + this.expiresAt, this.mode, this.sessionId, }); @@ -41,6 +42,11 @@ class AppIntentCommand { final String id; final AppIntentCommandKind kind; final DateTime issuedAt; + + /// When the App Intent stops waiting and reports failure to the person. + /// + /// Older native builds omit it; the shared age rule still bounds those. + final DateTime? expiresAt; final String? mode; final String? sessionId; @@ -60,13 +66,20 @@ class AppIntentCommand { if (parsedKind == null) return null; final mode = map['mode']; final sessionId = map['sessionId']; + final expiresAtMs = map['expiresAtMs']; if (mode != null && mode is! String) return null; if (sessionId != null && sessionId is! String) return null; + if (expiresAtMs != null && expiresAtMs is! num) return null; return AppIntentCommand( id: id, kind: parsedKind, issuedAt: DateTime.fromMillisecondsSinceEpoch(issuedAtMs.toInt()), + expiresAt: expiresAtMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (expiresAtMs as num).toInt(), + ), mode: mode as String?, sessionId: sessionId as String?, ); @@ -80,6 +93,7 @@ class AppIntentCommand { source: ExternalCommandSource.siri, kind: sessionKind, issuedAt: issuedAt, + expiresAt: expiresAt, mode: mode, sessionId: sessionId, ); diff --git a/lib/services/app_intents/last_companion_connection.dart b/lib/services/app_intents/last_companion_connection.dart index f0a6a1bd..097762ce 100644 --- a/lib/services/app_intents/last_companion_connection.dart +++ b/lib/services/app_intents/last_companion_connection.dart @@ -11,10 +11,17 @@ ExternalCommandAdmission resolveLastCompanionConnection({ required bool canReconnectWithoutUserInput, DateTime? now, }) { - final ageRefusal = externalCommandTimestampRefusal( - command.issuedAt, - now: now, - ); + // The deadline comes first: it is the instant the intent actually stops + // waiting, and it is set when the command reaches Dart rather than when it + // was created, so it can differ from the shared age rule in both directions. + final ageRefusal = externalCommandDeadlineRefusal( + command.expiresAt, + now: now, + ) ?? + externalCommandTimestampRefusal( + command.issuedAt, + now: now, + ); if (ageRefusal != null) { return ExternalCommandAdmission( disposition: ExternalCommandDisposition.refused, diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart index 47c276a0..d25e3678 100644 --- a/lib/services/app_intents/siri_snapshot_builder.dart +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -170,6 +170,24 @@ class SiriSnapshotBuilder { ); } + /// Counts distinct repeaters heard since [sessionStartedAt]. + /// + /// [maximumObservationAge] bounds the cache, not a session: a two-hour + /// history routinely spans several sessions, so counting all of it would let + /// a session that has heard nothing report the previous one's repeaters. + /// A null start means no session is running, which counts as none. + static int countUniqueRepeatersHeard( + List observations, + DateTime? sessionStartedAt, + ) { + if (sessionStartedAt == null) return 0; + return observations + .where((item) => !item.observedAt.isBefore(sessionStartedAt)) + .map((item) => item.entityId ?? 'unresolved:${item.displayHexId}') + .toSet() + .length; + } + static List buildRepeaterCatalog( List repeaters, ) { diff --git a/lib/services/app_intents/siri_snapshot_models.dart b/lib/services/app_intents/siri_snapshot_models.dart index e91ae58c..01844612 100644 --- a/lib/services/app_intents/siri_snapshot_models.dart +++ b/lib/services/app_intents/siri_snapshot_models.dart @@ -30,6 +30,7 @@ class SiriConnectionSnapshot { class SiriSessionSnapshot { const SiriSessionSnapshot({ this.id, + this.startedAt, required this.active, required this.starting, required this.mode, @@ -47,6 +48,9 @@ class SiriSessionSnapshot { }); final String? id; + + /// The boundary "current session" means. Null when nothing is running. + final DateTime? startedAt; final bool active; final bool starting; final String mode; @@ -64,6 +68,7 @@ class SiriSessionSnapshot { Map toMap() => { 'id': id, + 'startedAtMs': startedAt?.millisecondsSinceEpoch, 'active': active, 'starting': starting, 'mode': mode, diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart index 1be41d5a..5d267e08 100644 --- a/lib/services/external_commands/external_command_models.dart +++ b/lib/services/external_commands/external_command_models.dart @@ -255,6 +255,7 @@ class ExternalSessionCommand { required this.source, required this.kind, required this.issuedAt, + this.expiresAt, this.mode, this.sessionId, }); @@ -263,6 +264,14 @@ class ExternalSessionCommand { final ExternalCommandSource source; final ExternalSessionCommandKind kind; final DateTime issuedAt; + + /// The instant the issuing surface stops waiting for a real outcome. + /// + /// Siri resumes its App Intent with a failure once this passes, so the phone + /// must stop too rather than committing to the radio behind a person who was + /// already told it did not happen. Surfaces that wait indefinitely, such as + /// the watch, leave this null and fall back to [maximumExternalCommandAge]. + final DateTime? expiresAt; final String? mode; final String? sessionId; diff --git a/lib/services/external_commands/external_session_commands.dart b/lib/services/external_commands/external_session_commands.dart index e41a6831..10737754 100644 --- a/lib/services/external_commands/external_session_commands.dart +++ b/lib/services/external_commands/external_session_commands.dart @@ -2,9 +2,47 @@ import 'external_command_models.dart'; const Duration maximumExternalCommandAge = Duration(seconds: 30); const Duration externalCommandFutureTolerance = Duration(seconds: 5); + +/// Headroom reserved at the last checkpoint before an irreversible side effect. +/// +/// A deadline that has not quite passed is no use if the remaining work +/// outlives it: the surface would still have given up before the radio came up. +/// Refusing while this much is left keeps the two in the same order. +const Duration externalCommandCommitMargin = Duration(seconds: 2); + String get externalCommandExpiredReason => ExternalCommandReason.commandExpired.compactText; +/// Spoken form of [ExternalCommandReasonCode.commandExpired]. +/// +/// The compact label stays terse for the watch; a voice surface has room to say +/// what to do next. Connect builds its own responses, so it reads this too +/// rather than growing a fourth copy of the sentence. +const String externalCommandExpiredVoiceMessage = + 'That request took too long to reach MeshMapper. Try again.'; + +/// Refuses a command whose issuing surface has already stopped waiting. +ExternalCommandReason? externalCommandDeadlineRefusal( + DateTime? expiresAt, { + DateTime? now, +}) { + if (expiresAt == null) return null; + return (now ?? DateTime.now()).isBefore(expiresAt) + ? null + : ExternalCommandReason.commandExpired; +} + +/// Whether too little of the deadline is left to reach the radio in time. +/// +/// Admission runs before the awaited session check; this runs after it, at the +/// last point where refusing is still free. +bool externalCommandCannotCommit(DateTime? expiresAt, {DateTime? now}) { + if (expiresAt == null) return false; + return !(now ?? DateTime.now()) + .add(externalCommandCommitMargin) + .isBefore(expiresAt); +} + ExternalCommandReason? externalCommandTimestampRefusal( DateTime issuedAt, { DateTime? now, @@ -24,7 +62,8 @@ ExternalCommandReason? externalCommandAgeRefusal( DateTime? now, }) { if (command.kind == ExternalSessionCommandKind.stopSession) return null; - return externalCommandTimestampRefusal(command.issuedAt, now: now); + return externalCommandDeadlineRefusal(command.expiresAt, now: now) ?? + externalCommandTimestampRefusal(command.issuedAt, now: now); } /// Expands compact admission labels into complete, actionable voice responses. @@ -126,7 +165,8 @@ String _externalCommandVoiceMessage({ "MeshMapper couldn't send the manual ping. Open the app for details.", ExternalCommandReasonCode.appClosing => 'MeshMapper is closing. Open it and try again.', - ExternalCommandReasonCode.commandExpired || + ExternalCommandReasonCode.commandExpired => + externalCommandExpiredVoiceMessage, ExternalCommandReasonCode.noRememberedCompanion || ExternalCommandReasonCode.alreadyConnected || ExternalCommandReasonCode.anotherCompanionConnected || diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 44df99e6..3168363f 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -1039,6 +1039,22 @@ class MeshCoreConnection { await _write(data.toBytes()); } + /// Completes once an outbound non-sign write would not be parked. + /// + /// [_write] queues non-sign frames behind an in-progress sign, and that wait + /// is deliberately unbounded — a queued command is delayed, never failed. + /// Signing allows five seconds per protocol phase and the chunk phase loops, + /// so a caller working to a deadline that simply calls a send method can have + /// its frame reach the wire long after that deadline, with nothing left to + /// check by then. Awaiting this first moves the wait to a point where + /// abandoning is still free and leaves no half-built transmission behind. + Future awaitWritableState() async { + final gate = _signGate; + if (gate == null || gate.isCompleted) return; + debugLog('[CONN] Caller waiting out an in-progress sign before deciding'); + await gate.future; + } + // ============================================ // Command Methods (ported from connection.js) // ============================================ diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index e4909f97..abc22720 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -503,12 +503,40 @@ class PingService { return _manualPingCooldownTimer.remainingSec; } + /// Set when a deadline gate refused a transmission. + /// + /// Lets a caller tell "the radio never keyed up because I had already given + /// up" apart from the ordinary reasons a send is skipped — cooldown, failed + /// validation, no GPS. Only the former abandons a start or earns a spoken + /// "took too long"; the rest keep their own reasons. Reset on entry to every + /// gated path, so it always describes the send just attempted. + bool _transmitAbortedByDeadline = false; + + bool get transmitAbortedByDeadline => _transmitAbortedByDeadline; + + /// Whether the surface waiting on this transmission has already given up. + /// + /// Call at the last suspension point before an RF send. Every send path here + /// awaits a fresh GPS fix first, and that fix can outlive the deadline an + /// external surface is holding a person on. + bool _deadlinePassed(bool Function()? shouldAbortBeforeTransmit) { + if (!(shouldAbortBeforeTransmit?.call() ?? false)) return false; + _transmitAbortedByDeadline = true; + debugLog('[PING] Deadline passed before transmit — not sending'); + return true; + } + /// Send a TX ping /// @param manual - Whether this is a manual ping (true) or auto ping (false) + /// @param shouldAbortBeforeTransmit - Deadline gate for an external caller /// Returns true if ping was sent successfully /// Reference: sendPing() in wardrive.js - Future sendTxPing({bool manual = true}) async { + Future sendTxPing({ + bool manual = true, + bool Function()? shouldAbortBeforeTransmit, + }) async { debugLog('[PING] sendTxPing called (manual=$manual)'); + _transmitAbortedByDeadline = false; // Guard: don't send pings if connection is not in connected state // Handles race where timer callback fires after reconnect started @@ -534,6 +562,24 @@ class PingService { await _gpsService.getFreshPosition(); } + // The transport parks a non-sign write behind an in-progress sign, and + // that wait is unbounded — it would happen inside the send call below, + // after the TxPing record exists and past everything checkable here. Take + // it now, while abandoning still costs nothing. + if (shouldAbortBeforeTransmit != null) { + await _connection.awaitWritableState(); + } + + // Last suspension point before the transmission: with the write gate + // already open, nothing below this awaits until the BLE send. Checking + // here rather than at the send itself is the same instant in wall-clock + // terms and leaves no TxPing record and no consumed wire-tag counter + // behind for a ping that never went out. + if (_deadlinePassed(shouldAbortBeforeTransmit)) { + _pingInProgress = false; + return false; + } + // Use different validation and cooldown for manual vs auto pings if (manual) { // Manual ping: 15-second cooldown, no distance check @@ -1026,9 +1072,14 @@ class PingService { } /// Helper to send initial auto ping with error handling - Future _sendInitialAutoPing() async { + Future _sendInitialAutoPing({ + bool Function()? shouldAbortBeforeTransmit, + }) async { try { - await sendTxPing(manual: false); + await sendTxPing( + manual: false, + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); } catch (e) { debugLog('[ACTIVE MODE] Initial auto ping error: $e'); // Even on error, schedule next ping @@ -1043,11 +1094,18 @@ class PingService { /// @param hybridMode - If true, alternates discovery + TX pings each interval /// @param targetedMode - If true, sends trace path to specific repeater /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) + /// @param shouldAbortBeforeTransmit - Deadline gate for an external caller + /// + /// The gate lets an external surface holding a person on a deadline — Siri — + /// abandon the start if that deadline passes before the session's first + /// transmission. A session must never come up after the surface has already + /// reported that it did not. Future enableAutoPing({ bool passiveMode = false, bool hybridMode = false, bool targetedMode = false, String? targetRepeaterId, + bool Function()? shouldAbortBeforeTransmit, }) async { debugLog( '[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); @@ -1079,6 +1137,7 @@ class PingService { // Clear any previous skip reason _skipReason = null; + _transmitAbortedByDeadline = false; _autoPingEnabled = true; _passiveModeEnabled = passiveMode; @@ -1104,24 +1163,68 @@ class PingService { // Hybrid Mode: set up discovery infrastructure, then start with discovery debugLog( '[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); - await _startDiscoveryMode(); + await _startDiscoveryMode( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); // First ping was discovery, so next should be TX _nextPingIsDiscovery = false; } else if (passiveMode) { // Passive Mode: send discovery requests instead of TX pings debugLog( '[PASSIVE MODE] Passive Mode started - using discovery protocol'); - await _startDiscoveryMode(); + await _startDiscoveryMode( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); } else { // Active Mode: send first ping immediately, then schedule timer // Reference: sendPing(false) called immediately in startAutoPing() in wardrive.js debugLog('[ACTIVE MODE] Sending initial auto ping'); - _sendInitialAutoPing(); + if (shouldAbortBeforeTransmit == null) { + _sendInitialAutoPing(); + } else { + // Awaited only on the deadline-carrying path, so the ordinary start + // keeps returning as soon as the first ping is on its way. + await _sendInitialAutoPing( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); + } + } + + if (_transmitAbortedByDeadline) { + await _abandonAutoPingStart(); + return false; } return true; } + /// Unwind a start whose deadline passed before the first transmission. + /// + /// Mirrors the teardown in [disableAutoPing] without its cooldown and + /// pending-disable handling: nothing was transmitted, so there is no RX + /// window to let finish and no cooldown to earn. + Future _abandonAutoPingStart() async { + debugLog('[AUTO] Abandoning start — caller gave up before first transmit'); + _autoTimer?.cancel(); + _autoTimer = null; + _skipReason = null; + + if (_passiveModeEnabled || _hybridModeEnabled) { + _stopDiscoveryMode(); + } + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + + _autoPingEnabled = false; + _passiveModeEnabled = false; + _hybridModeEnabled = false; + _targetedModeEnabled = false; + _nextPingIsDiscovery = true; + + await _wakelockService.disable(); + } + /// Disable auto-ping mode (Active Mode or Passive Mode) /// Reference: stopAutoPing() and stopRxAuto() in wardrive.js Future disableAutoPing() async { @@ -1208,7 +1311,9 @@ class PingService { // ============================================ /// Start discovery mode - subscribes to control data and sends discovery requests - Future _startDiscoveryMode() async { + Future _startDiscoveryMode({ + bool Function()? shouldAbortBeforeTransmit, + }) async { debugLog('[DISC] Starting discovery mode'); // Create and configure discovery tracker @@ -1256,7 +1361,9 @@ class PingService { }); // Send first discovery request immediately - await _sendDiscoveryRequest(); + await _sendDiscoveryRequest( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); } /// Stop discovery mode - cleans up tracker and subscription @@ -1275,7 +1382,13 @@ class PingService { } /// Send a discovery request and start listening window - Future _sendDiscoveryRequest() async { + /// + /// [shouldAbortBeforeTransmit] is supplied only for the session's first + /// discovery. Later requests come from timers and belong to the session + /// itself, so no external deadline applies to them. + Future _sendDiscoveryRequest({ + bool Function()? shouldAbortBeforeTransmit, + }) async { // Guard: don't send discovery during reconnect (race with timer queue) if (_connection.currentStep != ConnectionStep.connected) { debugLog( @@ -1290,6 +1403,21 @@ class PingService { // Request fresh GPS position before discovery (same rationale as TX auto-ping) final position = await _gpsService.getFreshPosition(); + + // As in sendTxPing: the transport parks a non-sign write behind an + // in-progress sign, so take that wait here rather than inside the send. + if (shouldAbortBeforeTransmit != null) { + await _connection.awaitWritableState(); + } + + // Ahead of every early return below, not just of the send: those returns + // schedule a retry, so a caller that has already given up would otherwise + // get a session that transmits on the next tick anyway. + if (_deadlinePassed(shouldAbortBeforeTransmit)) { + _pingInProgress = false; + return; + } + if (position == null) { debugLog('[DISC] No GPS position, skipping discovery request'); _pingInProgress = false; diff --git a/test/services/app_intents/app_intent_bridge_service_test.dart b/test/services/app_intents/app_intent_bridge_service_test.dart index 62ebad3c..a5a25e3d 100644 --- a/test/services/app_intents/app_intent_bridge_service_test.dart +++ b/test/services/app_intents/app_intent_bridge_service_test.dart @@ -45,6 +45,7 @@ void main() { String source = 'siri', String kind = 'startSession', String? mode = 'passive', + Object? expiresAtMs, }) async { final reply = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger @@ -55,6 +56,7 @@ void main() { 'source': source, 'kind': kind, 'issuedAtMs': DateTime.now().millisecondsSinceEpoch, + if (expiresAtMs != null) 'expiresAtMs': expiresAtMs, if (mode != null) 'mode': mode, })), null, @@ -154,6 +156,60 @@ void main() { expect(result['message'], 'Malformed command'); }); + test("the intent's deadline reaches the shared session command", () async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + AppIntentCommand? received; + bridge.attachCommandHandler((command) async { + received = command; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + ); + }); + + await sendCommand( + 'command-deadline', + expiresAtMs: deadline.millisecondsSinceEpoch, + ); + + expect( + received?.expiresAt?.millisecondsSinceEpoch, + deadline.millisecondsSinceEpoch, + ); + expect( + received?.toExternalSessionCommand()?.expiresAt, + received?.expiresAt, + ); + }); + + test('an older native build sending no deadline still runs', () async { + AppIntentCommand? received; + bridge.attachCommandHandler((command) async { + received = command; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + ); + }); + + final result = await sendCommand('command-no-deadline'); + + expect(received?.expiresAt, isNull); + expect(result['success'], isTrue); + }); + + test('a non-numeric deadline fails closed', () async { + bridge.attachCommandHandler((_) async => throw StateError('not called')); + + final result = await sendCommand( + 'command-bad-deadline', + expiresAtMs: 'soon', + ); + + expect(result['disposition'], 'refused'); + expect(result['message'], 'Malformed command'); + }); + test('connect-last-companion remains a Siri-only mutation', () async { AppIntentCommand? received; bridge.attachCommandHandler((command) async { diff --git a/test/services/app_intents/last_companion_connection_test.dart b/test/services/app_intents/last_companion_connection_test.dart index 2606494a..eb15c32b 100644 --- a/test/services/app_intents/last_companion_connection_test.dart +++ b/test/services/app_intents/last_companion_connection_test.dart @@ -7,10 +7,12 @@ import 'package:mesh_mapper/services/external_commands/external_session_commands void main() { final now = DateTime.fromMillisecondsSinceEpoch(100000); - AppIntentCommand command({DateTime? issuedAt}) => AppIntentCommand( + AppIntentCommand command({DateTime? issuedAt, DateTime? expiresAt}) => + AppIntentCommand( id: 'connect-1', kind: AppIntentCommandKind.connectLastCompanion, issuedAt: issuedAt ?? now, + expiresAt: expiresAt, ); ExternalCommandAdmission resolve({ @@ -20,9 +22,10 @@ void main() { bool connecting = false, bool unattended = true, DateTime? issuedAt, + DateTime? expiresAt, }) => resolveLastCompanionConnection( - command: command(issuedAt: issuedAt), + command: command(issuedAt: issuedAt, expiresAt: expiresAt), hasRememberedCompanion: remembered, isConnected: connected, isConnectedToRememberedCompanion: connectedToRemembered, @@ -78,4 +81,40 @@ void main() { externalCommandExpiredReason, ); }); + + group("Connect honours the intent's deadline", () { + test('a passed deadline is refused even while the command is young', () { + // Connect's deadline is set when the command reaches Dart, so it can pass + // well before the shared 30-second age rule would refuse the command. + final admission = resolve( + expiresAt: now.subtract(const Duration(seconds: 1)), + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + expect(admission.reason?.compactText, externalCommandExpiredReason); + }); + + test('a live deadline still admits', () { + expect( + resolve(expiresAt: now.add(const Duration(seconds: 20))).disposition, + ExternalCommandDisposition.admitted, + ); + }); + + test('the deadline is checked before every other refusal', () { + // Expiry is about whether anyone is still listening, so it outranks the + // state-based refusals; reporting "already connecting" to a caller that + // has gone would be answering a question nobody asked. + final admission = resolve( + connecting: true, + expiresAt: now.subtract(const Duration(seconds: 1)), + ); + + expect(admission.reason?.compactText, externalCommandExpiredReason); + }); + + test('an older native build sending no deadline is unaffected', () { + expect(resolve().disposition, ExternalCommandDisposition.admitted); + }); + }); } diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart index 14009062..5fbd2d16 100644 --- a/test/services/app_intents/siri_snapshot_builder_test.dart +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -296,6 +296,146 @@ void main() { ); }); + group('unique repeaters heard is scoped to the running session', () { + SiriRepeaterObservation heard(String hexId, Duration ago) => + SiriRepeaterObservation( + entityId: 'SEA|$hexId', + displayHexId: hexId, + observedAt: now.subtract(ago), + kind: SiriObservationKind.passiveRx, + direct: true, + hopCount: 1, + resolved: true, + ); + + test('a previous session\'s repeaters are not credited to this one', () { + final observations = [ + heard('AAAA1111', const Duration(minutes: 2)), + heard('BBBB2222', const Duration(minutes: 90)), + heard('CCCC3333', const Duration(minutes: 100)), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(minutes: 5)), + ), + 1, + ); + }); + + test('an observation made at the boundary belongs to the session', () { + // The session's own first discovery is sent and recorded before the + // boundary is stored, so the boundary has to be inclusive and has to be + // captured before that transmission — otherwise a fresh Passive session + // reports nothing for its opening event. + final startedAt = now.subtract(const Duration(minutes: 5)); + final observations = [ + SiriRepeaterObservation( + entityId: 'SEA|first', + displayHexId: 'AAAA1111', + observedAt: startedAt, + kind: SiriObservationKind.discovery, + direct: true, + hopCount: 1, + resolved: true, + ), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard(observations, startedAt), + 1, + ); + }); + + test('an observation one millisecond earlier is a previous session', () { + final startedAt = now.subtract(const Duration(minutes: 5)); + final observations = [ + SiriRepeaterObservation( + entityId: 'SEA|earlier', + displayHexId: 'BBBB2222', + observedAt: startedAt.subtract(const Duration(milliseconds: 1)), + kind: SiriObservationKind.discovery, + direct: true, + hopCount: 1, + resolved: true, + ), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard(observations, startedAt), + 0, + ); + }); + + test('a session that has heard nothing yet reports zero', () { + final observations = [heard('BBBB2222', const Duration(minutes: 90))]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(seconds: 10)), + ), + 0, + ); + }); + + test('no running session means nothing to count', () { + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + [heard('AAAA1111', const Duration(minutes: 2))], + null, + ), + 0, + ); + }); + + test('one repeater heard repeatedly still counts once', () { + final observations = [ + heard('AAAA1111', const Duration(minutes: 1)), + heard('AAAA1111', const Duration(minutes: 2)), + heard('AAAA1111', const Duration(minutes: 3)), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(minutes: 10)), + ), + 1, + ); + }); + + test('unresolved observations count by hex, not collapsed together', () { + final observations = [ + SiriRepeaterObservation( + displayHexId: 'DDDD4444', + observedAt: now, + kind: SiriObservationKind.passiveRx, + direct: true, + hopCount: 1, + resolved: false, + ), + SiriRepeaterObservation( + displayHexId: 'EEEE5555', + observedAt: now, + kind: SiriObservationKind.passiveRx, + direct: true, + hopCount: 1, + resolved: false, + ), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(minutes: 1)), + ), + 2, + ); + }); + }); + test('non-finite radio and repeater values cannot poison the snapshot', () { const invalidLocation = Repeater( id: 'invalid-location', diff --git a/test/services/app_intents/siri_snapshot_wire_contract_test.dart b/test/services/app_intents/siri_snapshot_wire_contract_test.dart index 8f545a66..0b4308d3 100644 --- a/test/services/app_intents/siri_snapshot_wire_contract_test.dart +++ b/test/services/app_intents/siri_snapshot_wire_contract_test.dart @@ -14,6 +14,7 @@ void main() { ), session: SiriSessionSnapshot( id: 'session-1', + startedAt: at.subtract(const Duration(minutes: 4)), active: true, starting: false, mode: 'hybrid', @@ -87,6 +88,7 @@ void main() { }); expect((snapshot['session']! as Map).keys.toSet(), { 'id', + 'startedAtMs', 'active', 'starting', 'mode', diff --git a/test/services/external_commands/external_session_commands_test.dart b/test/services/external_commands/external_session_commands_test.dart index 90c4f10f..8138725f 100644 --- a/test/services/external_commands/external_session_commands_test.dart +++ b/test/services/external_commands/external_session_commands_test.dart @@ -8,12 +8,14 @@ ExternalSessionCommand command({ String? mode = 'passive', String? sessionId, DateTime? issuedAt, + DateTime? expiresAt, }) => ExternalSessionCommand( id: 'command-1', source: source, kind: kind, issuedAt: issuedAt ?? DateTime.fromMillisecondsSinceEpoch(100000), + expiresAt: expiresAt, mode: mode, sessionId: sessionId, ); @@ -322,4 +324,104 @@ void main() { expect(admission.disposition, ExternalCommandDisposition.refused); }); }); + + group("the issuing surface's deadline", () { + test('a passed deadline refuses a Start that is still young enough', () { + // Siri gives up long before the shared 30-second age rule would. + final admission = resolveExternalSessionTransition( + command: command(expiresAt: now.subtract(const Duration(seconds: 1))), + isSessionActive: false, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-a', + now: now, + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + expect(admission.reason?.compactText, externalCommandExpiredReason); + }); + + test('Stop stays exempt, because stopping is the safe direction', () { + final admission = resolveExternalSessionTransition( + command: command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + expiresAt: now.subtract(const Duration(seconds: 30)), + ), + isSessionActive: true, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-a', + now: now, + ); + + expect(admission.disposition, ExternalCommandDisposition.admitted); + }); + + test('a surface that names no deadline is unaffected', () { + final admission = resolveExternalSessionTransition( + command: command(), + isSessionActive: false, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-a', + now: now, + ); + + expect(admission.disposition, ExternalCommandDisposition.admitted); + }); + + test('the commit gate closes before the deadline, not after it', () { + // The awaited session check runs between admission and the radio. A + // deadline with less than the margin left cannot be met by work that has + // not started yet, so committing then would still land after Siri gave up. + expect( + externalCommandCannotCommit( + now.add(const Duration(seconds: 5)), + now: now, + ), + isFalse, + ); + expect( + externalCommandCannotCommit( + now.add(externalCommandCommitMargin), + now: now, + ), + isTrue, + ); + expect( + externalCommandCannotCommit( + now.subtract(const Duration(seconds: 1)), + now: now, + ), + isTrue, + ); + }); + + test('no deadline means nothing to miss', () { + expect(externalCommandCannotCommit(null, now: now), isFalse); + expect(externalCommandDeadlineRefusal(null, now: now), isNull); + }); + + test('an expired command gets a spoken reason, not the compact label', () { + final expired = command( + expiresAt: now.subtract(const Duration(seconds: 1)), + ); + final spoken = externalCommandCompletionForVoice( + command: expired, + completion: const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.commandExpired, + ), + ); + + expect(spoken.message?.compactText, externalCommandExpiredVoiceMessage); + expect( + externalCommandExpiredVoiceMessage, + isNot(externalCommandExpiredReason), + reason: 'the watch keeps the terse label; voice gets the next step', + ); + }); + }); } From de6faf1cac23de754d1f249beab498a7032ba14c Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 25 Aug 2026 17:50:27 -0700 Subject: [PATCH 338/360] Register the read-only Siri shortcuts from the app target App Shortcuts are indexed from the app, so an AppShortcutsProvider that lives in the App Intents extension is never registered: its phrases resolved to nothing when spoken, with no error to explain it. Status, Recent Repeaters and Find Repeater were all declared that way. Merge the two providers into one MeshMapperAppShortcuts in Runner and give the read intents and their entities dual target membership, which is Apple's arrangement for an intent that backs an App Shortcut and must also run in an extension. Reads still execute in the extension and never launch Flutter. Withdraw the Find Repeater phrase for now. Spoken lookup still failed on device, and a registered phrase that fails is worse than one that was never offered. The intent, entity and query stay built and usable from the Shortcuts app, with a TODO naming the two suspects worth checking. --- DEVELOPMENT.md | 33 +++++++++++--- .../MeshMapperReadIntents.swift | 43 ++++--------------- ios/Runner.xcodeproj/project.pbxproj | 14 ++++-- ios/Runner/AppDelegate.swift | 2 +- ...uts.swift => MeshMapperAppShortcuts.swift} | 35 ++++++++++++++- 5 files changed, 81 insertions(+), 46 deletions(-) rename ios/Runner/AppIntents/{MeshMapperMutationAppShortcuts.swift => MeshMapperAppShortcuts.swift} (50%) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 11307e05..e2b872ca 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -753,9 +753,25 @@ these Siri actions, and none of the app's other surfaces depend on them. Mutation intents live in the Runner process because session and connection changes must pass through the -same phone-owned admission path as the watch. Read-only intents use a separate -extension and the bounded App Group snapshot in `ios/Shared/AppIntents/`; they -must not launch Flutter. Despite its historical `Siri` type names and +same phone-owned admission path as the watch. Read-only intents *run* in a +separate extension against the bounded App Group snapshot in +`ios/Shared/AppIntents/`; they must not launch Flutter. + +**There is exactly one `AppShortcutsProvider`, `MeshMapperAppShortcuts`, and it +lives in the Runner target.** App Shortcuts are indexed from the app, so a +provider inside the App Intents extension is never registered and its phrases +silently do nothing when spoken — no error, just a pause and no result. Apple +additionally requires that every intent a provider names is a member of the +*same* target as the provider, which is why the read intents and their entities +(`MeshMapperReadIntents.swift`, `RepeaterEntity.swift`, +`HeardRepeaterEntity.swift`) are compiled into Runner **and** the extension. +Dual target membership is Apple's documented arrangement for an intent that +backs an App Shortcut and must also run in an extension; a shared framework is +explicitly not an option for these. The provider is capped at ten shortcuts — +six are used — and exceeding it is a compile error. + +When adding a read intent: add its file to both targets, and add the shortcut to +`MeshMapperAppShortcuts`, never to a second provider. Despite its historical `Siri` type names and `siri-snapshot.json` filename, that Foundation-only snapshot is the reusable low-frequency contract for future native glance surfaces. @@ -889,7 +905,9 @@ catalogue bound is also the entity-lookup bound: `RepeaterEntityQuery` searches only the cached active/recent entries, never the full loaded repeater set, which is why the intent is named "Find Recent MeshMapper Repeater". Widening the lookup means widening the catalogue or adding a separate compact index — do not -leave a broad name over a narrow index. Recent +leave a broad name over a narrow index, and keep that in mind when reviving the +withdrawn phrase, since an empty or stale catalogue is one candidate cause of +the lookup failure. Recent observations are ranked and truncated before catalogue identity resolution, so large histories do not multiply the resolution work. A cheap scalar/revision preflight key suppresses rebuilds when provider notifications do not change @@ -908,8 +926,11 @@ of every registered phrase): - `Siri, stop MeshMapper`. - `Siri, what is MeshMapper doing?` or `Siri, get MeshMapper status`. - `Siri, what has MeshMapper heard?` or `Siri, recent repeaters in MeshMapper`. -- `Siri, find a recent repeater in MeshMapper` (Siri asks which repeater when - needed; only active/recent cached repeaters resolve). + +Repeater lookup by name has no spoken phrase. `FindMeshMapperRepeaterIntent` +ships and can be used from the Shortcuts app, but spoken lookup did not work on +device and its `AppShortcut` is withdrawn until it does — see the TODO on the +intent. Adding the phrase back means adding the bullet back here. Mutation intents return their completion message both as Siri dialog and as a text output. A user-created Shortcut can pass that Result to a `Speak Text` diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift index 57b18c39..c242df9c 100644 --- a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -254,6 +254,15 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { } } +/// TODO: Not currently offered as a spoken App Shortcut. +/// +/// Spoken lookup did not work in on-device testing — the phrase was withdrawn +/// from `MeshMapperAppShortcuts` rather than hold up the branch. The intent +/// itself is still built into both targets and remains available in the +/// Shortcuts app, so re-registering is a matter of restoring the `AppShortcut` +/// entry once the disambiguation flow is understood. Suspects worth checking +/// first: `RepeaterEntityQuery.entities(matching:)` matching against spoken +/// text, and an empty or stale catalogue in the App Group snapshot. @available(iOS 26.0, *) struct FindMeshMapperRepeaterIntent: AppIntent { // Named for what it can actually search. The App Group catalog holds the @@ -300,37 +309,3 @@ struct FindMeshMapperRepeaterIntent: AppIntent { ) } } - -@available(iOS 26.0, *) -struct MeshMapperReadAppShortcuts: AppShortcutsProvider { - static var appShortcuts: [AppShortcut] { - AppShortcut( - intent: GetMeshMapperStatusIntent(), - phrases: [ - "What is \(.applicationName) doing", - "Get \(.applicationName) status", - ], - shortTitle: "Session Status", - systemImageName: "waveform" - ) - AppShortcut( - intent: GetRecentlyHeardRepeatersIntent(), - phrases: [ - "What has \(.applicationName) heard", - "Recent repeaters in \(.applicationName)", - ], - shortTitle: "Recent Repeaters", - systemImageName: "dot.radiowaves.left.and.right" - ) - AppShortcut( - intent: FindMeshMapperRepeaterIntent(), - phrases: [ - "Find a recent repeater in \(.applicationName)", - ], - shortTitle: "Find Repeater", - systemImageName: "magnifyingglass" - ) - } - - static var shortcutTileColor: ShortcutTileColor { .teal } -} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ab8e63b4..ef4b6001 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -44,7 +44,7 @@ B10000000000000000000003 /* StartMeshMapperSessionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */; }; B10000000000000000000004 /* StopMeshMapperSessionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */; }; B10000000000000000000005 /* SendManualPingIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* SendManualPingIntent.swift */; }; - B10000000000000000000006 /* MeshMapperMutationAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* MeshMapperMutationAppShortcuts.swift */; }; + B10000000000000000000006 /* MeshMapperAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* MeshMapperAppShortcuts.swift */; }; B10000000000000000000010 /* ConnectLastMeshMapperCompanionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */; }; B10000000000000000000007 /* MeshMapperSiriSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */; }; B10000000000000000000008 /* MeshMapperSiriSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */; }; @@ -54,6 +54,9 @@ B1000000000000000000000C /* MeshMapperSiriSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */; }; B1000000000000000000000D /* RepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* RepeaterEntity.swift */; }; B1000000000000000000000E /* HeardRepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* HeardRepeaterEntity.swift */; }; + B1000000000000000000001A /* MeshMapperReadIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000B /* MeshMapperReadIntents.swift */; }; + B1000000000000000000001B /* RepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* RepeaterEntity.swift */; }; + B1000000000000000000001C /* HeardRepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* HeardRepeaterEntity.swift */; }; B1000000000000000000000F /* MeshMapperAppIntents.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* MeshMapperAppIntents.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -188,7 +191,7 @@ B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartMeshMapperSessionIntent.swift; sourceTree = ""; }; B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopMeshMapperSessionIntent.swift; sourceTree = ""; }; B20000000000000000000005 /* SendManualPingIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendManualPingIntent.swift; sourceTree = ""; }; - B20000000000000000000006 /* MeshMapperMutationAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperMutationAppShortcuts.swift; sourceTree = ""; }; + B20000000000000000000006 /* MeshMapperAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperAppShortcuts.swift; sourceTree = ""; }; B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSiriSnapshot.swift; sourceTree = ""; }; B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSiriSnapshotStore.swift; sourceTree = ""; }; B20000000000000000000009 /* RepeaterEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeaterEntity.swift; sourceTree = ""; }; @@ -392,7 +395,7 @@ B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */, B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */, B20000000000000000000005 /* SendManualPingIntent.swift */, - B20000000000000000000006 /* MeshMapperMutationAppShortcuts.swift */, + B20000000000000000000006 /* MeshMapperAppShortcuts.swift */, B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */, ); path = AppIntents; @@ -756,10 +759,13 @@ B10000000000000000000003 /* StartMeshMapperSessionIntent.swift in Sources */, B10000000000000000000004 /* StopMeshMapperSessionIntent.swift in Sources */, B10000000000000000000005 /* SendManualPingIntent.swift in Sources */, - B10000000000000000000006 /* MeshMapperMutationAppShortcuts.swift in Sources */, + B10000000000000000000006 /* MeshMapperAppShortcuts.swift in Sources */, B10000000000000000000010 /* ConnectLastMeshMapperCompanionIntent.swift in Sources */, B10000000000000000000007 /* MeshMapperSiriSnapshot.swift in Sources */, B10000000000000000000008 /* MeshMapperSiriSnapshotStore.swift in Sources */, + B1000000000000000000001A /* MeshMapperReadIntents.swift in Sources */, + B1000000000000000000001B /* RepeaterEntity.swift in Sources */, + B1000000000000000000001C /* HeardRepeaterEntity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c40e78d0..97f49786 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -89,7 +89,7 @@ class IOSMapOfflineBridge { // to refresh parameters on launch makes newly added Siri phrases and enum // synonyms available promptly after an app update. if #available(iOS 26.0, *) { - MeshMapperMutationAppShortcuts.updateAppShortcutParameters() + MeshMapperAppShortcuts.updateAppShortcutParameters() } // Register background service diff --git a/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift b/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift similarity index 50% rename from ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift rename to ios/Runner/AppIntents/MeshMapperAppShortcuts.swift index f284c12c..d3477e57 100644 --- a/ios/Runner/AppIntents/MeshMapperMutationAppShortcuts.swift +++ b/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift @@ -1,7 +1,16 @@ import AppIntents +/// The app's single `AppShortcutsProvider`. +/// +/// Both the provider and every intent it names must be members of the app +/// target — an `AppShortcutsProvider` inside the App Intents extension is never +/// indexed, so its phrases silently do nothing when spoken. The read intents +/// are therefore compiled into Runner as well as the extension, which is +/// Apple's documented arrangement for an intent that backs an App Shortcut and +/// must also run in an extension. Keep the count at or below ten; exceeding it +/// is a compile error. @available(iOS 26.0, *) -struct MeshMapperMutationAppShortcuts: AppShortcutsProvider { +struct MeshMapperAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: ConnectLastMeshMapperCompanionIntent(), @@ -42,6 +51,30 @@ struct MeshMapperMutationAppShortcuts: AppShortcutsProvider { shortTitle: "Stop Session", systemImageName: "stop.fill" ) + AppShortcut( + intent: GetMeshMapperStatusIntent(), + phrases: [ + "What is \(.applicationName) doing", + "Get \(.applicationName) status", + ], + shortTitle: "Session Status", + systemImageName: "waveform" + ) + AppShortcut( + intent: GetRecentlyHeardRepeatersIntent(), + phrases: [ + "What has \(.applicationName) heard", + "Recent repeaters in \(.applicationName)", + ], + shortTitle: "Recent Repeaters", + systemImageName: "dot.radiowaves.left.and.right" + ) + // TODO: Re-register FindMeshMapperRepeaterIntent once spoken repeater + // lookup works. The intent, RepeaterEntity and RepeaterEntityQuery are all + // still built and shipped — only the spoken phrase is withdrawn, because a + // registered phrase that fails is worse than one that was never offered. + // Restore the AppShortcut here, and its bullet in DEVELOPMENT.md's phrase + // list, in the same change. } static var shortcutTileColor: ShortcutTileColor { .teal } From 53fea1f98544fab2a5f2adfff694a0e4cf9a7d5a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 26 Aug 2026 22:55:18 -0400 Subject: [PATCH 339/360] Show the real error message when a Siri intent fails --- .../AppIntents/SiriIntentCoordinator.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/ios/Runner/AppIntents/SiriIntentCoordinator.swift b/ios/Runner/AppIntents/SiriIntentCoordinator.swift index e8f4e1a1..b21c6a38 100644 --- a/ios/Runner/AppIntents/SiriIntentCoordinator.swift +++ b/ios/Runner/AppIntents/SiriIntentCoordinator.swift @@ -1,3 +1,4 @@ +import AppIntents import Flutter import Foundation @@ -7,7 +8,8 @@ enum SiriBridgeError: LocalizedError { case invalidResponse case flutter(String) - var errorDescription: String? { + /// The single source for what the person is told, spoken and written alike. + var message: String { switch self { case .appUnavailable: return "MeshMapper did not finish launching in time." @@ -19,6 +21,18 @@ enum SiriBridgeError: LocalizedError { return message } } + + var errorDescription: String? { message } +} + +/// App Intents dialogs read `localizedStringResource`, not `errorDescription`. +/// Without this conformance every error thrown out of `perform()` reaches the +/// person as a generic system failure dialog instead of the reason above. +@available(iOS 16.0, *) +extension SiriBridgeError: CustomLocalizedStringResourceConvertible { + var localizedStringResource: LocalizedStringResource { + LocalizedStringResource(stringLiteral: message) + } } struct SiriCommand { @@ -38,8 +52,8 @@ struct SiriCommand { /// Connecting cannot be called back once a transport is dialling, and a BLE /// GATT phase alone may run 15 seconds before protocol setup and /// authentication. Stopping is deliberately exempt from the deadline - /// altogether — a safe-direction command must never be abandoned because a - /// voice request timed out — and its teardown may queue a pending disable + /// altogether (a safe-direction command must never be abandoned because a + /// voice request timed out), and its teardown may queue a pending disable /// behind an RX window. Both overrun as a matter of course rather than /// exceptionally, so claiming cancellation would be a lie about as often as /// it was true. @@ -69,7 +83,7 @@ struct SiriCommand { /// How long the intent waits for the real outcome before telling the person /// it failed. The same instant is handed to Dart as `expiresAtMs`, so giving - /// up here and giving up there are one decision rather than two — for the + /// up here and giving up there are one decision rather than two, for the /// session kinds. For `connectLastCompanion` it bounds the *response* only; /// see `Kind.timeoutMessage` for why, and for what is said instead. var responseTimeout: TimeInterval { From 7b31df9078ee0bd3bc832d5a37900e99fc6599f9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 26 Aug 2026 22:55:18 -0400 Subject: [PATCH 340/360] Speak ages in the singular and say why two intents skip auth --- .../MeshMapperReadIntents.swift | 22 ++++++++++++++----- .../AppIntents/MeshMapperAppShortcuts.swift | 4 ++-- .../StopMeshMapperSessionIntent.swift | 2 ++ ios/RunnerTests/AppIntentLogicTests.swift | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift index c242df9c..235fcdea 100644 --- a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -11,10 +11,17 @@ enum MeshMapperSnapshotFreshness { static func relativeAge(_ age: TimeInterval) -> String { let seconds = max(Int(age), 0) - if seconds < 60 { return "\(seconds) seconds ago" } - if seconds < 60 * 60 { return "\(seconds / 60) minutes ago" } - if seconds < 24 * 60 * 60 { return "\(seconds / (60 * 60)) hours ago" } - return "\(seconds / (24 * 60 * 60)) days ago" + if seconds < 60 { return "\(counted(seconds, "second")) ago" } + if seconds < 60 * 60 { return "\(counted(seconds / 60, "minute")) ago" } + if seconds < 24 * 60 * 60 { + return "\(counted(seconds / (60 * 60), "hour")) ago" + } + return "\(counted(seconds / (24 * 60 * 60), "day")) ago" + } + + /// This is spoken aloud, so "1 seconds ago" is heard rather than skimmed. + private static func counted(_ count: Int, _ unit: String) -> String { + "\(count) \(unit)\(count == 1 ? "" : "s")" } } @@ -256,7 +263,7 @@ struct GetRecentlyHeardRepeatersIntent: AppIntent { /// TODO: Not currently offered as a spoken App Shortcut. /// -/// Spoken lookup did not work in on-device testing — the phrase was withdrawn +/// Spoken lookup did not work in on-device testing, so the phrase was withdrawn /// from `MeshMapperAppShortcuts` rather than hold up the branch. The intent /// itself is still built into both targets and remains available in the /// Shortcuts app, so re-registering is a matter of restoring the `AppShortcut` @@ -272,6 +279,11 @@ struct FindMeshMapperRepeaterIntent: AppIntent { static let description = IntentDescription( "Looks up one of the active or recently heard repeaters MeshMapper has cached, and says how current that reading is." ) + // Unlocking is not required because nothing private is read. This answers + // from the cached catalog of publicly broadcast repeater identities, and + // never touches the user's location or their session activity. The two read + // intents above do gate: both speak what this device has been doing, which + // is the part worth keeping behind the lock screen. static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } static var supportedModes: IntentModes { .background } diff --git a/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift b/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift index d3477e57..d399c1f7 100644 --- a/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift +++ b/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift @@ -3,7 +3,7 @@ import AppIntents /// The app's single `AppShortcutsProvider`. /// /// Both the provider and every intent it names must be members of the app -/// target — an `AppShortcutsProvider` inside the App Intents extension is never +/// target: an `AppShortcutsProvider` inside the App Intents extension is never /// indexed, so its phrases silently do nothing when spoken. The read intents /// are therefore compiled into Runner as well as the extension, which is /// Apple's documented arrangement for an intent that backs an App Shortcut and @@ -71,7 +71,7 @@ struct MeshMapperAppShortcuts: AppShortcutsProvider { ) // TODO: Re-register FindMeshMapperRepeaterIntent once spoken repeater // lookup works. The intent, RepeaterEntity and RepeaterEntityQuery are all - // still built and shipped — only the spoken phrase is withdrawn, because a + // still built and shipped; only the spoken phrase is withdrawn, because a // registered phrase that fails is worse than one that was never offered. // Restore the AppShortcut here, and its bullet in DEVELOPMENT.md's phrase // list, in the same change. diff --git a/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift index c5e313eb..9e9efdf4 100644 --- a/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift +++ b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift @@ -3,6 +3,8 @@ import AppIntents @available(iOS 26.0, *) struct StopMeshMapperSessionIntent: AppIntent { static let title: LocalizedStringResource = "Stop MeshMapper Session" + // Stopping is the safe direction: it only ends transmissions, exposes + // nothing, and must stay reachable from a locked phone mid-drive. static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } static var supportedModes: IntentModes { [.background, .foreground(.dynamic)] diff --git a/ios/RunnerTests/AppIntentLogicTests.swift b/ios/RunnerTests/AppIntentLogicTests.swift index 0d2a93cb..b1190068 100644 --- a/ios/RunnerTests/AppIntentLogicTests.swift +++ b/ios/RunnerTests/AppIntentLogicTests.swift @@ -15,7 +15,7 @@ final class AppIntentLogicTests: XCTestCase { func testCommandCarriesTheSameDeadlineTheIntentWaitsOn() { // Dart must be able to stop for the same reason, and at the same instant, - // that the intent stops waiting — otherwise a slow start reaches the radio + // that the intent stops waiting; otherwise a slow start reaches the radio // after the person has already been told it failed. let command = SiriCommand(kind: .startSession, mode: "active") let expiresAt = Date().addingTimeInterval(command.responseTimeout) From 38e9899f33979a5a41a47f86c5322f3cd89dd720 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 26 Aug 2026 22:55:23 -0400 Subject: [PATCH 341/360] Count the whole session, publish discoveries as they land, say Trace not targeted --- lib/providers/app_state_provider.dart | 30 +++- .../app_intents/siri_snapshot_builder.dart | 97 ++++++++++-- .../external_command_models.dart | 2 +- .../external_session_commands.dart | 8 +- .../siri_snapshot_builder_test.dart | 145 +++++++++++++++++- .../external_session_commands_test.dart | 28 +++- 6 files changed, 282 insertions(+), 28 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d2243714..ff26ff63 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -79,7 +79,18 @@ enum AutoMode { hybrid, /// Trace Mode: Zero-hop trace to specific repeater - targeted, + targeted; + + /// What the app's own buttons call this mode, minus the trailing "Mode". + /// + /// The wire name is not speakable: the UI has never called `targeted` + /// anything but Trace, so a spoken sentence must not leak the enum name. + String get displayName => switch (this) { + AutoMode.active => 'Active', + AutoMode.passive => 'Passive', + AutoMode.hybrid => 'Hybrid', + AutoMode.targeted => 'Trace', + }; } /// Ping type for the top-heard overlay dots @@ -1900,7 +1911,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ]; final sessionStartedAt = _liveActivitySessionStartedAt; final uniqueHeard = SiriSnapshotBuilder.countUniqueRepeatersHeard( - recentHeard, + recentHeard.distinctHeard, sessionStartedAt, ); @@ -1945,8 +1956,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? _manualPingCooldownTimer.endTime : null, ), - recentHeard: recentHeard, - repeaters: SiriSnapshotBuilder.buildRepeaterCatalog(_repeaters), + recentHeard: recentHeard.observations, + repeaters: SiriSnapshotBuilder.buildRepeaterCatalog( + _repeaters, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + ), ).toMap(); } @@ -2051,6 +2065,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { isSessionStarting: _autoPingStarting, currentMode: _autoMode.name, currentSessionId: _liveActivitySessionId ?? 'idle', + currentModeLabel: _autoMode.displayName, ); if (!transition.shouldExecute) return transition; @@ -2231,8 +2246,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), ); } - // Admission ran before that wait, so re-check: a cold launch can spend the - // whole readiness budget and leave the caller already gone. + // The deadline the coordinator stamped can expire during that wait: a cold + // launch can spend the whole readiness budget and leave the caller already + // gone. Check it before doing any further work; admission runs after this + // and checks it again, so an expiry landing in between is still caught. if (externalCommandDeadlineRefusal(command.expiresAt) != null) { return const ExternalCommandCompletion( success: false, @@ -4676,6 +4693,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { identities: _discoveryIdentities(discPing.discoveredNodes), ); _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); + _siriObservationRevision++; _notifyMapThrottled(); }; diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart index d25e3678..072f2afc 100644 --- a/lib/services/app_intents/siri_snapshot_builder.dart +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -11,7 +11,16 @@ class SiriSnapshotBuilder { static const int maximumRepeaters = 64; static const Duration maximumObservationAge = Duration(hours: 2); - static List buildRecentHeard({ + /// Stable identity a Siri entity is keyed on. + /// + /// The heard entity and the catalogue entity are joined on this string, so + /// both sides must derive it the same way. [Repeater.iata] is nullable, and a + /// repeater without one would otherwise be filed under two different keys and + /// lose its name, zone and coordinates on the heard side. + static String repeaterEntityId(Repeater repeater, String? zoneCode) => + '${repeater.iata ?? zoneCode ?? 'GLOBAL'}|${repeater.id}'; + + static SiriRecentHeard buildRecentHeard({ required List txEntries, required List rxEntries, required List discoveryEntries, @@ -23,10 +32,14 @@ class SiriSnapshotBuilder { }) { final cutoff = (now ?? DateTime.now()).subtract(maximumObservationAge); final candidates = <_SiriObservationCandidate>[]; + final repeaterCache = {}; - SiriRepeaterObservation resolve(_SiriObservationCandidate candidate) { - final normalizedId = candidate.displayId.toUpperCase(); - final repeater = (candidate.identity == null + // The catalogue scan is the expensive part, and the same repeater is heard + // over and over, so the lookup is memoised on the candidate's raw identity. + Repeater? lookup(_SiriObservationCandidate candidate) { + final cacheKey = _identityKey(candidate); + if (repeaterCache.containsKey(cacheKey)) return repeaterCache[cacheKey]; + final found = (candidate.identity == null ? null : ExternalSurfaceGeoBuilder.resolveRepeater( repeaters: repeaters, @@ -35,9 +48,16 @@ class SiriSnapshotBuilder { )) ?? ExternalSurfaceGeoBuilder.resolveRepeater( repeaters: repeaters, - id: normalizedId, + id: candidate.displayId.toUpperCase(), hopBytes: hopBytes, ); + repeaterCache[cacheKey] = found; + return found; + } + + SiriRepeaterObservation resolve(_SiriObservationCandidate candidate) { + final normalizedId = candidate.displayId.toUpperCase(); + final repeater = lookup(candidate); final resolved = repeater != null; final hasDistance = resolved && repeater.hasLocation && @@ -46,9 +66,7 @@ class SiriSnapshotBuilder { candidate.latitude.isFinite && candidate.longitude.isFinite; return SiriRepeaterObservation( - entityId: resolved - ? '${repeater.iata ?? zoneCode ?? 'GLOBAL'}|${repeater.id}' - : null, + entityId: resolved ? repeaterEntityId(repeater, zoneCode) : null, displayHexId: normalizedId, name: resolved && repeater.name != 'Unknown' ? repeater.name : null, observedAt: candidate.observedAt, @@ -161,17 +179,39 @@ class SiriSnapshotBuilder { )); } - // Identity resolution scans the zone catalogue. Rank and bound the cheap - // raw candidates first so a busy two-hour log pays that cost at most 64 - // times rather than once per observation across all four histories. + // One row per distinct raw identity, carrying that repeater's most recent + // sighting. Collapsing the candidates before resolving keeps the catalogue + // scan proportional to the number of repeaters rather than the number of + // observations, and it is what lets the session count see past the + // [maximumObservations] newest events without walking the whole history + // again. + final newestByIdentity = {}; + for (final candidate in candidates) { + final key = _identityKey(candidate); + final existing = newestByIdentity[key]; + if (existing == null || + existing.observedAt.isBefore(candidate.observedAt)) { + newestByIdentity[key] = candidate; + } + } + candidates.sort((a, b) => b.observedAt.compareTo(a.observedAt)); - return candidates.take(maximumObservations).map(resolve).toList( - growable: false, - ); + return SiriRecentHeard( + observations: candidates.take(maximumObservations).map(resolve).toList( + growable: false, + ), + distinctHeard: + newestByIdentity.values.map(resolve).toList(growable: false), + ); } /// Counts distinct repeaters heard since [sessionStartedAt]. /// + /// Feed this [SiriRecentHeard.distinctHeard], not the snapshot's + /// [SiriRecentHeard.observations]: that list is capped at + /// [maximumObservations] for the wire, and counting it would cap the spoken + /// total at the 64 newest events instead of the whole session. + /// /// [maximumObservationAge] bounds the cache, not a session: a two-hour /// history routinely spans several sessions, so counting all of it would let /// a session that has heard nothing report the previous one's repeaters. @@ -188,9 +228,14 @@ class SiriSnapshotBuilder { .length; } + static String _identityKey(_SiriObservationCandidate candidate) => + '${candidate.identity?.toUpperCase() ?? ''}|' + '${candidate.displayId.toUpperCase()}'; + static List buildRepeaterCatalog( - List repeaters, - ) { + List repeaters, { + String? zoneCode, + }) { final ranked = List.of(repeaters) ..sort((a, b) { final active = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); @@ -204,7 +249,7 @@ class SiriSnapshotBuilder { repeater.lat.isFinite && repeater.lon.isFinite; return SiriRepeaterEntitySnapshot( - id: '${repeater.iata ?? 'GLOBAL'}|${repeater.id}', + id: repeaterEntityId(repeater, zoneCode), name: repeater.name, hexId: repeater.hexId.toUpperCase(), zoneCode: repeater.iata, @@ -223,6 +268,24 @@ class SiriSnapshotBuilder { } } +/// The two views of the same heard history the snapshot needs. +/// +/// [observations] is what ships to the watch and to Siri, bounded so the App +/// Group payload stays small. [distinctHeard] is the unbounded roll-up used for +/// counting, so a long session's total is not silently capped at that bound. +class SiriRecentHeard { + const SiriRecentHeard({ + required this.observations, + required this.distinctHeard, + }); + + /// Newest first, capped at [SiriSnapshotBuilder.maximumObservations]. + final List observations; + + /// One entry per distinct repeater, carrying its most recent sighting. + final List distinctHeard; +} + class _SiriObservationCandidate { const _SiriObservationCandidate({ required this.displayId, diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart index 5d267e08..f9e943ed 100644 --- a/lib/services/external_commands/external_command_models.dart +++ b/lib/services/external_commands/external_command_models.dart @@ -227,7 +227,7 @@ class ExternalCommandReason { ExternalCommandReasonCode.anotherOperationInProgress => 'Another operation is in progress', ExternalCommandReasonCode.stillStartingTryStopAgain => - 'Still starting — try Stop again', + 'Still starting, try Stop again', ExternalCommandReasonCode.sessionAlreadyEnded => 'That session already ended', ExternalCommandReasonCode.couldNotStart => 'Could not start', diff --git a/lib/services/external_commands/external_session_commands.dart b/lib/services/external_commands/external_session_commands.dart index 10737754..e808b855 100644 --- a/lib/services/external_commands/external_session_commands.dart +++ b/lib/services/external_commands/external_session_commands.dart @@ -177,12 +177,17 @@ String _externalCommandVoiceMessage({ }; } +/// [currentMode] is the wire name of the running mode and is what the stop path +/// resolves back into an [ExternalSessionMode]. [currentModeLabel] is the name +/// a person hears, which the app spells differently (Trace, not targeted); it +/// falls back to the wire name so a caller with nothing better still reads. ExternalCommandAdmission resolveExternalSessionTransition({ required ExternalSessionCommand command, required bool isSessionActive, required bool isSessionStarting, required String currentMode, required String currentSessionId, + String? currentModeLabel, DateTime? now, }) { final ageRefusal = externalCommandAgeRefusal(command, now: now); @@ -199,7 +204,8 @@ ExternalCommandAdmission resolveExternalSessionTransition({ return ExternalCommandAdmission( disposition: ExternalCommandDisposition.noOp, reason: ExternalCommandReason.other( - 'MeshMapper is already running in $currentMode mode.', + 'MeshMapper is already running in ' + '${currentModeLabel ?? currentMode} mode.', ), ); } diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart index 5fbd2d16..9a28751d 100644 --- a/test/services/app_intents/siri_snapshot_builder_test.dart +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -18,7 +18,7 @@ void main() { iata: 'SEA', ); - List build({ + SiriRecentHeard buildHeard({ List tx = const [], List rx = const [], List discovery = const [], @@ -36,6 +36,21 @@ void main() { now: now, ); + List build({ + List tx = const [], + List rx = const [], + List discovery = const [], + List trace = const [], + List repeaters = const [capitolHill], + }) => + buildHeard( + tx: tx, + rx: rx, + discovery: discovery, + trace: trace, + repeaters: repeaters, + ).observations; + test('direct TX resolves only an unambiguous identity and uses event GPS', () { final at = now.subtract(const Duration(minutes: 1)); @@ -296,6 +311,132 @@ void main() { ); }); + test('the unique count sees repeaters older than the 64 newest events', () { + // 80 distinct repeaters, each heard once, newest first. The wire list keeps + // the 64 newest; the count has to answer for the whole session, so the 16 + // that fall off the end must still be credited. + final repeaters = List.generate( + 80, + (index) => Repeater( + id: 'database-$index', + hexId: (0x10000000 + index).toRadixString(16).toUpperCase(), + name: 'Repeater $index', + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ), + ); + final entries = [ + for (var index = 0; index < repeaters.length; index++) + RxLogEntry( + timestamp: now.subtract(Duration(seconds: index)), + repeaterId: repeaters[index].hexId, + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ]; + + final heard = buildHeard(rx: entries, repeaters: repeaters); + + expect(heard.observations, hasLength(64)); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.observations, + now.subtract(const Duration(minutes: 5)), + ), + 64, + reason: 'the capped wire list is exactly what used to be counted', + ); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 80, + ); + }); + + test('a repeater heard only outside the session is not counted', () { + final entries = [ + RxLogEntry( + timestamp: now.subtract(const Duration(minutes: 90)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ]; + + final heard = buildHeard(rx: entries); + + expect(heard.distinctHeard, hasLength(1)); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 0, + ); + }); + + test('a repeater with no IATA keys the same on both sides of the join', () { + const zoneless = Repeater( + id: 'database-999', + hexId: '77889900', + name: 'Zoneless', + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + ); + final heard = buildHeard( + rx: [ + RxLogEntry( + timestamp: now, + repeaterId: '77889900', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ], + repeaters: const [zoneless], + ); + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog( + const [zoneless], + zoneCode: 'SEA', + ); + + expect(heard.observations.single.entityId, 'SEA|database-999'); + expect(catalog.single.id, heard.observations.single.entityId); + }); + + test('a repeater with no IATA and no zone falls back to GLOBAL', () { + const zoneless = Repeater( + id: 'database-999', + hexId: '77889900', + name: 'Zoneless', + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + ); + + expect( + SiriSnapshotBuilder.repeaterEntityId(zoneless, null), + 'GLOBAL|database-999', + ); + expect( + SiriSnapshotBuilder.buildRepeaterCatalog(const [zoneless]).single.id, + 'GLOBAL|database-999', + ); + }); + group('unique repeaters heard is scoped to the running session', () { SiriRepeaterObservation heard(String hexId, Duration ago) => SiriRepeaterObservation( @@ -327,7 +468,7 @@ void main() { test('an observation made at the boundary belongs to the session', () { // The session's own first discovery is sent and recorded before the // boundary is stored, so the boundary has to be inclusive and has to be - // captured before that transmission — otherwise a fresh Passive session + // captured before that transmission; otherwise a fresh Passive session // reports nothing for its opening event. final startedAt = now.subtract(const Duration(minutes: 5)); final observations = [ diff --git a/test/services/external_commands/external_session_commands_test.dart b/test/services/external_commands/external_session_commands_test.dart index 8138725f..faa7adbd 100644 --- a/test/services/external_commands/external_session_commands_test.dart +++ b/test/services/external_commands/external_session_commands_test.dart @@ -77,6 +77,7 @@ void main() { bool starting = false, String currentMode = 'hybrid', String currentSessionId = 'session-a', + String? currentModeLabel, }) => resolveExternalSessionTransition( command: value, @@ -84,9 +85,34 @@ void main() { isSessionStarting: starting, currentMode: currentMode, currentSessionId: currentSessionId, + currentModeLabel: currentModeLabel, now: now, ); + test('a running session is named the way the app names it, not the wire', + () { + final admission = resolve( + command(), + active: true, + currentMode: 'targeted', + currentModeLabel: 'Trace', + ); + + expect( + admission.reason?.compactText, + 'MeshMapper is already running in Trace mode.', + ); + }); + + test('a caller that supplies no label still reads', () { + final admission = resolve(command(), active: true); + + expect( + admission.reason?.compactText, + 'MeshMapper is already running in hybrid mode.', + ); + }); + test('Siri defaults an unqualified Start to Passive Discovery', () { final admission = resolve(command(mode: null)); @@ -254,7 +280,7 @@ void main() { ); expect( stoppingWhileStarting.reason?.compactText, - 'Still starting — try Stop again', + 'Still starting, try Stop again', ); }); From dd1f5ea5a69d7f9101308fc5aeb833530fde74f5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 26 Aug 2026 22:55:23 -0400 Subject: [PATCH 342/360] Sync the intent docs with the code and drop the em dashes --- DEVELOPMENT.md | 60 +++++++++++-------- .../geo/external_surface_geo_builder.dart | 8 +-- lib/services/ping_service.dart | 10 ++-- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e2b872ca..e0dade28 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -760,15 +760,15 @@ separate extension against the bounded App Group snapshot in **There is exactly one `AppShortcutsProvider`, `MeshMapperAppShortcuts`, and it lives in the Runner target.** App Shortcuts are indexed from the app, so a provider inside the App Intents extension is never registered and its phrases -silently do nothing when spoken — no error, just a pause and no result. Apple +silently do nothing when spoken: no error, just a pause and no result. Apple additionally requires that every intent a provider names is a member of the *same* target as the provider, which is why the read intents and their entities (`MeshMapperReadIntents.swift`, `RepeaterEntity.swift`, `HeardRepeaterEntity.swift`) are compiled into Runner **and** the extension. Dual target membership is Apple's documented arrangement for an intent that backs an App Shortcut and must also run in an extension; a shared framework is -explicitly not an option for these. The provider is capped at ten shortcuts — -six are used — and exceeding it is a compile error. +explicitly not an option for these. The provider is capped at ten shortcuts +(five are used), and exceeding it is a compile error. When adding a read intent: add its file to both targets, and add the shortcut to `MeshMapperAppShortcuts`, never to a second provider. Despite its historical `Siri` type names and @@ -806,7 +806,7 @@ one decision rather than two, and it is rechecked at three points: any existing mode is torn down. 3. `PingService` rechecks in `sendTxPing` and `_sendDiscoveryRequest` after **both** of the unbounded waits that precede a transmission, and before any - of the early returns that follow — not just before the BLE call. Nothing + of the early returns that follow, not just before the BLE call. Nothing between that check and the wire awaits, so it is the send instant in wall-clock terms while leaving no `TxPing`/`DiscLogEntry` record and no consumed wire-tag counter behind for a transmission never made. @@ -818,8 +818,8 @@ one decision rather than two, and it is rechecked at three points: check below them lets a GPS fix that crossed the deadline start a session that transmits on the next tick anyway. - **After the write gate, not just after GPS.** `MeshCoreConnection._write` - parks non-sign frames behind an in-progress `CMD_SIGN` — five seconds per - phase, chunk phase looping — and that wait is unbounded by design ("delayed, + parks non-sign frames behind an in-progress `CMD_SIGN` (five seconds per + phase, chunk phase looping), and that wait is unbounded by design ("delayed, never failed"). It sits *inside* the send call, past everything a caller can check. `awaitWritableState()` exists so a deadline-carrying caller takes that wait where abandoning is still free; it is awaited only on that path, @@ -834,15 +834,16 @@ ping is awaited only when a gate is supplied, so the ordinary start path keeps its existing fire-and-forget timing. `PingService.transmitAbortedByDeadline` distinguishes "the caller had already -given up" from the ordinary reasons a send is skipped — cooldown, failed +given up" from the ordinary reasons a send is skipped: cooldown, failed validation, no GPS. It is reset on entry to every gated path, and both the start and the manual-ping paths read it so the refusal says the request arrived too late rather than the generic "couldn't send the ping". The gate reaches only the session's *first* transmission; later pings come from -timers and belong to the session, not to the surface that started it. Checks 1 -and 2 also close `externalCommandCommitMargin` early, because work that has not -begun cannot finish inside a deadline that is nearly gone. Stop stays exempt +timers and belong to the session, not to the surface that started it. Checks 2 +and 3 also close `externalCommandCommitMargin` early, because work that has not +begun cannot finish inside a deadline that is nearly gone; check 1 applies no +margin and refuses only a command that has already expired. Stop stays exempt throughout; stopping is the safe direction, and a safe-direction command must never be abandoned because a voice request timed out. A surface that sends no `expiresAtMs`, such as the watch, still falls back to the shared 30-second @@ -852,16 +853,17 @@ never be abandoned because a voice request timed out. A surface that sends no This is the one mutation where the two differ, and the difference is deliberate. Up to the point of dialling, Connect behaves like the rest: -`resolveLastCompanionConnection` checks `expiresAt` ahead of the age rule and -ahead of every state-based refusal, `_connectToLastCompanion` rechecks after the -10-second readiness wait (admission ran before it, and a cold launch can spend -that whole budget), and it requires `externalCommandCommitMargin` before -dialling. Refusing there is free and avoids pointless transport churn. +`_connectToLastCompanion` checks the deadline as soon as the 10-second readiness +wait ends (a cold launch can spend that whole budget before admission has even +run), `resolveLastCompanionConnection` then checks `expiresAt` ahead of the age +rule and ahead of every state-based refusal, and it requires +`externalCommandCommitMargin` before dialling. Refusing there is free and avoids +pointless transport churn. Once dialling starts there is no way back: `connectToDevice`/`connectViaTcp` have no cancellation seam, and a BLE GATT phase alone may run 15 seconds before protocol setup and authentication. A reconnect begun with ~20 seconds left can -therefore finish after the intent has given up — that is ordinary, not an edge +therefore finish after the intent has given up; that is ordinary, not an edge case, which is why the preflight margin is a sanity check rather than a guarantee. Such a reconnect is deliberately left connected: it transmits nothing on the mesh, starts no session, and is what the person asked for; tearing it @@ -894,17 +896,17 @@ returns, so taking the timestamp afterwards would push a Passive or Hybrid session's own first observation outside its boundary. `toggleAutoPing` therefore reads the clock before the call and passes it to `_startLiveActivitySession`, and the filter is inclusive of that instant. A manual ping that is later upgraded -to an automatic mode deliberately keeps its original boundary — that is one +to an automatic mode deliberately keeps its original boundary: that is one session under one ID, and the manual ping and its RX window are that session's own results. Any surface that says "current" or "this session" must also -check `updatedAt` against `MeshMapperSnapshotFreshness.currentClaimLimit` — the +check `updatedAt` against `MeshMapperSnapshotFreshness.currentClaimLimit`; the snapshot deliberately outlives Runner and can be days old. The snapshot contains at most 64 recent observations and 64 repeaters. That catalogue bound is also the entity-lookup bound: `RepeaterEntityQuery` searches only the cached active/recent entries, never the full loaded repeater set, which is why the intent is named "Find Recent MeshMapper Repeater". Widening the -lookup means widening the catalogue or adding a separate compact index — do not +lookup means widening the catalogue or adding a separate compact index. Do not leave a broad name over a narrow index, and keep that in mind when reviving the withdrawn phrase, since an empty or stale catalogue is one candidate cause of the lookup failure. Recent @@ -929,8 +931,8 @@ of every registered phrase): Repeater lookup by name has no spoken phrase. `FindMeshMapperRepeaterIntent` ships and can be used from the Shortcuts app, but spoken lookup did not work on -device and its `AppShortcut` is withdrawn until it does — see the TODO on the -intent. Adding the phrase back means adding the bullet back here. +device and its `AppShortcut` is withdrawn until it does (see the TODO on the +intent). Adding the phrase back means adding the bullet back here. Mutation intents return their completion message both as Siri dialog and as a text output. A user-created Shortcut can pass that Result to a `Speak Text` @@ -940,10 +942,11 @@ Prefer Spoken Responses when voice feedback is required even in Silent mode. Connect and Start require device authentication and may bring the app forward. Connect reuses the last remembered BLE/TCP companion; USB still requires an -in-app selection. Connect can also outlast Siri's 30-second wait — a slow radio +in-app selection. Connect can also outlast Siri's 30-second wait: a slow radio may finish connecting after Siri has stopped listening, which is why its timeout -says the app may still be connecting rather than that the request was cancelled. Starting does not silently change companions or reconnect — -ask to connect first when MeshMapper is disconnected. +says the app may still be connecting rather than that the request was cancelled. +Starting does not silently change companions or reconnect; ask to connect first +when MeshMapper is disconnected. ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` @@ -1061,6 +1064,8 @@ debugError('[API] Failed to post batch: $error'); |-----|-------------| | `[BLE]` | Bluetooth connection and device communication | | `[CONN]` | MeshCore connection protocol operations | +| `[SIRI]` | Siri App Intents bridge and snapshot publishing | +| `[EXTERNAL]` | External command execution (shared Siri/watch lane) | | `[GPS]` | GPS/geolocation operations | | `[PING]` | Ping sending and validation | | `[API QUEUE]` | API queue operations (batch posting) | @@ -1231,6 +1236,13 @@ All API endpoints may return maintenance mode: - `lib/services/watch/watch_color.dart` - Wire colour projection shared with the phone map - `lib/services/live_activity/live_activity_service.dart` - ActivityKit bridge: preflight urgency, throttle, dedupe, unavailable backoff - `lib/services/live_activity/live_activity_models.dart` - Live Activity snapshot model and urgency keys +- `lib/services/external_surfaces/external_surface_publisher.dart` - Shared publish pipeline (preflight dedupe, throttle, retry) behind watch, Live Activity, and Siri snapshots +- `lib/services/external_surfaces/geo/external_surface_geo_builder.dart` - Ping/repeater/heard geography for external surfaces, with wire caps (was watch_geo_builder) +- `lib/services/external_commands/external_session_commands.dart` - Shared Siri/watch session-command admission and deadline rules +- `lib/services/external_commands/external_command_models.dart` - External command wire model, refusal reasons, and voice copy +- `lib/services/app_intents/app_intent_bridge_service.dart` - Siri method channel: command decode, dedupe, snapshot publish +- `lib/services/app_intents/siri_snapshot_builder.dart` - App Group snapshot content (recent heard, repeater catalogue, counts) +- `lib/services/app_intents/last_companion_connection.dart` - Connect-last-companion admission for the Siri intent - `lib/screens/watch_diagnostics_screen.dart` - Watch transport diagnostics (Settings) - `lib/services/meshcore/packet_validator.dart` - Packet validation and carpeater filtering - `lib/models/noise_floor_session.dart` - Noise floor session data models diff --git a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart index 19cd168b..4ebe98f1 100644 --- a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart +++ b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart @@ -9,7 +9,7 @@ import 'external_surface_geo_models.dart'; /// Pure builders for geography shared by native external surfaces. /// /// Kept free of provider and platform dependencies so the caps, decimation, -/// and colour resolution can be unit-tested directly — those are exactly the +/// 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 ExternalSurfaceGeoBuilder { ExternalSurfaceGeoBuilder._(); @@ -251,9 +251,9 @@ class ExternalSurfaceGeoBuilder { /// Resolve one identifier to a repeater, or null when it is not unambiguous. /// - /// Tries exact identity first — the catalogue's short ID, its hex ID, or the + /// Tries exact identity first (the catalogue's short ID, its hex ID, or the /// hex truncated to the zone's hop width, which is the form an overlay row is - /// labelled with — and only then a unique prefix relation. + /// labelled with) and only then a unique prefix relation. /// /// **A repeater with no `hexId` is never a prefix candidate.** The API omits /// `hex_id` often enough that `Repeater.fromJson` defaults it to the empty @@ -366,7 +366,7 @@ class ExternalSurfaceGeoBuilder { /// 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 + /// 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({ diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 2ab88376..6393d99c 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -522,7 +522,7 @@ class PingService { /// Set when a deadline gate refused a transmission. /// /// Lets a caller tell "the radio never keyed up because I had already given - /// up" apart from the ordinary reasons a send is skipped — cooldown, failed + /// up" apart from the ordinary reasons a send is skipped: cooldown, failed /// validation, no GPS. Only the former abandons a start or earns a spoken /// "took too long"; the rest keep their own reasons. Reset on entry to every /// gated path, so it always describes the send just attempted. @@ -538,7 +538,7 @@ class PingService { bool _deadlinePassed(bool Function()? shouldAbortBeforeTransmit) { if (!(shouldAbortBeforeTransmit?.call() ?? false)) return false; _transmitAbortedByDeadline = true; - debugLog('[PING] Deadline passed before transmit — not sending'); + debugLog('[PING] Deadline passed before transmit, not sending'); return true; } @@ -579,7 +579,7 @@ class PingService { } // The transport parks a non-sign write behind an in-progress sign, and - // that wait is unbounded — it would happen inside the send call below, + // that wait is unbounded. It would happen inside the send call below, // after the TxPing record exists and past everything checkable here. Take // it now, while abandoning still costs nothing. if (shouldAbortBeforeTransmit != null) { @@ -1108,7 +1108,7 @@ class PingService { /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) /// @param shouldAbortBeforeTransmit - Deadline gate for an external caller /// - /// The gate lets an external surface holding a person on a deadline — Siri — + /// The gate lets an external surface holding a person on a deadline (Siri) /// abandon the start if that deadline passes before the session's first /// transmission. A session must never come up after the surface has already /// reported that it did not. @@ -1216,7 +1216,7 @@ class PingService { /// pending-disable handling: nothing was transmitted, so there is no RX /// window to let finish and no cooldown to earn. Future _abandonAutoPingStart() async { - debugLog('[AUTO] Abandoning start — caller gave up before first transmit'); + debugLog('[AUTO] Abandoning start: caller gave up before first transmit'); _autoTimer?.cancel(); _autoTimer = null; _skipReason = null; From 40c7a1ee12289dbc93adf12f974917c8fcc9c531 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 26 Aug 2026 23:14:50 -0400 Subject: [PATCH 343/360] Speak app mode names and singular counts in the status reply --- .../MeshMapperReadIntents.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift index 235fcdea..b4d5e70f 100644 --- a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -20,7 +20,10 @@ enum MeshMapperSnapshotFreshness { } /// This is spoken aloud, so "1 seconds ago" is heard rather than skimmed. - private static func counted(_ count: Int, _ unit: String) -> String { + /// + /// Shared with the status intent's ping and repeater counts, which are read + /// out in the same sentence and have the same problem. + static func counted(_ count: Int, _ unit: String) -> String { "\(count) \(unit)\(count == 1 ? "" : "s")" } } @@ -120,13 +123,29 @@ struct GetMeshMapperStatusIntent: AppIntent { let zone = snapshot.session.zoneCode.map { " in \($0)" } ?? "" let detail = snapshot.session.phaseDetail.map { " \($0)." } ?? "" + let pings = MeshMapperSnapshotFreshness.counted( + snapshot.session.txCount, "ping" + ) + let heard = MeshMapperSnapshotFreshness.counted( + snapshot.session.uniqueRepeatersHeard, "unique repeater" + ) return .result( - dialog: "\(prefix)MeshMapper was running in \(Self.modeName(snapshot.session.mode)) mode\(zone). It had sent \(snapshot.session.txCount) pings and heard \(snapshot.session.uniqueRepeatersHeard) unique repeaters. \(snapshot.session.phaseTitle).\(detail)" + dialog: "\(prefix)MeshMapper was running in \(Self.modeName(snapshot.session.mode)) mode\(zone). It had sent \(pings) and heard \(heard). \(snapshot.session.phaseTitle).\(detail)" ) } + /// The wire mode names are not speakable. `targeted` is only ever called + /// Trace on screen, and `passive` is plain Passive in the app's own copy, so + /// capitalizing the raw value would say words no MeshMapper screen uses. + /// Kept in step with `AutoMode.displayName`. private static func modeName(_ mode: String) -> String { - mode == "passive" ? "Passive Discovery" : mode.capitalized + switch mode { + case "active": return "Active" + case "passive": return "Passive" + case "hybrid": return "Hybrid" + case "targeted": return "Trace" + default: return mode.capitalized + } } } From aff69d469ac4e0c4deccd274f5343023350cc769 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 26 Aug 2026 23:14:50 -0400 Subject: [PATCH 344/360] Keep Siri's repeater identity stable and its session count honest --- lib/providers/app_state_provider.dart | 20 +- .../app_intents/siri_snapshot_builder.dart | 116 +++++++++-- .../external_session_commands.dart | 10 +- .../siri_snapshot_builder_test.dart | 180 ++++++++++++------ .../external_session_commands_test.dart | 14 +- 5 files changed, 239 insertions(+), 101 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ff26ff63..e7525018 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1826,12 +1826,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); String get _resolvedWatchSessionModeTitle => - switch (_resolvedWatchSessionMode) { - AutoMode.active => 'Active', - AutoMode.passive => 'Passive', - AutoMode.hybrid => 'Hybrid', - AutoMode.targeted => 'Trace', - }; + _resolvedWatchSessionMode.displayName; WatchControls _buildWatchControls() { final cooldownMs = _manualPingCooldownTimer.remainingMs; @@ -1895,7 +1890,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { discoveryEntries: _discLogEntries, traceEntries: _traceLogEntries, repeaters: _repeaters, - zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, hopBytes: _hopBytes, ); final phase = _resolveWatchPhase(); @@ -1957,10 +1951,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { : null, ), recentHeard: recentHeard.observations, - repeaters: SiriSnapshotBuilder.buildRepeaterCatalog( - _repeaters, - zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, - ), + repeaters: SiriSnapshotBuilder.buildRepeaterCatalog(_repeaters), ).toMap(); } @@ -2850,12 +2841,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String get _liveActivityModeTitle { if (_liveActivityManualSession) return 'Manual'; - return switch (_autoMode) { - AutoMode.active => 'Active', - AutoMode.passive => 'Passive', - AutoMode.hybrid => 'Hybrid', - AutoMode.targeted => 'Trace', - }; + return _autoMode.displayName; } String get _liveActivityGpsLabel => switch (_gpsStatus) { diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart index 072f2afc..687345d4 100644 --- a/lib/services/app_intents/siri_snapshot_builder.dart +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -17,8 +17,14 @@ class SiriSnapshotBuilder { /// both sides must derive it the same way. [Repeater.iata] is nullable, and a /// repeater without one would otherwise be filed under two different keys and /// lose its name, zone and coordinates on the heard side. - static String repeaterEntityId(Repeater repeater, String? zoneCode) => - '${repeater.iata ?? zoneCode ?? 'GLOBAL'}|${repeater.id}'; + /// + /// **This string must never depend on session state.** Shortcuts persists a + /// chosen RepeaterEntity by its identifier and resolves it back by exact + /// match, so an id that folded in the live session's zone would flip between + /// connects and zones (`SEA|x` one drive, `GLOBAL|x` the next) and silently + /// break every saved shortcut that named that repeater. + static String repeaterEntityId(Repeater repeater) => + '${repeater.iata ?? 'GLOBAL'}|${repeater.id}'; static SiriRecentHeard buildRecentHeard({ required List txEntries, @@ -26,7 +32,6 @@ class SiriSnapshotBuilder { required List discoveryEntries, required List traceEntries, required List repeaters, - required String? zoneCode, int? hopBytes, DateTime? now, }) { @@ -66,7 +71,7 @@ class SiriSnapshotBuilder { candidate.latitude.isFinite && candidate.longitude.isFinite; return SiriRepeaterObservation( - entityId: resolved ? repeaterEntityId(repeater, zoneCode) : null, + entityId: resolved ? repeaterEntityId(repeater) : null, displayHexId: normalizedId, name: resolved && repeater.name != 'Unknown' ? repeater.name : null, observedAt: candidate.observedAt, @@ -195,16 +200,84 @@ class SiriSnapshotBuilder { } } + // The count only needs an identity and a timestamp, so the distinct roll-up + // stays lightweight: no names, no coordinates and above all no haversine + // per row, none of which any caller of it reads. Resolution comes from the + // same memo the wire list uses, so nothing is looked up twice. + final resolvedHeard = {}; + final unresolvedHeard = {}; + for (final entry in newestByIdentity.entries) { + final repeater = lookup(entry.value); + if (repeater != null) { + resolvedHeard[entry.key] = repeater; + } else { + unresolvedHeard[entry.key] = entry.value; + } + } + + // A repeater met by discovery carries a full public key and resolves; the + // same repeater met by TX echo or passive RX carries only a short path + // hash, which is ambiguous against the whole catalogue and so resolves to + // nothing. Counted raw that is one repeater twice. Re-run the same prefix + // relation against only the repeaters actually heard in this set: that + // narrower field is usually unambiguous, and where it is not the row stays + // on its own rather than being attached to a guess. + final heardRepeaters = {}; + for (final repeater in resolvedHeard.values) { + heardRepeaters[repeater.id] = repeater; + } + final heardList = heardRepeaters.values.toList(growable: false); + + final distinct = [ + for (final entry in resolvedHeard.entries) + SiriHeardIdentity( + key: repeaterEntityId(entry.value), + observedAt: newestByIdentity[entry.key]!.observedAt, + ), + for (final entry in unresolvedHeard.entries) + SiriHeardIdentity( + key: _collapsedKey( + candidate: entry.value, + heardRepeaters: heardList, + hopBytes: hopBytes, + ) ?? + 'unresolved:${entry.value.displayId.toUpperCase()}', + observedAt: entry.value.observedAt, + ), + ]; + candidates.sort((a, b) => b.observedAt.compareTo(a.observedAt)); return SiriRecentHeard( observations: candidates.take(maximumObservations).map(resolve).toList( growable: false, ), - distinctHeard: - newestByIdentity.values.map(resolve).toList(growable: false), + distinctHeard: List.unmodifiable(distinct), ); } + /// The entity id of the already-heard repeater this row unambiguously + /// belongs to, or null when nothing in the heard set claims it outright. + static String? _collapsedKey({ + required _SiriObservationCandidate candidate, + required List heardRepeaters, + required int? hopBytes, + }) { + if (heardRepeaters.isEmpty) return null; + final match = (candidate.identity == null + ? null + : ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: heardRepeaters, + id: candidate.identity!, + hopBytes: hopBytes, + )) ?? + ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: heardRepeaters, + id: candidate.displayId.toUpperCase(), + hopBytes: hopBytes, + ); + return match == null ? null : repeaterEntityId(match); + } + /// Counts distinct repeaters heard since [sessionStartedAt]. /// /// Feed this [SiriRecentHeard.distinctHeard], not the snapshot's @@ -217,13 +290,13 @@ class SiriSnapshotBuilder { /// a session that has heard nothing report the previous one's repeaters. /// A null start means no session is running, which counts as none. static int countUniqueRepeatersHeard( - List observations, + List heard, DateTime? sessionStartedAt, ) { if (sessionStartedAt == null) return 0; - return observations + return heard .where((item) => !item.observedAt.isBefore(sessionStartedAt)) - .map((item) => item.entityId ?? 'unresolved:${item.displayHexId}') + .map((item) => item.key) .toSet() .length; } @@ -233,9 +306,8 @@ class SiriSnapshotBuilder { '${candidate.displayId.toUpperCase()}'; static List buildRepeaterCatalog( - List repeaters, { - String? zoneCode, - }) { + List repeaters, + ) { final ranked = List.of(repeaters) ..sort((a, b) { final active = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); @@ -249,7 +321,7 @@ class SiriSnapshotBuilder { repeater.lat.isFinite && repeater.lon.isFinite; return SiriRepeaterEntitySnapshot( - id: repeaterEntityId(repeater, zoneCode), + id: repeaterEntityId(repeater), name: repeater.name, hexId: repeater.hexId.toUpperCase(), zoneCode: repeater.iata, @@ -283,7 +355,23 @@ class SiriRecentHeard { final List observations; /// One entry per distinct repeater, carrying its most recent sighting. - final List distinctHeard; + final List distinctHeard; +} + +/// One distinct repeater heard, reduced to what the session count reads. +/// +/// Deliberately not a [SiriRepeaterObservation]: this list is unbounded, it is +/// rebuilt on every snapshot publish, and nothing downstream reads a name, a +/// signal figure or a distance from it. Resolving those (a haversine per row) +/// for a number that only needs distinct identities was pure waste. +class SiriHeardIdentity { + const SiriHeardIdentity({required this.key, required this.observedAt}); + + /// The repeater's entity id, or `unresolved:` when the catalogue and + /// the rest of the heard set both fail to claim the hash. + final String key; + + final DateTime observedAt; } class _SiriObservationCandidate { diff --git a/lib/services/external_commands/external_session_commands.dart b/lib/services/external_commands/external_session_commands.dart index e808b855..d77ea110 100644 --- a/lib/services/external_commands/external_session_commands.dart +++ b/lib/services/external_commands/external_session_commands.dart @@ -179,15 +179,16 @@ String _externalCommandVoiceMessage({ /// [currentMode] is the wire name of the running mode and is what the stop path /// resolves back into an [ExternalSessionMode]. [currentModeLabel] is the name -/// a person hears, which the app spells differently (Trace, not targeted); it -/// falls back to the wire name so a caller with nothing better still reads. +/// a person hears, which the app spells differently (Trace, not targeted). It +/// is required: the wire name is not speakable, so falling back to it would +/// have put the enum spelling in a spoken sentence. ExternalCommandAdmission resolveExternalSessionTransition({ required ExternalSessionCommand command, required bool isSessionActive, required bool isSessionStarting, required String currentMode, required String currentSessionId, - String? currentModeLabel, + required String currentModeLabel, DateTime? now, }) { final ageRefusal = externalCommandAgeRefusal(command, now: now); @@ -204,8 +205,7 @@ ExternalCommandAdmission resolveExternalSessionTransition({ return ExternalCommandAdmission( disposition: ExternalCommandDisposition.noOp, reason: ExternalCommandReason.other( - 'MeshMapper is already running in ' - '${currentModeLabel ?? currentMode} mode.', + 'MeshMapper is already running in $currentModeLabel mode.', ), ); } diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart index 9a28751d..cbf56680 100644 --- a/test/services/app_intents/siri_snapshot_builder_test.dart +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -31,7 +31,6 @@ void main() { discoveryEntries: discovery, traceEntries: trace, repeaters: repeaters, - zoneCode: 'SEA', hopBytes: 2, now: now, ); @@ -342,15 +341,12 @@ void main() { final heard = buildHeard(rx: entries, repeaters: repeaters); - expect(heard.observations, hasLength(64)); expect( - SiriSnapshotBuilder.countUniqueRepeatersHeard( - heard.observations, - now.subtract(const Duration(minutes: 5)), - ), - 64, - reason: 'the capped wire list is exactly what used to be counted', + heard.observations, + hasLength(64), + reason: 'the capped wire list is what used to be counted, and it lies', ); + expect(heard.distinctHeard, hasLength(80)); expect( SiriSnapshotBuilder.countUniqueRepeatersHeard( heard.distinctHeard, @@ -407,16 +403,13 @@ void main() { ], repeaters: const [zoneless], ); - final catalog = SiriSnapshotBuilder.buildRepeaterCatalog( - const [zoneless], - zoneCode: 'SEA', - ); + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog(const [zoneless]); - expect(heard.observations.single.entityId, 'SEA|database-999'); + expect(heard.observations.single.entityId, 'GLOBAL|database-999'); expect(catalog.single.id, heard.observations.single.entityId); }); - test('a repeater with no IATA and no zone falls back to GLOBAL', () { + test('a repeater with no IATA is keyed GLOBAL, never the live zone', () { const zoneless = Repeater( id: 'database-999', hexId: '77889900', @@ -427,8 +420,10 @@ void main() { enabled: 1, ); + // Shortcuts resolves a saved RepeaterEntity by exact id, so this string + // must not move when the session's zone does. expect( - SiriSnapshotBuilder.repeaterEntityId(zoneless, null), + SiriSnapshotBuilder.repeaterEntityId(zoneless), 'GLOBAL|database-999', ); expect( @@ -437,16 +432,118 @@ void main() { ); }); + test('one repeater met by discovery and by short-hash echo counts once', () { + // Discovery carries the full public key and resolves; the TX echo carries + // only the 2-byte path hash, which is ambiguous against the catalogue and + // resolves to nothing. Counted raw that is the same radio twice. + final at = now.subtract(const Duration(minutes: 1)); + final heard = buildHeard( + discovery: [ + DiscLogEntry( + timestamp: at, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 6, + localRssi: -80, + remoteSnr: 4, + pubkeyHex: + '4E5D82AA00000000000000000000000000000000000000000000000000000000', + ), + ], + ), + ], + tx: [ + TxLogEntry( + timestamp: at.add(const Duration(seconds: 10)), + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 5.0, rssi: -82)], + ), + ], + ); + + expect(heard.distinctHeard, hasLength(2)); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 1, + ); + }); + + test('an ambiguous hash stays its own entry rather than being guessed at', + () { + const twin = Repeater( + id: 'database-456', + hexId: '4E5DFFFF', + name: 'Twin', + lat: 47.63, + lon: -122.33, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final at = now.subtract(const Duration(minutes: 1)); + final heard = buildHeard( + repeaters: const [capitolHill, twin], + discovery: [ + DiscLogEntry( + timestamp: at, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 6, + localRssi: -80, + remoteSnr: 4, + pubkeyHex: + '4E5D82AA00000000000000000000000000000000000000000000000000000000', + ), + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 4, + localRssi: -90, + remoteSnr: 2, + pubkeyHex: + '4E5DFFFF00000000000000000000000000000000000000000000000000000000', + ), + ], + ), + ], + tx: [ + TxLogEntry( + timestamp: at.add(const Duration(seconds: 10)), + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 5.0, rssi: -82)], + ), + ], + ); + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 3, + reason: 'both twins plus the hash that could be either', + ); + }); + group('unique repeaters heard is scoped to the running session', () { - SiriRepeaterObservation heard(String hexId, Duration ago) => - SiriRepeaterObservation( - entityId: 'SEA|$hexId', - displayHexId: hexId, + SiriHeardIdentity heard(String hexId, Duration ago) => SiriHeardIdentity( + key: 'SEA|$hexId', observedAt: now.subtract(ago), - kind: SiriObservationKind.passiveRx, - direct: true, - hopCount: 1, - resolved: true, ); test('a previous session\'s repeaters are not credited to this one', () { @@ -472,15 +569,7 @@ void main() { // reports nothing for its opening event. final startedAt = now.subtract(const Duration(minutes: 5)); final observations = [ - SiriRepeaterObservation( - entityId: 'SEA|first', - displayHexId: 'AAAA1111', - observedAt: startedAt, - kind: SiriObservationKind.discovery, - direct: true, - hopCount: 1, - resolved: true, - ), + SiriHeardIdentity(key: 'SEA|first', observedAt: startedAt), ]; expect( @@ -492,14 +581,9 @@ void main() { test('an observation one millisecond earlier is a previous session', () { final startedAt = now.subtract(const Duration(minutes: 5)); final observations = [ - SiriRepeaterObservation( - entityId: 'SEA|earlier', - displayHexId: 'BBBB2222', + SiriHeardIdentity( + key: 'SEA|earlier', observedAt: startedAt.subtract(const Duration(milliseconds: 1)), - kind: SiriObservationKind.discovery, - direct: true, - hopCount: 1, - resolved: true, ), ]; @@ -549,22 +633,8 @@ void main() { test('unresolved observations count by hex, not collapsed together', () { final observations = [ - SiriRepeaterObservation( - displayHexId: 'DDDD4444', - observedAt: now, - kind: SiriObservationKind.passiveRx, - direct: true, - hopCount: 1, - resolved: false, - ), - SiriRepeaterObservation( - displayHexId: 'EEEE5555', - observedAt: now, - kind: SiriObservationKind.passiveRx, - direct: true, - hopCount: 1, - resolved: false, - ), + SiriHeardIdentity(key: 'unresolved:DDDD4444', observedAt: now), + SiriHeardIdentity(key: 'unresolved:EEEE5555', observedAt: now), ]; expect( diff --git a/test/services/external_commands/external_session_commands_test.dart b/test/services/external_commands/external_session_commands_test.dart index faa7adbd..24a074ce 100644 --- a/test/services/external_commands/external_session_commands_test.dart +++ b/test/services/external_commands/external_session_commands_test.dart @@ -77,7 +77,7 @@ void main() { bool starting = false, String currentMode = 'hybrid', String currentSessionId = 'session-a', - String? currentModeLabel, + String currentModeLabel = 'Hybrid', }) => resolveExternalSessionTransition( command: value, @@ -104,15 +104,6 @@ void main() { ); }); - test('a caller that supplies no label still reads', () { - final admission = resolve(command(), active: true); - - expect( - admission.reason?.compactText, - 'MeshMapper is already running in hybrid mode.', - ); - }); - test('Siri defaults an unqualified Start to Passive Discovery', () { final admission = resolve(command(mode: null)); @@ -360,6 +351,7 @@ void main() { isSessionStarting: false, currentMode: 'passive', currentSessionId: 'session-a', + currentModeLabel: 'Passive', now: now, ); @@ -378,6 +370,7 @@ void main() { isSessionStarting: false, currentMode: 'passive', currentSessionId: 'session-a', + currentModeLabel: 'Passive', now: now, ); @@ -391,6 +384,7 @@ void main() { isSessionStarting: false, currentMode: 'passive', currentSessionId: 'session-a', + currentModeLabel: 'Passive', now: now, ); From 252eefdb4ab9fe0cc83ed607fccc9e0b8750366a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 27 Aug 2026 04:34:19 -0400 Subject: [PATCH 345/360] Format the files CI keeps flagging --- lib/screens/main_scaffold.dart | 3 +- lib/screens/settings_screen.dart | 4 +-- lib/services/api_service.dart | 8 ++++- lib/services/ping_service.dart | 3 +- .../api_queue_offline_retry_test.dart | 6 ++-- .../services/portal_account_service_test.dart | 30 ++++++++++--------- 6 files changed, 32 insertions(+), 22 deletions(-) diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 47127b02..124c63be 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -269,7 +269,8 @@ class _MainScaffoldState extends State { final message = switch (code) { 'denied' || 'access_denied' => 'Sign-in cancelled', 'expired' => 'That sign-in expired — please try again', - 'timeout' || 'network' => + 'timeout' || + 'network' => 'Could not reach MyMeshMapper — check your connection', 'code_invalid' => 'That sign-in link was already used — please try again', _ => 'Sign-in did not finish — please try again', diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d6439437..1b89a37b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2827,8 +2827,8 @@ class _SettingsScreenState extends State { // inside the block only re-arms a fresh penalty. final wait = outcome.retryAfter; if (wait != null) { - AppToast.warning(context, - 'Too many attempts. Try again in ${_waitLabel(wait)}.'); + AppToast.warning( + context, 'Too many attempts. Try again in ${_waitLabel(wait)}.'); } else { AppToast.error(context, 'Could not link right now — try again later'); } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 6c5dd5e9..5b32b876 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -14,7 +14,13 @@ import '../utils/debug_logger_io.dart'; /// we simply never got an answer. The queue must not spend a retry on it, or a /// drive through a dead zone burns the whole ladder and strands the pings /// (#437). -enum UploadResult { success, retryable, unreachable, sessionError, nonRetryable } +enum UploadResult { + success, + retryable, + unreachable, + sessionError, + nonRetryable +} /// The request never reached the server: no coverage, DNS failure, connection /// reset, TLS handshake, or a timeout waiting for the first byte. diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 6393d99c..4a875e00 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1344,8 +1344,7 @@ class PingService { _pendingDisableTimeout?.cancel(); _pendingDisableTimeout = Timer(_pendingDisableTimeoutDelay, () async { if (!_pendingDisable) return; // the window drained it, nothing to do - debugWarn( - '[PING] Pending disable never drained after ' + debugWarn('[PING] Pending disable never drained after ' '${_pendingDisableTimeoutDelay.inSeconds}s (no RX window arrived) - forcing it'); // The ping this was waiting on is gone. Leaving this set would keep the // ping controls locked out until a restart. diff --git a/test/services/api_queue_offline_retry_test.dart b/test/services/api_queue_offline_retry_test.dart index 660aa018..8c134355 100644 --- a/test/services/api_queue_offline_retry_test.dart +++ b/test/services/api_queue_offline_retry_test.dart @@ -72,7 +72,8 @@ void main() { await queue.flushQueue(); expect( - harness.wardrivePosts, hasLength(2), + harness.wardrivePosts, + hasLength(2), reason: 'both flushes should have tried the network. If the second was ' 'skipped, the first failure started the backoff ladder, which means ' 'a dead socket is being counted as a server rejection', @@ -95,7 +96,8 @@ void main() { expect(queue.queueSize, 1, reason: 'the sample is still held in the queue'); expect( - queue.failedItems, isEmpty, + queue.failedItems, + isEmpty, reason: 'nothing in the app ever reads failedItems or resets retryCount, ' 'so an item that lands here is coverage the user will never get', ); diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart index 0f020137..8bbaddf4 100644 --- a/test/services/portal_account_service_test.dart +++ b/test/services/portal_account_service_test.dart @@ -182,8 +182,8 @@ void main() { // The exchange answers with the identity but no pubkeys, so the account // card would read "0 device(s)" until the next radio connect ran `me`. await seedPending('st4te'); - final service = buildService(recordingClient((request) => - request.url.queryParameters['action'] == 'me' + final service = buildService(recordingClient( + (request) => request.url.queryParameters['action'] == 'me' ? http.Response( jsonEncode({ 'ok': true, @@ -945,8 +945,8 @@ void main() { test('a 429 on me blocks the next refresh even when it is forced', () async { - final service = - await signedIn(recordingClient((_) => rateLimited(retryAfter: '600'))); + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '600'))); expect(await service.refreshMe(), isFalse); expect(requests.length, 1); @@ -961,8 +961,8 @@ void main() { }); test('a 429 is not a sign-out', () async { - final service = - await signedIn(recordingClient((_) => rateLimited(retryAfter: '600'))); + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '600'))); await service.refreshMe(); @@ -975,7 +975,8 @@ void main() { await service.refreshMe(); - expectBackoff(service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); + expectBackoff( + service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); }); test('an unparseable Retry-After falls back to the default', () async { @@ -984,7 +985,8 @@ void main() { await service.refreshMe(); - expectBackoff(service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); + expectBackoff( + service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); }); test('an absurd Retry-After is clamped', () async { @@ -997,8 +999,8 @@ void main() { }); test('logout does not retry a 429', () async { - final service = - await signedIn(recordingClient((_) => rateLimited(retryAfter: '300'))); + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '300'))); await service.logout(); @@ -1007,8 +1009,8 @@ void main() { }); test('a 429 on nonce backs the whole link lane off', () async { - final service = - await signedIn(recordingClient((_) => rateLimited(retryAfter: '120'))); + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '120'))); expect(await service.requestNonce('A' * 64), isNull); @@ -1016,8 +1018,8 @@ void main() { }); test('a blocked route is not knocked on a second time', () async { - final service = - await signedIn(recordingClient((_) => rateLimited(retryAfter: '120'))); + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '120'))); expect(await service.requestNonce('A' * 64), isNull); expect(callsTo('nonce'), 1); From bf483f388b91656b97fe500a3689d46273a9011a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 27 Aug 2026 04:36:49 -0400 Subject: [PATCH 346/360] Let the formatter own the provider file too --- lib/providers/app_state_provider.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e7525018..d95d3569 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -7883,9 +7883,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // surveyor pacing one spot trips it), so it warns rather than alarms. debugError('[API] Session error: $reason - $userMessage'); logError(userMessage, - severity: isStationaryRevoke - ? ErrorSeverity.warning - : ErrorSeverity.error); + severity: + isStationaryRevoke ? ErrorSeverity.warning : ErrorSeverity.error); // Session errors that require disconnect const sessionErrors = { From 4c9be3e3ff26dd6fcf0a279fd971747ee826183c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 28 Aug 2026 22:34:04 -0400 Subject: [PATCH 347/360] Rebuild the CarPlay card with names, noise floor and battery --- .../MeshMapperLiveActivity.swift | 497 +++++++++++------- ios/Runner/LiveActivityManager.swift | 10 + ios/Shared/MeshMapperActivityAttributes.swift | 8 + lib/models/user_preferences.dart | 12 + lib/providers/app_state_provider.dart | 29 + lib/screens/settings_screen.dart | 15 + .../live_activity/live_activity_models.dart | 24 + .../live_activity_models_test.dart | 1 + 8 files changed, 397 insertions(+), 199 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 7210eb7b..3832428c 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -137,120 +137,131 @@ private struct MeshMapperLockScreenContent: View { } } -/// The watch Smart Stack card. +/// The small activity family card: the watch Smart Stack and, on iOS 26, +/// the CarPlay dashboard. /// -/// **It is a widget-sized card, not a lock-screen banner**: 152x69.5 pt on a -/// 40 mm watch through 191x81.5 pt on a 49 mm one, per the HIG's watchOS -/// widget dimensions. +/// **The layout is a band over rows.** A slate band carries the session's +/// status facts (phase on the left, mode and zone in the middle, noise floor +/// and battery pinned to the far right, each beside its app icon) above a +/// hairline divider, and the body below is up to three named repeater rows. /// -/// **One header line, then the observations.** The phase names itself on the -/// left; mode, latest outcome and zone sit at the right of that same line, -/// where they cost no line of their own. Everything under it is the map page's -/// Top Heard block — `[type dot] [hex ID] [SNR]` in two columns, column-major, -/// four observations deep. +/// **Width decides how much the band spells out.** The watch card is +/// 152–191 pt wide per the HIG's watchOS widget dimensions; CarPlay's is +/// wider. Past 200 pt the band adds the mode word, the battery percentage and +/// a live countdown, and the body seats a third row; under it the icons carry +/// the middle, the battery keeps only its glyph (until it runs low), and two +/// rows fit. /// -/// **What it deliberately no longer carries.** The countdown is gone: a -/// `Text(timerInterval:)` is redrawn by the system every second it is visible, -/// and the display is where this app's marginal energy goes on the wrist — a -/// measured 80 % of it. The phase title already says what is happening, and -/// the exact second it ends is not a wrist-glance fact. The heard-summary line -/// went with it, and the session counters that shared it. `repeatersAreCurrent` -/// still reaches the wearer as the grid's opacity, which is where that fact was -/// already carried on cards too narrow to spell it out. +/// **The energy rules are inherited, not relaxed.** Nothing here holds state +/// or animates, and the ticking countdown is width-gated to the wide layout: +/// a `Text(timerInterval:)` is redrawn by the system every second it is +/// visible, and on the wrist that redraw was measured at 80 % of this app's +/// display energy. `repeatersAreCurrent` still reaches the reader as the +/// rows' opacity. /// -/// **Both dimensions are measured rather than assumed.** This view is rendered -/// by the iOS extension and mirrored to the wrist, so `WKInterfaceDevice` -/// screen bounds — which size the equivalent panel in `MapPage` — are not -/// reachable from here. The `GeometryReader` hands the row solver the card's -/// real width *and* the height left under the header, so the type is as large -/// as the smaller of the two allows. Height alone cannot enlarge it: at these -/// widths the rows are width-bound, which is why freeing two lines buys -/// headroom for the cap rather than bigger type on its own. +/// **The name is the only elastic element in a row.** The hex ID stays the +/// observation's identity (a 1-byte hash is frequently ambiguous) and the SNR +/// keeps its width and alignment, so a long resolved name truncates between +/// them rather than disturbing either. @available(iOSApplicationExtension 18.0, *) private struct MeshMapperSmallActivityContent: View { let state: MeshMapperActivityAttributes.ContentState let isStale: Bool - /// Matches the map panel's own insets. The card has rounded corners the - /// system draws, so the horizontal value is clearance, not decoration. - private static let horizontalPadding: CGFloat = 8 - private static let verticalPadding: CGFloat = 5 - private static let headerSpacing: CGFloat = 4 - /// The 13 pt semibold title's line height, pinned rather than measured so - /// the height the row solver is given and the height the header actually - /// takes cannot drift apart. - private static let headerHeight: CGFloat = 16 + /// Watch cards top out at 191 pt; anything wider is a surface (CarPlay) + /// that can afford the spelled-out band and the third row. + private static let wideThreshold: CGFloat = 200 + + /// Width past which the band can afford the spelled-out mode word on top + /// of everything else it carries. + private static let modeWordThreshold: CGFloat = 280 var body: some View { GeometryReader { geo in - let layout = MeshMapperHeardLayout( - contentWidth: geo.size.width - 2 * Self.horizontalPadding, - contentHeight: geo.size.height - 2 * Self.verticalPadding - - Self.headerHeight - Self.headerSpacing, - repeaters: state.repeaters - ) - VStack(alignment: .leading, spacing: Self.headerSpacing) { - headerLine - .frame(height: Self.headerHeight) - MeshMapperHeardGrid(state: state, layout: layout) + let isWide = geo.size.width >= Self.wideThreshold + let showsModeWord = geo.size.width >= Self.modeWordThreshold + VStack(alignment: .leading, spacing: 0) { + headerBand(isWide: isWide, showsModeWord: showsModeWord) + heardRows(isWide: isWide, width: geo.size.width) // Rows from a finished cycle are still worth showing, but they are - // not a claim about now. With the summary line gone this opacity is - // the only thing left saying so, which it already was on the cards - // that never had room for the words. + // not a claim about now; the dimming is that statement. .opacity(state.repeatersAreCurrent ? 1 : 0.55) + .padding(.horizontal, isWide ? 12 : 7) + .padding(.vertical, isWide ? 6 : 4) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .padding(.horizontal, Self.horizontalPadding) - .padding(.vertical, Self.verticalPadding) } .foregroundStyle(.white) } - /// Phase on the left, session identity on the right. - /// - /// Staleness replaces the phase title rather than sitting beside it, as it - /// does in the map panel: a phase that nobody is refreshing should not keep - /// announcing itself, and the swap costs no width the right side needs. - private var headerLine: some View { - HStack(spacing: 4) { + // MARK: Header band + + private func headerBand(isWide: Bool, showsModeWord: Bool) -> some View { + HStack(spacing: isWide ? 5 : 2) { if isStale { Label("Update delayed", systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: isWide ? 12 : 11, weight: .semibold)) .foregroundStyle(.orange) .lineLimit(1) .minimumScaleFactor(0.85) } else { - Text(state.phaseTitle) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1)) + Text(state.bandPhaseTitle) + .font(.system(size: isWide ? 13 : 11, weight: .semibold)) + .foregroundStyle( + state.phaseColor.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1) + ) .lineLimit(1) - // Shrink before truncating. Sharing the line with the mode leaves - // "Listening…" about four points short on a 40 mm card, and a title - // clipped to "Liste…" reads as a different word; a title one point - // smaller reads as the same one. - .minimumScaleFactor(0.8) + // Shrink before truncating: a title clipped to "Liste…" reads as a + // different word; a title one point smaller reads as the same one. + .minimumScaleFactor(0.7) .truncationMode(.tail) + .layoutPriority(1) + // The system draws and ticks this itself; no ActivityKit updates. + // Wide cards only: on the wrist a ticking timer was measured at 80 % + // of this app's display energy, so the watch keeps the title alone. + if isWide, state.activeCountdownRange != nil { + MeshMapperCountdown( + state: state, + font: .system(size: 12, weight: .semibold).monospacedDigit() + ) + .foregroundStyle(.secondary) + .fixedSize() + } } Spacer(minLength: 4) - trailingIdentity + middleIdentity(isWide: isWide, showsModeWord: showsModeWord) + Spacer(minLength: 4) + trailingHealth(isWide: isWide, showsModeWord: showsModeWord) + } + .padding(.horizontal, isWide ? 12 : 7) + .padding(.vertical, isWide ? 6 : 4) + .background(MeshMapperPalette.surface) + .overlay(alignment: .bottom) { + Rectangle() + .fill(MeshMapperPalette.hairline) + .frame(height: 0.5) } } - /// Mode, latest outcome and zone — the facts that used to own the bottom - /// line. They are `fixedSize` so the phase title, which can be truncated - /// without becoming wrong, is what gives way on a narrow card. - private var trailingIdentity: some View { - HStack(spacing: 4) { - Text(state.mode.uppercased()) - .font(.system(size: 10, weight: .bold)) - .tracking(0.3) - MeshMapperOutcomeDot(state: state, diameter: 6) + /// Mode and zone, each beside the icon the app itself uses for it. Narrow + /// cards drop the mode word and let the icon carry it. + private func middleIdentity(isWide: Bool, showsModeWord: Bool) -> some View { + HStack(spacing: isWide ? 4 : 3) { + Image(systemName: state.modeSymbol) + .font(.system(size: isWide ? 9 : 6, weight: .bold)) + if showsModeWord { + Text(state.mode.uppercased()) + .font(.system(size: 10, weight: .bold)) + .tracking(0.3) + } if let zone = state.zoneCode { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: isWide ? 9 : 6, weight: .bold)) Text(zone) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .font(.system(size: isWide ? 10 : 7, weight: .semibold, design: .monospaced)) } else if !state.isConnected { Text("Disconnected") - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: isWide ? 10 : 8, weight: .semibold)) .foregroundStyle(.orange) } } @@ -258,159 +269,179 @@ private struct MeshMapperSmallActivityContent: View { .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } -} -/// The map panel's Top Heard block, rendered from Live Activity state. -/// -/// Rows are `[type dot] [hex ID] [SNR]` and read down the left column before -/// the right, matching `MapPage.statusPanel`. The hex path hash stays primary -/// and unabbreviated: a 1-byte hash is frequently ambiguous, so it — not a -/// resolved name — is the observation's identity, and names are dropped here -/// rather than truncated. -@available(iOSApplicationExtension 18.0, *) -private struct MeshMapperHeardGrid: View { - let state: MeshMapperActivityAttributes.ContentState - let layout: MeshMapperHeardLayout + /// Noise floor and companion battery, pinned to the band's far right. Both + /// are optional payload fields, so a radio that has not answered a stats + /// poll simply leaves the slot empty. + private func trailingHealth(isWide: Bool, showsModeWord: Bool) -> some View { + HStack(spacing: isWide ? 4 : 3) { + if let noiseFloor = state.noiseFloorDbm { + HStack(spacing: 2) { + Image(systemName: "waveform") + .font(.system(size: isWide ? 9 : 6, weight: .bold)) + Text("\(noiseFloor)") + .font(.system(size: isWide ? 10 : 7, weight: .bold, design: .monospaced)) + } + .foregroundStyle(Self.noiseFloorColor(noiseFloor)) + } + if let battery = state.companionBatteryPct { + HStack(spacing: 2) { + Image(systemName: Self.batterySymbol(battery)) + .font(.system(size: isWide ? 10 : 7, weight: .semibold)) + // The number earns its width only on the widest tier; below it + // the glyph is enough until the battery is actually in trouble. + if showsModeWord || battery < 20 { + Text("\(battery)") + .font(.system(size: isWide ? 10 : 7, weight: .bold, design: .monospaced)) + } + } + .foregroundStyle(battery < 20 ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) + } + } + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } - var body: some View { + /// The noise floor graph's own thresholds: quiet green to -100 dBm, busy + /// orange to -90, loud red past it. + private static func noiseFloorColor(_ dbm: Int) -> Color { + if dbm <= -100 { return .green } + if dbm <= -90 { return .orange } + return .red + } + + private static func batterySymbol(_ percent: Int) -> String { + switch percent { + case 90...: return "battery.100" + case 60..<90: return "battery.75" + case 35..<60: return "battery.50" + case 10..<35: return "battery.25" + default: return "battery.0" + } + } + + // MARK: Heard rows + + @ViewBuilder + private func heardRows(isWide: Bool, width: CGFloat) -> some View { 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("Nothing heard") - .font(.system(size: 11)) + Text(state.repeaterEmptyLabel) + .font(.system(size: isWide ? 12 : 11)) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.75) } - } else if layout.usesTwoColumns { - HStack(alignment: .top, spacing: MeshMapperHeardLayout.columnGap) { - column(Array(rows.prefix(2))) - column(Array(rows.dropFirst(2))) + } else if state.showRepeaterNames ?? true { + VStack(alignment: .leading, spacing: isWide ? 3 : 2) { + ForEach(state.repeaters.prefix(isWide ? 3 : 2)) { repeater in + namedRow(repeater, isWide: isWide) + } } } else { - column(rows) + hexGrid(isWide: isWide, width: width) } } - /// Four rows are what two columns hold; one column keeps the two it can show - /// without pushing the lines below it off the card. - private var rows: [MeshMapperActivityAttributes.HeardRepeater] { - Array(state.repeaters.prefix(layout.usesTwoColumns ? 4 : 2)) + /// The nameless alternative behind the Settings switch: `[dot hex SNR]` + /// packed two columns wide, column-major, four observations deep, the + /// layout the card shipped with before names arrived. More repeaters per + /// glance, no identity beyond the hex. + /// + /// Font and column count are solved from the measured width: rigid rows at + /// a fixed size overflowed the 40 mm card as soon as 3-byte path mode + /// produced 6-character IDs. + private func hexGrid(isWide: Bool, width: CGFloat) -> some View { + let rows = Array(state.repeaters.prefix(4)) + let columnGap: CGFloat = isWide ? 16 : 8 + let contentWidth = width - 2 * (isWide ? 12 : 7) + let idChars = CGFloat(rows.map { $0.id.count }.max() ?? 2) + let snrChars = CGFloat( + rows.map { Self.formattedRowSnr($0.snr, signed: false).count }.max() ?? 4 + ) + // Advance of one semibold monospaced character is about 0.62 em; the dot + // and its two gaps consume about 12 points of each column. + let columnWidth = (contentWidth - columnGap) / 2 - 2 + let widthBound = (columnWidth - 12) / (0.62 * (idChars + snrChars)) + // Below 8 points the type stops paying for itself and one clearer column + // wins. Truncating the ID is never the trade: the ID is the identity. + let usesTwoColumns = widthBound >= 8 + let cap: CGFloat = isWide ? 13 : 10 + let fontSize = min(max(usesTwoColumns ? widthBound : cap, 8), cap) + + return HStack(alignment: .top, spacing: columnGap) { + hexColumn(Array(rows.prefix(2)), isWide: isWide, fontSize: fontSize) + if usesTwoColumns, rows.count > 2 { + hexColumn(Array(rows.dropFirst(2)), isWide: isWide, fontSize: fontSize) + } + } } - private func column( - _ repeaters: [MeshMapperActivityAttributes.HeardRepeater] + private func hexColumn( + _ repeaters: [MeshMapperActivityAttributes.HeardRepeater], + isWide: Bool, + fontSize: CGFloat ) -> some View { - VStack(alignment: .leading, spacing: MeshMapperHeardLayout.rowGap) { - ForEach(repeaters) { row($0) } + VStack(alignment: .leading, spacing: isWide ? 3 : 2) { + ForEach(repeaters) { repeater in + HStack(spacing: isWide ? 5 : 3) { + Circle() + .fill(repeater.typeColor.map(Color.init) ?? state.outcomeColor) + .frame(width: isWide ? 6 : 5, height: isWide ? 6 : 5) + Text(repeater.id.uppercased()) + .font(.system(size: fontSize, weight: .semibold, design: .monospaced)) + Text(Self.formattedRowSnr(repeater.snr, signed: false)) + .font(.system(size: fontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .ignore) + .accessibilityLabel(repeater.accessibilitySummary) + } } } - /// Content-sized on purpose, so the row's intrinsic measurement stays equal - /// to the width model that chose the font and the column count. - private func row( - _ repeater: MeshMapperActivityAttributes.HeardRepeater + /// `[type dot] [hex] [name] … [SNR]`. + private func namedRow( + _ repeater: MeshMapperActivityAttributes.HeardRepeater, + isWide: Bool ) -> some View { - HStack(spacing: 3) { + HStack(spacing: isWide ? 6 : 4) { Circle() .fill(repeater.typeColor.map(Color.init) ?? state.outcomeColor) - .frame(width: 6, height: 6) + .frame(width: isWide ? 6 : 5, height: isWide ? 6 : 5) Text(repeater.id.uppercased()) - .font( - .system(size: layout.rowFontSize, weight: .semibold, design: .monospaced) - ) - // No unit and no plus sign, as on the map panel: the column is all SNR, - // and "dB" on every row costs the width a fourth observation needs. - Text(repeater.snr, format: .number.precision(.fractionLength(1))) - .font( - .system(size: layout.rowFontSize, weight: .semibold, design: .monospaced) - ) + .font(.system(size: isWide ? 13 : 10, weight: .semibold, design: .monospaced)) + .fixedSize() + if let name = repeater.resolvedName { + Text(name) + .font(.system(size: isWide ? 12 : 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + // The one element that gives way when the row runs out of width. + .layoutPriority(-1) + } + Spacer(minLength: 4) + Text(Self.formattedRowSnr(repeater.snr, signed: isWide)) + .font(.system(size: isWide ? 13 : 10, weight: .semibold, design: .monospaced)) .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) - // Fixed width so SNRs line up down a column despite varying digits. - .frame(width: layout.rowFontSize * layout.snrWidthEm, alignment: .trailing) + .fixedSize() } .lineLimit(1) - .fixedSize() .accessibilityElement(children: .ignore) .accessibilityLabel(repeater.accessibilitySummary) } -} -/// Column count and row font, solved from the card's measured width the way -/// `MapPage` solves them from the watch's screen bounds. -@available(iOSApplicationExtension 18.0, *) -private struct MeshMapperHeardLayout { - /// A stable gutter keeps the width equation and the rendered columns in - /// agreement; deriving it from card size would silently spend the room the - /// font calculation just recovered on the smallest watch. - static let columnGap: CGFloat = 8 - /// Vertical gap between the two rows of a column. - static let rowGap: CGFloat = 2 - /// Advance of one semibold monospaced character, in ems. Closer to 0.62 than - /// the nominal 0.6, measured off the rendered row. - private static let charAdvance: CGFloat = 0.62 - /// A rendered line box against its nominal point size, for the height bound. - private static let lineHeightRatio: CGFloat = 1.25 - - let usesTwoColumns: Bool - let rowFontSize: CGFloat - /// Width of the aligned SNR column, in ems of `rowFontSize`. Solved from the - /// widest value actually present rather than from the widest one possible: - /// a fixed five-character reservation charged every card for "-12.4" when - /// the data was "2.1", and that reservation was the single largest consumer - /// of the width the type needed. - let snrWidthEm: CGFloat - - init( - contentWidth: CGFloat, - contentHeight: CGFloat, - repeaters: [MeshMapperActivityAttributes.HeardRepeater] - ) { - let idChars = CGFloat(repeaters.map(\.id.count).max() ?? 2) - let snrChars = CGFloat(repeaters.map { Self.snrCharacterCount($0.snr) }.max() ?? 4) - snrWidthEm = Self.charAdvance * snrChars - - let columnWidth = (contentWidth - Self.columnGap) / 2 - 2 - // The constants mirror the row: dot and its two gaps consume 12 pt, and - // both text runs advance at `charAdvance`. Two points of slack put - // measurement error on smaller type rather than on a row that overflows. - let widthBound = - (columnWidth - 12) / (Self.charAdvance * idChars + snrWidthEm) - - // Eight points is the wrist's measured glanceability floor and is - // inherited unchanged — below it the extra width stops paying for itself - // and one clearer column wins. Truncating the ID is never the trade, - // because the ID is the identity. - usesTwoColumns = widthBound >= 8 - - // Two rows deep either way: two columns hold four, one column keeps the - // two it can show. Type that overflows this is the defect the card was - // rebuilt to fix, so the height is a bound and not a preference. - let heightBound = - (contentHeight - Self.rowGap) / 2 / Self.lineHeightRatio - - // Raised now that the header is the only line above the grid. The card is - // wider than the app's panel and has no map beneath it to stay clear of, - // so the same arm's-length reading distance affords larger type here; the - // ladder still steps down as the ID lengthens, because a longer ID spends - // the same width on identity that the size would otherwise take. - let cap: CGFloat = idChars > 4 ? 14 : idChars > 2 ? 15 : 16 - let bound = min(usesTwoColumns ? widthBound : cap, heightBound) - rowFontSize = min(max(bound, 8), cap) - } - - /// Characters in an SNR as the row formats it: one fraction digit, its - /// separator, the integer digits, and a minus sign when there is one. The - /// monospaced face advances all of them equally, so counting them is the - /// same measurement the ID gets. - private static func snrCharacterCount( - _ snr: Double - ) -> Int { - let magnitude = abs(snr) - let integerDigits = magnitude < 10 ? 1 : magnitude < 100 ? 2 : 3 - return integerDigits + 2 + (snr < 0 ? 1 : 0) + /// No unit, as on the map panel: the column is all SNR, and "dB" on every + /// row costs width the name needs. The narrow card also drops the plus sign. + private static func formattedRowSnr(_ snr: Double, signed: Bool) -> String { + let value = snr.formatted(.number.precision(.fractionLength(1))) + return signed && snr >= 0 ? "+\(value)" : value } } @@ -725,8 +756,14 @@ private struct MeshMapperCountdown: View { } private enum MeshMapperPalette { - static let background = Color(red: 0.055, green: 0.075, blue: 0.105) - static let accent = Color.accentColor + /// slate-900, the app's main dark background. + static let background = Color(red: 0.059, green: 0.090, blue: 0.165) + /// slate-800, the app's card and panel surface, used for the small card's band. + static let surface = Color(red: 0.118, green: 0.161, blue: 0.231) + /// slate-700, the app's border colour, used for the band's divider. + static let hairline = Color(red: 0.200, green: 0.255, blue: 0.333) + /// emerald-600, the app's primary. + static let accent = Color(red: 0.020, green: 0.588, blue: 0.412) } extension MeshMapperActivityAttributes.ContentState { @@ -787,6 +824,28 @@ extension MeshMapperActivityAttributes.ContentState { } } + /// The phase title without its trailing ellipsis, for the small card's + /// band: the countdown beside it already says "in progress", and the dots + /// are what pushed the longest title into truncating on tight cards. + fileprivate var bandPhaseTitle: String { + var trimmed = phaseTitle + while trimmed.hasSuffix("\u{2026}") || trimmed.hasSuffix(".") { + trimmed.removeLast() + } + return trimmed.isEmpty ? phaseTitle : trimmed + } + + /// The app's own per-mode icon as an SF Symbol: swap arrows for Hybrid, + /// a paperplane for Active's send, an ear for Passive, a scope for Trace. + fileprivate var modeSymbol: String { + switch mode.lowercased() { + case "hybrid": return "arrow.left.arrow.right" + case "passive": return "ear" + case "trace", "targeted": return "scope" + default: return "paperplane.fill" + } + } + fileprivate var phaseSymbol: String { switch phase { case "sending": return "arrow.up.circle.fill" @@ -874,6 +933,8 @@ extension MeshMapperActivityAttributes.ContentState { pingColor: .init(r: 0.20, g: 0.84, b: 0.45), isConnected: true, zoneCode: "SEA", + noiseFloorDbm: -102, + companionBatteryPct: 80, txCount: 42, rxCount: 318, discoveryCount: 8, @@ -928,6 +989,8 @@ extension MeshMapperActivityAttributes.ContentState { pingColor: .init(r: 0.94, g: 0.28, b: 0.25), isConnected: true, zoneCode: "SEA", + noiseFloorDbm: -96, + companionBatteryPct: 15, txCount: 17, rxCount: 3, discoveryCount: 0, @@ -940,6 +1003,24 @@ extension MeshMapperActivityAttributes.ContentState { ) } + fileprivate static var previewGridMode: Self { + var state = previewRunningWithResponses + state.showRepeaterNames = false + // The widest rows the grid can be handed: 6-character IDs from 3-byte + // path mode with double-digit negative SNRs. The solver must fit these + // on a 40 mm card without clipping. + state.repeaters = state.repeaters.enumerated().map { index, repeater in + MeshMapperActivityAttributes.HeardRepeater( + id: repeater.id + String(format: "%02X", index), + name: repeater.name, + snr: index == 2 ? -12.4 : repeater.snr, + typeColor: repeater.typeColor, + snrColor: repeater.snrColor + ) + } + return state + } + fileprivate static var previewLapsedDeadline: Self { var state = previewRunningWithResponses state.phaseEndsAt = Date().addingTimeInterval(-5) @@ -1045,6 +1126,7 @@ enum MeshMapperActivityRenderHarness { ("responses", .previewRunningWithResponses), ("none-heard", .previewRunningWithNoneHeard), ("lapsed", .previewLapsedDeadline), + ("grid", .previewGridMode), ] var written: [String] = [] @@ -1077,6 +1159,19 @@ enum MeshMapperActivityRenderHarness { written.append(path) } } + // The wide layouts CarPlay renders; the exact CarPlay size is not + // published, so these bracket the threshold: icon-only mode at 250, + // the spelled-out mode word at 320. + for width in [CGFloat(250), CGFloat(320)] { + if let path = render( + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: width, height: 100) + .background(MeshMapperPalette.background), + named: "small-carplay\(Int(width))-\(name)", in: directory + ) { + written.append(path) + } + } } } return written @@ -1106,4 +1201,8 @@ enum MeshMapperActivityRenderHarness { #Preview("Lapsed deadline") { MeshMapperActivityPreview(state: .previewLapsedDeadline) } + +#Preview("Grid mode (names off)") { + MeshMapperActivityPreview(state: .previewGridMode) +} #endif diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index ffd2c0c8..773a34c7 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -138,6 +138,9 @@ final class LiveActivityManager { pingColor: resolvedColor(payload["pingColor"]), isConnected: payload["isConnected"] as? Bool ?? false, zoneCode: boundedString(payload["zoneCode"], maxLength: 12), + noiseFloorDbm: boundedInteger(payload["noiseFloorDbm"], min: -200, max: 100), + companionBatteryPct: boundedInteger(payload["companionBatteryPct"], min: 0, max: 100), + showRepeaterNames: payload["showRepeaterNames"] as? Bool, txCount: nonnegativeInteger(payload["txCount"]), rxCount: nonnegativeInteger(payload["rxCount"]), discoveryCount: nonnegativeInteger(payload["discoveryCount"]), @@ -284,6 +287,13 @@ final class LiveActivityManager { return 0 } + private func boundedInteger(_ value: Any?, min minValue: Int, max maxValue: Int) -> Int? { + guard let value = finiteNumber(value) else { return nil } + // Clamp in Double space first: Int(hugeFiniteDouble) is a runtime trap. + let clamped = Swift.min(Swift.max(value, Double(minValue)), Double(maxValue)) + return Int(clamped) + } + 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))) diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift index c42c0295..347d38f1 100644 --- a/ios/Shared/MeshMapperActivityAttributes.swift +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -31,6 +31,14 @@ struct MeshMapperActivityAttributes: ActivityAttributes { var pingColor: ResolvedColor? var isConnected: Bool var zoneCode: String? + /// Bucketed to 2 dBm steps by the phone so poll jitter never mints an + /// update. Defaulted so payloads from an app without the field still + /// decode. + var noiseFloorDbm: Int? = nil + /// Companion radio battery, bucketed to 5 percent steps by the phone. + var companionBatteryPct: Int? = nil + /// The Settings switch: named rows (true) or the two-column hex grid. + var showRepeaterNames: Bool? = nil var txCount: Int var rxCount: Int var discoveryCount: Int diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 064c11f6..f20fa4a9 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -94,6 +94,10 @@ class UserPreferences { /// Show top 3 repeaters by SNR on the map during wardriving final bool showTopRepeaters; + /// Live Activity heard rows show resolved repeater names (fewer, named + /// rows). When false the card packs a two-column hex grid instead. + final bool liveActivityShowNames; + /// Coverage marker style on the map (dot, pin, diamond) final String markerStyle; @@ -165,6 +169,7 @@ class UserPreferences { this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, this.showTopRepeaters = false, + this.liveActivityShowNames = true, this.markerStyle = 'dot', this.gpsMarkerStyle = 'arrow', this.colorVisionType = 'none', @@ -214,6 +219,7 @@ class UserPreferences { minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, + liveActivityShowNames: (json['liveActivityShowNames'] as bool?) ?? true, markerStyle: (json['markerStyle'] as String?) ?? 'dot', gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', @@ -276,6 +282,7 @@ class UserPreferences { 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, 'showTopRepeaters': showTopRepeaters, + 'liveActivityShowNames': liveActivityShowNames, 'markerStyle': markerStyle, 'gpsMarkerStyle': gpsMarkerStyle, 'colorVisionType': colorVisionType, @@ -323,6 +330,7 @@ class UserPreferences { int? minPingDistanceMeters, bool? autoStopAfterIdle, bool? showTopRepeaters, + bool? liveActivityShowNames, String? markerStyle, String? gpsMarkerStyle, String? colorVisionType, @@ -371,6 +379,8 @@ class UserPreferences { minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, + liveActivityShowNames: + liveActivityShowNames ?? this.liveActivityShowNames, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, @@ -448,6 +458,7 @@ class UserPreferences { other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && other.showTopRepeaters == showTopRepeaters && + other.liveActivityShowNames == liveActivityShowNames && other.markerStyle == markerStyle && other.gpsMarkerStyle == gpsMarkerStyle && other.colorVisionType == colorVisionType && @@ -493,6 +504,7 @@ class UserPreferences { minPingDistanceMeters, autoStopAfterIdle, showTopRepeaters, + liveActivityShowNames, markerStyle, gpsMarkerStyle, colorVisionType, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d95d3569..7efcce54 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1480,6 +1480,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseDurationMs: _phaseDurationMsFor(phase.endsAt), isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + showRepeaterNames: _preferences.liveActivityShowNames, ); } @@ -2560,6 +2561,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { pingColor: pingColor, isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + noiseFloorDbm: _bucketedNoiseFloorDbm(), + companionBatteryPct: _bucketedCompanionBatteryPct(), + showRepeaterNames: _preferences.liveActivityShowNames, txCount: _pingStats.txCount, rxCount: _pingStats.rxCount, discoveryCount: _pingStats.discCount, @@ -2572,6 +2576,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } + /// 2 dBm steps so ambient jitter on the 5 second poll never changes the + /// Live Activity payload fingerprint; the exact value stays a phone fact. + int? _bucketedNoiseFloorDbm() { + final noiseFloor = _currentNoiseFloor; + if (noiseFloor == null) return null; + return (noiseFloor / 2).round() * 2; + } + + /// 5 percent steps so routine drain never changes the Live Activity payload + /// fingerprint; the exact value stays a phone-screen fact. + int? _bucketedCompanionBatteryPct() { + final percent = _currentBatteryPercent; + if (percent == null) return null; + return ((percent / 5).round() * 5).clamp(0, 100); + } + ({ LiveActivityPhase phase, String title, @@ -7466,6 +7486,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { + final liveActivityLayoutChanged = + preferences.liveActivityShowNames != _preferences.liveActivityShowNames; debugLog( '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); @@ -7527,6 +7549,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Marker-style / GPS-marker prefs can change here — bump the map. _notifyMapNow(); + // The Live Activity row layout follows a preference now. Only that flip + // schedules a sync (the snapshot build walks the full ping history, too + // expensive for unrelated toggles), and the flag sits in the urgency key, + // which is what carries it past the 15 second non-urgent floor. + if (liveActivityLayoutChanged) { + _scheduleLiveActivitySync(immediate: true); + } _savePreferences(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1b89a37b..0c23e788 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -293,6 +293,21 @@ class _SettingsScreenState extends State { .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), + // Live Activities are iOS-only, so the switch would be inert + // anywhere else. + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) + SwitchListTile( + secondary: const Icon(Icons.badge), + title: const Text('Repeater Names on Live Activity'), + subtitle: Text(prefs.liveActivityShowNames + ? 'Named rows on CarPlay and watch' + : 'Compact grid fits more repeaters'), + value: prefs.liveActivityShowNames, + onChanged: (value) { + appState.updatePreferences( + prefs.copyWith(liveActivityShowNames: value)); + }, + ), ]), // Ping Settings diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index d7f90ff6..da1a0eaf 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -98,6 +98,9 @@ class LiveActivitySnapshot { this.phaseDurationMs, this.pingColor, this.zoneCode, + this.noiseFloorDbm, + this.companionBatteryPct, + this.showRepeaterNames = true, }); final String sessionId; @@ -110,6 +113,18 @@ class LiveActivitySnapshot { final WatchColor? pingColor; final bool isConnected; final String? zoneCode; + + /// Latest radio noise floor in whole dBm, for the Live Activity header. + /// Null when the radio has not answered a stats poll yet. + final int? noiseFloorDbm; + + /// Companion radio battery, already bucketed to 5 percent steps so routine + /// drain does not change the payload fingerprint on every poll. + final int? companionBatteryPct; + + /// Whether the small card spells out resolved repeater names (fewer, named + /// rows) or packs the two-column hex grid. Mirrors the Settings switch. + final bool showRepeaterNames; final int txCount; final int rxCount; final int discoveryCount; @@ -131,6 +146,10 @@ class LiveActivitySnapshot { if (pingColor != null) 'pingColor': pingColor!.toMap(), 'isConnected': isConnected, 'zoneCode': zoneCode, + if (noiseFloorDbm != null) 'noiseFloorDbm': noiseFloorDbm, + if (companionBatteryPct != null) + 'companionBatteryPct': companionBatteryPct, + 'showRepeaterNames': showRepeaterNames, 'txCount': txCount, 'rxCount': rxCount, 'discoveryCount': discoveryCount, @@ -154,6 +173,7 @@ class LiveActivitySnapshot { phaseDurationMs: phaseDurationMs, isConnected: isConnected, zoneCode: zoneCode, + showRepeaterNames: showRepeaterNames, ), pingColor?.r ?? '', pingColor?.g ?? '', @@ -182,6 +202,9 @@ class LiveActivitySnapshot { required int? phaseDurationMs, required bool isConnected, required String? zoneCode, + // Urgent because it is a user gesture: the wearer just flipped a display + // switch and is looking at the card to see it change. + bool showRepeaterNames = true, }) => [ sessionId, @@ -213,5 +236,6 @@ class LiveActivitySnapshot { phaseDurationMs ?? 0, isConnected, zoneCode ?? '', + showRepeaterNames, ].join('|'); } diff --git a/test/services/live_activity/live_activity_models_test.dart b/test/services/live_activity/live_activity_models_test.dart index e9233cb3..1c9d07b5 100644 --- a/test/services/live_activity/live_activity_models_test.dart +++ b/test/services/live_activity/live_activity_models_test.dart @@ -40,6 +40,7 @@ void main() { 'phaseEndsAt': phaseEndsAt.millisecondsSinceEpoch, 'isConnected': true, 'zoneCode': 'JKG', + 'showRepeaterNames': true, 'txCount': 42, 'rxCount': 318, 'discoveryCount': 8, From 522cf01915ad9af2153882c31d08b187adf31341 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 29 Aug 2026 07:39:55 -0400 Subject: [PATCH 348/360] Give the watch band a logo and one word it can actually say --- .../Assets.xcassets/Contents.json | 3 + .../LiveActivityLogo.imageset/Contents.json | 8 ++ .../LiveActivityLogo.imageset/logo_14.png | Bin 0 -> 450 bytes .../LiveActivityLogo.imageset/logo_28.png | Bin 0 -> 915 bytes .../LiveActivityLogo.imageset/logo_42.png | Bin 0 -> 1574 bytes .../MeshMapperLiveActivity.swift | 97 +++++++++++++++--- ios/Runner.xcodeproj/project.pbxproj | 4 + 7 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json create mode 100644 ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json create mode 100644 ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_14.png create mode 100644 ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_28.png create mode 100644 ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_42.png diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json b/ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json new file mode 100644 index 00000000..6f7aaf27 --- /dev/null +++ b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "logo_14.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "logo_28.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "logo_42.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_14.png b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_14.png new file mode 100644 index 0000000000000000000000000000000000000000..4c3315316345774e1f6a87c2b903d97d4c88dbdb GIT binary patch literal 450 zcmeAS@N?(olHy`uVBq!ia0vp^d?3uh1SBVD?P>#3jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(M4k&`?4efmEM#}hfr&vQug zI4@)4cX)nw`hHbb1x6=@`b{<$xaR!4%E*P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR919H0XL1ONa40RR918~^|S0A#)Nq5uE_{YgYYR7ef&m0L(uK^Vtp=A3nR zb=^c=cXcx_%L=Zzct>Ts5ctqj5BXpZK|O>$n1l(vL{LvjAq9~Lf<721Nmi5~MHeC| zq?@KDURKQYwz?|nu6xd$nP$E2ks~HpPktOYGv9pk`~LIIoH_82(FXS0(0&60yaVX| zSM@*Elwu4h-E|3pFeOt^N+n5eO+g_cO7UAh$Z>j$l@hY$+GuTu5>be-XJpqj=#nhh z>)q7LGO=f47L|Oy&^U8WO?`B3J`scs|N2`Fr4;chOWD=Q_iq+@x?>6*oK_paVXTca zhnf$sp^-{$C~zz}hh6P$ma+;?t=`PNMwLMc#(G=2CfVYwdl-?KlXmBU#%N-5Y`3a% zAGPK-wx-{{$Ln?^MMN=JtPBby9G;oI{Cm_lIucua*4^=8 z;b&K3X}LZxzxR4&_qFm+Tbku^<>Fxf)Z16FXG&Hk#utYM^qF~-Y*r=!=Yun_Hj0i} z8goH}P<*2MNfVQ)%|AL)UpLj>1|Z;LOs4ate3)+LQ%CHDOKdY2T`p)aLN*p;GAJAI z3Rqa((1jJj4-wPunVH4r1U^JXDP@g0&m6^~2+@LHfPi`>LRl|}$eJ=JfY3KSrb)F! zN*DY4xa>mkxN-h@OIXE?aBUddE_l4iQ(Fy0kx# z&al--h|Id7Jqz0G;)@pC`4LaJfGCiWVN!TP@(Z~7CY>B7f**Hw;Gura#VZs`{^kaN z5L@LYM-F9fB9T!iI$Z*KzDt9@0OFVmIz7P(2l~3nyLPa8Dkb+cpOC~ApYzw;B~ugI zltP8y36W5=k<5EQ@F%!-6$Hs)(ojSsTUkJ++?1fJdIK7FHFi9e!nP+_=O~3Lq}U;B p2dDyI53U2~9*Xssq5q_&e*mz4L{iTd+`a$+002ovPDHLkV1h(3hBg2I literal 0 HcmV?d00001 diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_42.png b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_42.png new file mode 100644 index 0000000000000000000000000000000000000000..37c3416e6cfb55a73c72d0b1966e254f14323c68 GIT binary patch literal 1574 zcmV+>2HE+EP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91Dxd=Z1ONa40RR91DgXcg0Mr_#3jhEGkV!;AR9Fe^S8HrkMHId>_ujqx z=weF*c$EiJAzJ5EJ}ih>0(LfKfmoMomaC1VKZB zHX@XlZ7G(Qn3k5(F70-A?{;_Zd&YBHpu6pEsXw4U?A^^~X3m`Ro%5YDXYL|*?i6PD z9z1-|Ch`dUF!KNZBOjqYx`!cpN{1D`4}9d{C2}!vOerN~;POybK$S_%b1I#ClpeyE zw>zkzr_vp4jT#1Lv4HyE9t;L=$a2xL6|=tFZO<0x3-f%s z6tLEe>;!La4xf|p+z-5!PMcBkLjH>@ zGs}I3BDeo^fO6Qoumbc3V#7*mc-A5~eAARrEPTF(VUYL9#jMxevTv7o^)hF*m2CXL zR$kh1tg3!(W!;+9{zE_WAyHiYm`z z>yV=d3}{!~szwMg{iQUfXskgg^bmv;Q4Gg5Bd-ucDM3biA|m;PqC{j-G^ZHH2}2bX z6v32a5Ccrnq30eU2wIMG>vkuyT8Sb{{x;2(#bxIpjHD|K@#~Ff);!mWRX_~lILDF~ z@PZ1d?Pk284zO8|*YM;hxBNtski}xyok}E1;_-pZrzt&zXgn^r-OxNv!z=w72Bs@~+GT}OEw?}` zC{h>%(%6CT;fz>{OSt8$v|H^`)lndyFZJn}9{FNDF$j6Hs~>P$w8(@(chxKw&Ya8d{qh|W)Vh~4lZjm6Y$ACXYke~i8diH3tX7=WSi z+b>Xneb}%BFtUsr|3`AzI`lQJtbdSH7JxAYs zZ@C+HqCUI?fG8!gl$iYkUTRY+-Z7FoC3mEza$0QIlOlrzv&R7zklHA?)&?Rm}Gk-`Xo9Y>|})e2g`R7+Ax#< YFWiikN3P0Jod5s;07*qoM6N<$f_ymUc>n+a literal 0 HcmV?d00001 diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 3832428c..93991b38 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -204,9 +204,9 @@ private struct MeshMapperSmallActivityContent: View { .foregroundStyle(.orange) .lineLimit(1) .minimumScaleFactor(0.85) - } else { + } else if isWide { Text(state.bandPhaseTitle) - .font(.system(size: isWide ? 13 : 11, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle( state.phaseColor.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1) ) @@ -227,6 +227,21 @@ private struct MeshMapperSmallActivityContent: View { .foregroundStyle(.secondary) .fixedSize() } + } else { + // The watch band: the logo carries identity and a single word carries + // state. A forward-looking title like "Next ping" read as orphaned + // there with no countdown beside it, which testers called out. + MeshMapperLogoMark(size: 14) + Text(state.narrowStatusWord) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle( + state.phaseColor.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1) + ) + .lineLimit(1) + // "Listening" is the longest word and the only one that needs the + // deeper floor; the short outcome words render at full size. + .minimumScaleFactor(0.65) + .layoutPriority(1) } Spacer(minLength: 4) middleIdentity(isWide: isWide, showsModeWord: showsModeWord) @@ -247,21 +262,29 @@ private struct MeshMapperSmallActivityContent: View { /// cards drop the mode word and let the icon carry it. private func middleIdentity(isWide: Bool, showsModeWord: Bool) -> some View { HStack(spacing: isWide ? 4 : 3) { - Image(systemName: state.modeSymbol) - .font(.system(size: isWide ? 9 : 6, weight: .bold)) + // The mode icon earns its place only beside the wide band's title; on + // the watch the logo owns identity and the zone reads better bigger. + if isWide { + Image(systemName: state.modeSymbol) + .font(.system(size: 9, weight: .bold)) + } if showsModeWord { Text(state.mode.uppercased()) .font(.system(size: 10, weight: .bold)) .tracking(0.3) } if let zone = state.zoneCode { - Image(systemName: "mappin.and.ellipse") - .font(.system(size: isWide ? 9 : 6, weight: .bold)) + // The pin is the least informative pixel on the 40 mm card, and its + // width is exactly what "Listening" needs to render unclipped. + if isWide { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: 9, weight: .bold)) + } Text(zone) - .font(.system(size: isWide ? 10 : 7, weight: .semibold, design: .monospaced)) + .font(.system(size: isWide ? 10 : 9, weight: .semibold, design: .monospaced)) } else if !state.isConnected { Text("Disconnected") - .font(.system(size: isWide ? 10 : 8, weight: .semibold)) + .font(.system(size: isWide ? 10 : 9, weight: .semibold)) .foregroundStyle(.orange) } } @@ -278,21 +301,22 @@ private struct MeshMapperSmallActivityContent: View { if let noiseFloor = state.noiseFloorDbm { HStack(spacing: 2) { Image(systemName: "waveform") - .font(.system(size: isWide ? 9 : 6, weight: .bold)) + .font(.system(size: isWide ? 9 : 8, weight: .bold)) Text("\(noiseFloor)") - .font(.system(size: isWide ? 10 : 7, weight: .bold, design: .monospaced)) + .font(.system(size: isWide ? 10 : 9, weight: .bold, design: .monospaced)) } .foregroundStyle(Self.noiseFloorColor(noiseFloor)) } if let battery = state.companionBatteryPct { HStack(spacing: 2) { Image(systemName: Self.batterySymbol(battery)) - .font(.system(size: isWide ? 10 : 7, weight: .semibold)) + .font(.system(size: isWide ? 10 : 9, weight: .semibold)) // The number earns its width only on the widest tier; below it - // the glyph is enough until the battery is actually in trouble. - if showsModeWord || battery < 20 { + // the glyph is the signal, turning red when the battery is in + // trouble, and the number's width belongs to the status word. + if showsModeWord { Text("\(battery)") - .font(.system(size: isWide ? 10 : 7, weight: .bold, design: .monospaced)) + .font(.system(size: isWide ? 10 : 9, weight: .bold, design: .monospaced)) } } .foregroundStyle(battery < 20 ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) @@ -520,6 +544,30 @@ private struct MeshMapperPhaseProgress: View { private static let trackHeight: CGFloat = 4 } +/// The app mark for the watch band. Falls back to a drawn monogram when the +/// asset cannot load, so the slot never renders empty. +private struct MeshMapperLogoMark: View { + let size: CGFloat + + var body: some View { + if let image = UIImage(named: "LiveActivityLogo") { + Image(uiImage: image) + .resizable() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.24, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: size * 0.24, style: .continuous) + .fill(MeshMapperPalette.accent) + .frame(width: size, height: size) + .overlay { + Text("MM") + .font(.system(size: size * 0.42, weight: .heavy)) + .foregroundStyle(.white) + } + } + } +} + private struct MeshMapperStatusLabel: View { let state: MeshMapperActivityAttributes.ContentState let isStale: Bool @@ -835,6 +883,27 @@ extension MeshMapperActivityAttributes.ContentState { return trimmed.isEmpty ? phaseTitle : trimmed } + /// One word of state for the watch band, where the full title has no room + /// and a countdown-less "Next ping" reads as a question. Waiting phases say + /// what just happened instead of promising a time. + fileprivate var narrowStatusWord: String { + switch phase { + case "sending": return "Sending" + case "discovering": return "Discovery" + case "tracing": return "Tracing" + case "listening", "listening_discovery", "listening_trace": return "Listening" + case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return "Sent" + case "skipped": return "Skipped" + case "waiting_for_gps": return "No GPS" + case "paused_outside_zone": return "Paused" + case "disconnected": return "Offline" + case "tx_blocked": return "Blocked" + case "stopping", "stopped": return "Stopped" + case "starting": return "Starting" + default: return bandPhaseTitle + } + } + /// The app's own per-mode icon as an SF Symbol: swap arrows for Hybrid, /// a paperplane for Active's send, an ear for Passive, a scope for Trace. fileprivate var modeSymbol: String { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ef4b6001..5064383f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 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 */; }; + A10000000000000000000009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* Assets.xcassets */; }; 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 */; }; @@ -177,6 +178,7 @@ 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 = ""; }; + A20000000000000000000009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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; }; @@ -381,6 +383,7 @@ isa = PBXGroup; children = ( A20000000000000000000003 /* MeshMapperLiveActivity.swift */, + A20000000000000000000009 /* Assets.xcassets */, A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */, A20000000000000000000005 /* Info.plist */, ); @@ -610,6 +613,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A10000000000000000000009 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; From d529230e2dd652a81340d30e89d98fdb3a524cae Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 29 Aug 2026 07:39:55 -0400 Subject: [PATCH 349/360] Let pod install trim the lockfile to the non-SPM plugins --- ios/Podfile.lock | 127 ----------------------------------------------- 1 file changed, 127 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c0207dcf..10431777 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,172 +1,45 @@ PODS: - - app_links (6.4.1): - - Flutter - - audio_session (0.0.1): - - Flutter - disk_space_plus (0.0.1): - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter - flutter_secure_storage (6.0.0): - Flutter - - geolocator_apple (1.2.0): - - Flutter - - FlutterMacOS - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - MapLibre (6.19.1) - - maplibre_gl (0.25.0): - - Flutter - - MapLibre (= 6.19.1) - - package_info_plus (0.4.5): - - Flutter - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.7): - - SDWebImage/Core (= 5.21.7) - - SDWebImage/Core (5.21.7) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftyGif (5.4.5) - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - - app_links (from `.symlinks/plugins/app_links/ios`) - - audio_session (from `.symlinks/plugins/audio_session/ios`) - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - MapLibre - - SDWebImage - - SwiftyGif EXTERNAL SOURCES: - app_links: - :path: ".symlinks/plugins/app_links/ios" - audio_session: - :path: ".symlinks/plugins/audio_session/ios" disk_space_plus: :path: ".symlinks/plugins/disk_space_plus/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" - geolocator_apple: - :path: ".symlinks/plugins/geolocator_apple/darwin" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - maplibre_gl: - :path: ".symlinks/plugins/maplibre_gl/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - MapLibre: 7f24faba45439f80ccb0f83393c29fa32cb81952 - maplibre_gl: a2114567cbd1065866614fbd34dfb75ab782aaa2 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 From 025c02cb6dad339cdb21c22bf944a6cdbb68a9f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 29 Aug 2026 14:24:50 -0400 Subject: [PATCH 350/360] Paint the progress bar in the running mode's color again --- .../MeshMapperLiveActivity.swift | 10 +++++--- ios/Runner/LiveActivityManager.swift | 1 + ios/Shared/MeshMapperActivityAttributes.swift | 3 +++ lib/providers/app_state_provider.dart | 24 +++++++++++++++++++ .../live_activity/live_activity_models.dart | 7 ++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 93991b38..a5aee16e 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -527,9 +527,11 @@ private struct MeshMapperPhaseProgress: View { 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) + // The bar wears the running mode's identity color, phone-resolved from + // the same ping palette as the app's markers (so CVD palettes carry + // over). Outcome still lives in the dots: a failed ping must not turn + // the whole track red, which is why this is mode, not result. + .tint(state.modeColor.map(Color.init) ?? 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 @@ -1000,6 +1002,7 @@ extension MeshMapperActivityAttributes.ContentState { phaseEndsAt: Date().addingTimeInterval(42), phaseDurationMs: 60_000, pingColor: .init(r: 0.20, g: 0.84, b: 0.45), + modeColor: .init(r: 0.30, g: 0.69, b: 0.31), isConnected: true, zoneCode: "SEA", noiseFloorDbm: -102, @@ -1056,6 +1059,7 @@ extension MeshMapperActivityAttributes.ContentState { phaseEndsAt: Date().addingTimeInterval(25), phaseDurationMs: 60_000, pingColor: .init(r: 0.94, g: 0.28, b: 0.25), + modeColor: .init(r: 0.30, g: 0.69, b: 0.31), isConnected: true, zoneCode: "SEA", noiseFloorDbm: -96, diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index 773a34c7..9d807e73 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -136,6 +136,7 @@ final class LiveActivityManager { phaseEndsAt: date(payload["phaseEndsAt"]), phaseDurationMs: positiveInteger(payload["phaseDurationMs"]), pingColor: resolvedColor(payload["pingColor"]), + modeColor: resolvedColor(payload["modeColor"]), isConnected: payload["isConnected"] as? Bool ?? false, zoneCode: boundedString(payload["zoneCode"], maxLength: 12), noiseFloorDbm: boundedInteger(payload["noiseFloorDbm"], min: -200, max: 100), diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift index 347d38f1..eba75da2 100644 --- a/ios/Shared/MeshMapperActivityAttributes.swift +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -29,6 +29,9 @@ struct MeshMapperActivityAttributes: ActivityAttributes { var phaseEndsAt: Date? var phaseDurationMs: Int? var pingColor: ResolvedColor? + /// The running mode's identity color, phone-resolved from the active + /// ping palette. Tints the lock screen's phase progress bar. + var modeColor: ResolvedColor? = nil var isConnected: Bool var zoneCode: String? /// Bucketed to 2 dBm steps by the phone so poll jitter never mints an diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7efcce54..b83c44de 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1872,6 +1872,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } + /// The running mode's identity color from the active ping palette, for the + /// Live Activity's progress bar. Hybrid follows the half of the cycle that + /// is on the air, so the bar says which ping type is running right now. + WatchColor? _resolveLiveActivityModeColor(LiveActivityPhase phase) { + switch (phase) { + case LiveActivityPhase.discovering: + case LiveActivityPhase.listeningDiscovery: + case LiveActivityPhase.waitingDiscovery: + return WatchColor.fromColor(PingColors.discSuccess); + case LiveActivityPhase.tracing: + case LiveActivityPhase.listeningTrace: + case LiveActivityPhase.waitingTrace: + return WatchColor.fromColor(PingColors.traceSuccess); + default: + break; + } + return switch (_liveActivityModeTitle.toLowerCase()) { + 'passive' => WatchColor.fromColor(PingColors.discSuccess), + 'trace' => WatchColor.fromColor(PingColors.traceSuccess), + _ => WatchColor.fromColor(PingColors.txSuccess), + }; + } + /// 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() => @@ -2559,6 +2582,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseEndsAt: phase.endsAt, phaseDurationMs: phaseDurationMs, pingColor: pingColor, + modeColor: _resolveLiveActivityModeColor(phase.phase), isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, noiseFloorDbm: _bucketedNoiseFloorDbm(), diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index da1a0eaf..152f91d6 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -97,6 +97,7 @@ class LiveActivitySnapshot { this.phaseEndsAt, this.phaseDurationMs, this.pingColor, + this.modeColor, this.zoneCode, this.noiseFloorDbm, this.companionBatteryPct, @@ -111,6 +112,11 @@ class LiveActivitySnapshot { final DateTime? phaseEndsAt; final int? phaseDurationMs; final WatchColor? pingColor; + + /// The running mode's identity color from the active ping palette, tinting + /// the lock screen's progress bar the way the app colors that ping type. + final WatchColor? modeColor; + final bool isConnected; final String? zoneCode; @@ -144,6 +150,7 @@ class LiveActivitySnapshot { 'phaseEndsAt': phaseEndsAt?.millisecondsSinceEpoch, if (phaseDurationMs != null) 'phaseDurationMs': phaseDurationMs, if (pingColor != null) 'pingColor': pingColor!.toMap(), + if (modeColor != null) 'modeColor': modeColor!.toMap(), 'isConnected': isConnected, 'zoneCode': zoneCode, if (noiseFloorDbm != null) 'noiseFloorDbm': noiseFloorDbm, From 5ab0b0292e76fe1204e143583c25b60fa6a485c1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 29 Aug 2026 14:32:38 -0400 Subject: [PATCH 351/360] Execute a queued stop on every path that ends a ping --- lib/services/ping_service.dart | 48 ++- pubspec.lock | 2 +- pubspec.yaml | 1 + .../ping_service_pending_disable_test.dart | 312 ++++++++++++++++++ 4 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 test/services/ping_service_pending_disable_test.dart diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 4a875e00..44c014d6 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -122,11 +122,10 @@ class PingService { // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; - // Backstop for the flag above. It is drained at exactly one point (the end of - // the RX window) while _pingInProgress is cleared on ~18 paths, so a ping that - // ends without ever arming a window (validation reject, no GPS fix, failed - // send) parks the disable forever and auto mode keeps its timer and keeps - // pinging after the user stopped it (#476). + // Last-resort backstop for the flag above. Every path that ends a ping + // lifecycle (RX/discovery/trace window end, validation skip, failed send) + // drains the flag itself (#496); this timer only remains for paths nobody + // has thought of, so a stranded disable can never outlive it (#476). Timer? _pendingDisableTimeout; /// How long to wait for the real window completion before forcing a parked @@ -861,6 +860,16 @@ class PingService { debugLog('[PING] Ping operation failed: $e'); _pingInProgress = false; return false; + } finally { + // A ping that ended without arming an RX window (validation skip, no + // GPS, failed send, any early return above) is the end of the lifecycle + // a queued disable was waiting on. Drain it here rather than leaving it + // for the timeout backstop, which locked the controls for the whole + // 12s wait (#496). A successful send leaves _pingInProgress true until + // _endRxListeningWindow, which does its own drain. + if (!_pingInProgress && _pendingDisable) { + await _executePendingDisable('ping ended without RX window'); + } } } @@ -1308,6 +1317,7 @@ class PingService { _pendingDisable = false; _pendingDisableTimeout?.cancel(); _pendingDisableTimeout = null; + final wasPassive = _passiveModeEnabled; final wasHybrid = _hybridModeEnabled; final wasTargeted = _targetedModeEnabled; _autoPingEnabled = false; @@ -1317,8 +1327,8 @@ class PingService { _nextPingIsDiscovery = true; _autoTimer?.cancel(); _autoTimer = null; - // Clean up discovery infrastructure if hybrid was enabled - if (wasHybrid) { + // Clean up discovery infrastructure if passive or hybrid was enabled + if (wasPassive || wasHybrid) { _stopDiscoveryMode(); } // Clean up targeted infrastructure if targeted was enabled @@ -1577,12 +1587,16 @@ class PingService { } catch (e) { _pingInProgress = false; debugError('[DISC] Failed to send discovery request: $e'); + if (_pendingDisable) { + await _executePendingDisable('discovery send failed'); + return; + } _scheduleNextDiscovery(); } } /// Handle discovery window completion - void _handleDiscoveryWindowComplete(List nodes) { + Future _handleDiscoveryWindowComplete(List nodes) async { _discoveryWindowCountdown.stop(); final position = _discoveryStartPosition; if (position == null) { @@ -1590,6 +1604,10 @@ class PingService { // Notify about discovery failure for noise floor graph onDiscoveryWindowComplete?.call(false); _lastDiscPing = null; + if (_pendingDisable) { + await _executePendingDisable('after discovery window'); + return; + } _scheduleNextDiscovery(); return; } @@ -1647,6 +1665,10 @@ class PingService { '[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); _lastDiscPing = null; + if (_pendingDisable) { + await _executePendingDisable('after discovery window'); + return; + } _scheduleNextDiscovery(); } @@ -1881,12 +1903,16 @@ class PingService { } catch (e) { _pingInProgress = false; debugError('[TRACE] Failed to send trace: $e'); + if (_pendingDisable) { + await _executePendingDisable('trace send failed'); + return; + } _scheduleNextTargetedPing(); } } /// Handle trace window completion - void _handleTraceWindowComplete(TraceResult? result) { + Future _handleTraceWindowComplete(TraceResult? result) async { _discoveryWindowCountdown.stop(); final position = _lastTargetedPosition; final targetId = _targetRepeaterId ?? ''; @@ -1923,6 +1949,10 @@ class PingService { // Notify for noise floor graph and log updates onTraceWindowComplete?.call(result); + if (_pendingDisable) { + await _executePendingDisable('after trace window'); + return; + } _scheduleNextTargetedPing(); } diff --git a/pubspec.lock b/pubspec.lock index 7141fd25..86d9a5f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -298,7 +298,7 @@ packages: source: hosted version: "2.0.8" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index fbfe8339..4e38cac8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dev_dependencies: build_runner: ^2.4.0 hive_generator: ^2.0.0 flutter_launcher_icons: ^0.14.3 + fake_async: ^1.3.3 # Vendored, patched copy of maplibre_gl 0.25.0. The ONLY delta from the # upstream pub.dev release is a native guard in the camera handlers diff --git a/test/services/ping_service_pending_disable_test.dart b/test/services/ping_service_pending_disable_test.dart new file mode 100644 index 00000000..3e02ec7b --- /dev/null +++ b/test/services/ping_service_pending_disable_test.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mesh_mapper/models/connection_state.dart'; +import 'package:mesh_mapper/models/device_model.dart'; +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/countdown_timer_service.dart'; +import 'package:mesh_mapper/services/gps_service.dart'; +import 'package:mesh_mapper/services/meshcore/connection.dart'; +import 'package:mesh_mapper/services/ping_service.dart'; +import 'package:mesh_mapper/services/wakelock_service.dart'; + +/// #496: a disable queued while a ping is in flight must be executed by +/// whatever ends that ping's lifecycle — skip, failure, or listening window — +/// never left for the 12s timeout backstop (or, on v1.3.0, the next ping). +/// +/// The production capture: the user's stop landed during an auto ping's GPS +/// fetch; the ping was then skipped as too-close, so no RX window was armed +/// and nothing drained the flag. Ping controls stayed locked until the NEXT +/// ping's window drained it 41s later. + +class _FakeGps implements GpsService { + Position? position; + bool tooClose = false; + Completer? freshPositionGate; + + @override + GpsStatus get status => GpsStatus.locked; + + @override + Position? get lastPosition => position; + + @override + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) => + freshPositionGate?.future ?? Future.value(position); + + @override + bool isAccuracyAcceptableForPing(Position position) => true; + + @override + bool canPingAtPosition(Position position) => !tooClose; + + @override + double get configuredMinDistance => 25.0; + + @override + void markPingPosition(Position position) {} + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('GpsService.${invocation.memberName}'); +} + +class _FakeConnection implements MeshCoreConnection { + Completer? discoveryGate; + Completer? traceGate; + bool sendPingThrows = false; + + @override + ConnectionStep get currentStep => ConnectionStep.connected; + + @override + DeviceModel? get deviceModel => null; + + @override + int? get lastNoiseFloor => null; + + @override + int? get wardrivingChannelIndex => null; + + @override + Uint8List? get wardrivingChannelKey => null; + + @override + int? get wardrivingChannelHash => null; + + @override + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + const Stream.empty(); + + @override + Stream get traceDataStream => const Stream.empty(); + + @override + Future sendPing(String message) async { + if (sendPingThrows) throw Exception('BLE write failed'); + } + + @override + Future sendDiscoveryRequest() => + discoveryGate?.future ?? Future.value(Uint8List(4)); + + @override + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) => + traceGate?.future ?? Future.value(Uint8List(4)); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('MeshCoreConnection.${invocation.memberName}'); +} + +class _FakeApiQueue implements ApiQueueService { + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.memberName.toString().contains('enqueue')) { + return Future.value(); + } + throw UnimplementedError('ApiQueueService.${invocation.memberName}'); + } +} + +class _FakeWakelock implements WakelockService { + @override + bool get isEnabled => false; + + @override + Future enable() async {} + + @override + Future disable() async {} + + @override + Future dispose() async {} +} + +Position _pos(double lat, double lon) => Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 0.0, + altitudeAccuracy: 1.0, + heading: 0.0, + headingAccuracy: 1.0, + speed: 0.0, + speedAccuracy: 1.0, + ); + +PingService _buildService(_FakeGps gps, _FakeConnection conn) => PingService( + gpsService: gps, + connection: conn, + apiQueue: _FakeApiQueue(), + wakelockService: _FakeWakelock(), + cooldownTimer: CooldownTimer(), + manualPingCooldownTimer: ManualPingCooldownTimer(), + rxWindowTimer: RxWindowTimer(), + discoveryWindowTimer: DiscoveryWindowTimer(), + deviceId: 'TEST', + ); + +void main() { + test('a disable queued during a skipped auto ping executes immediately', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn); + + final gate = Completer(); + gps.freshPositionGate = gate; + + ping.enableAutoPing(); + async.flushMicrotasks(); + expect(ping.pingInProgress, isTrue, + reason: 'initial auto ping should be parked on the GPS fetch'); + + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue, + reason: 'disable should queue while the ping is in flight'); + + // The ping resumes and is skipped by the 25m check: no RX window armed. + gps.tooClose = true; + gate.complete(gps.position); + async.flushMicrotasks(); + + expect(ping.pendingDisable, isFalse, + reason: 'a skipped ping must drain the queued disable itself'); + expect(ping.autoPingEnabled, isFalse, + reason: 'auto mode must stop when the skipped ping ends'); + ping.dispose(); + }); + }); + + test('a disable queued during a failed TX send executes immediately', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection()..sendPingThrows = true; + final ping = _buildService(gps, conn) + ..getSessionId = (() => 'OTT-20260829-0001') + ..getNextPingCounter = (() => 1); + + final gate = Completer(); + gps.freshPositionGate = gate; + + ping.enableAutoPing(); + async.flushMicrotasks(); + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + gate.complete(gps.position); + async.flushMicrotasks(); + + expect(ping.pendingDisable, isFalse, + reason: 'a ping whose BLE send failed must drain the disable'); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('passive mode: discovery window completion executes a queued disable', + () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final discGate = Completer(); + conn.discoveryGate = discGate; + final ping = _buildService(gps, conn); + + ping.enableAutoPing(passiveMode: true); + async.flushMicrotasks(); + expect(ping.pingInProgress, isTrue, + reason: 'discovery send should be parked on the BLE write'); + + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + discGate.complete(Uint8List(4)); + async.flushMicrotasks(); + + // The 7s discovery window runs out; well before the 12s backstop. + async.elapse(const Duration(seconds: 8)); + + expect(ping.pendingDisable, isFalse, + reason: 'the discovery window end must drain the queued disable'); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('targeted mode: trace window completion executes a queued disable', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final traceGate = Completer(); + conn.traceGate = traceGate; + final ping = _buildService(gps, conn); + + ping.enableAutoPing(targetedMode: true, targetRepeaterId: '4e'); + async.flushMicrotasks(); + expect(ping.pingInProgress, isTrue, + reason: 'trace send should be parked on the BLE write'); + + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + traceGate.complete(Uint8List(4)); + async.flushMicrotasks(); + + // The 5s trace window runs out; well before the 12s backstop. + async.elapse(const Duration(seconds: 6)); + + expect(ping.pendingDisable, isFalse, + reason: 'the trace window end must drain the queued disable'); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('a successful TX ping still drains the disable at RX window end', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn) + ..getSessionId = (() => 'OTT-20260829-0001') + ..getNextPingCounter = (() => 1); + + final gate = Completer(); + gps.freshPositionGate = gate; + + ping.enableAutoPing(); + async.flushMicrotasks(); + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + gate.complete(gps.position); + async.flushMicrotasks(); + + // Ping transmitted; the RX window is live. The disable must wait for it + // so in-flight echoes are still collected... + expect(ping.pingInProgress, isTrue, + reason: 'RX window should be running after a successful send'); + expect(ping.pendingDisable, isTrue, + reason: 'the disable must NOT preempt a live RX window'); + + // ...and execute when the window ends. + async.elapse(const Duration(seconds: 6)); + + expect(ping.pendingDisable, isFalse); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); +} From 50c5248ded6ed78ead64b6a72726982303527519 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 29 Aug 2026 14:40:48 -0400 Subject: [PATCH 352/360] Serialize coverage overlay edits so a zone change can't double-add --- lib/utils/serial_task_gate.dart | 25 +++++++ lib/widgets/map_widget.dart | 38 +++++++++-- test/utils/serial_task_gate_test.dart | 95 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 lib/utils/serial_task_gate.dart create mode 100644 test/utils/serial_task_gate_test.dart diff --git a/lib/utils/serial_task_gate.dart b/lib/utils/serial_task_gate.dart new file mode 100644 index 00000000..efa0dba0 --- /dev/null +++ b/lib/utils/serial_task_gate.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +/// Runs queued async tasks strictly one at a time, in submission order. +/// +/// MapLibre style mutations (add/remove source and layer) are multi-await +/// sequences guarded by Dart-side "ready" flags. Two flows interleaving +/// between a flag check and the final await double-add or half-remove native +/// objects: the zone-transfer overlay rebuild racing the post-upload patch +/// refresh produced `CannotAddSourceException: Source meshmapper-coverage-patch +/// already exists` at a region boundary (#495). Funnelling every mutating flow +/// through one gate makes each flow atomic with respect to the others. +/// +/// A task's error propagates to its own submitter only; the queue keeps +/// draining behind it. Do NOT await a nested [run] from inside a running task: +/// it is queued after the current task and awaiting it would deadlock. +class SerialTaskGate { + Future _tail = Future.value(); + + /// Queue [task] behind everything already submitted and return its result. + Future run(Future Function() task) { + final result = _tail.then((_) => task()); + _tail = result.then((_) {}, onError: (_) {}); + return result; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index ec6300fd..cdccb85b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -25,6 +25,7 @@ import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import '../utils/repeater_format.dart'; +import '../utils/serial_task_gate.dart'; import 'cell_summary_sheet.dart'; import 'repeater_id_chip.dart'; import 'rx_path_chain.dart'; @@ -599,6 +600,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; bool _patchLayerReady = false; + /// Serializes every coverage overlay/patch style mutation. These flows are + /// multi-await sequences whose ready-flags are checked before the first + /// await; two of them interleaving (the zone-transfer rebuild racing the + /// post-upload patch refresh) double-added the patch source and crashed at + /// a region boundary (#495). Entry points enqueue; the *Locked bodies and + /// the helpers they call must never enqueue, or they deadlock the gate. + final SerialTaskGate _coverageGate = SerialTaskGate(); + // Tap-to-highlight overlay: a fill layer painting the clicked cell's // (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the tapped // tile, in one uniform dominant colour while the coverage backdrop dims @@ -3083,7 +3092,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Add MeshMapper coverage raster overlay as a MapLibre source+layer. /// Allocates fresh suffixed IDs each call to avoid native collisions. - Future _addCoverageOverlay(AppStateProvider appState) async { + /// Gated entry point; see [_coverageGate]. + Future _addCoverageOverlay(AppStateProvider appState) => + _coverageGate.run(() => _addCoverageOverlayLocked(appState)); + + Future _addCoverageOverlayLocked(AppStateProvider appState) async { if (_mapController == null || !_showMeshMapperOverlay) { debugLog( '[MAP] Coverage overlay add skipped: controller=${_mapController != null}, showOverlay=$_showMeshMapperOverlay'); @@ -3103,6 +3116,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final zone = appState.zoneCode!.toLowerCase(); final gridSize = prefs.coverageGridSize; + // Replace, never stack: if an overlay is already up (double-triggered + // add, resume racing a rebuild), tear it down first or the old + // source+layer pair leaks untracked on the map. + if (_activeCoverageSourceId != null) { + await _removeCoverageOverlayLocked(); + } + final sourceId = _nextCoverageSourceId(); final layerId = _coverageLayerIdFor(sourceId); @@ -3289,7 +3309,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Read the palette before any await so context isn't used across an async gap. final cvd = context.read().preferences.colorVisionType; final colors = CoverageTilePalette.colorsForStatus(cvd, st); - if (!await _ensureCellHighlightLayer()) return; + if (!await _coverageGate.run(_ensureCellHighlightLayer)) return; try { // setLayerProperties serializes with skipNulls:false (resets omitted // fields to spec defaults), so resend all three fill props together. @@ -3379,7 +3399,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Remove the active coverage overlay source and layer (if any) and clear /// the tracked IDs. Called by the mapTilesEnabled-toggle teardown and on /// style reload — it does NOT participate in the double-buffer swap path. - Future _removeCoverageOverlay() async { + /// Gated entry point; see [_coverageGate]. + Future _removeCoverageOverlay() => + _coverageGate.run(_removeCoverageOverlayLocked); + + Future _removeCoverageOverlayLocked() async { final layerId = _activeCoverageLayerId; final sourceId = _activeCoverageSourceId; _activeCoverageLayerId = null; @@ -3530,7 +3554,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Push the latest session patch into the map: update the GeoJSON source /// (flash-free, in-place) and extend the base-layer filter. Nothing else on /// the map changes — this is the live-update path for the user's own pings. - Future _applyCoveragePatch(AppStateProvider appState) async { + /// Gated entry point; see [_coverageGate]. + Future _applyCoveragePatch(AppStateProvider appState) => + _coverageGate.run(() => _applyCoveragePatchLocked(appState)); + + Future _applyCoveragePatchLocked(AppStateProvider appState) async { if (_mapController == null) return; final baseLayerId = _activeCoverageLayerId; if (baseLayerId == null) { @@ -3554,6 +3582,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// never as a live-refresh path (that's the session patch's job), so the /// brief remove/add gap is acceptable. Future _refreshCoverageOverlay(AppStateProvider appState) async { + // Two gate entries, not one: another queued op landing between them (a + // patch apply, a toggle) still sees a consistent overlay either way. await _removeCoverageOverlay(); await _addCoverageOverlay(appState); } diff --git a/test/utils/serial_task_gate_test.dart b/test/utils/serial_task_gate_test.dart new file mode 100644 index 00000000..cc3611ed --- /dev/null +++ b/test/utils/serial_task_gate_test.dart @@ -0,0 +1,95 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/serial_task_gate.dart'; + +/// #495: concurrent MapLibre style mutations (zone-transfer overlay rebuild +/// racing the post-upload patch refresh) double-added the coverage patch +/// source. The gate serializes those mutation flows; these tests pin down the +/// semantics the map code relies on. +void main() { + test('tasks run strictly in submission order, never overlapping', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + final events = []; + var running = 0; + var maxConcurrent = 0; + + Future task(String name, Duration length) async { + running++; + if (running > maxConcurrent) maxConcurrent = running; + events.add('$name start'); + await Future.delayed(length); + events.add('$name end'); + running--; + } + + gate.run(() => task('a', const Duration(milliseconds: 30))); + gate.run(() => task('b', const Duration(milliseconds: 10))); + gate.run(() => task('c', const Duration(milliseconds: 20))); + async.elapse(const Duration(milliseconds: 100)); + + expect(maxConcurrent, 1, + reason: 'a queued task must never start before the previous ended'); + expect(events, [ + 'a start', + 'a end', + 'b start', + 'b end', + 'c start', + 'c end', + ]); + }); + }); + + test('a task result is returned to its caller', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + int? result; + gate.run(() async => 42).then((v) => result = v); + async.flushMicrotasks(); + expect(result, 42); + }); + }); + + test('a throwing task fails its own caller but not the queue', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + Object? error; + var laterRan = false; + + gate.run(() async { + throw StateError('boom'); + }).then((_) {}, onError: (Object e) { + error = e; + }); + gate.run(() async { + laterRan = true; + }); + async.flushMicrotasks(); + + expect(error, isA(), + reason: 'the error must reach the task submitter'); + expect(laterRan, isTrue, + reason: 'one failed task must not poison the tasks behind it'); + }); + }); + + test('a task queued from within a running task does not deadlock', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + final events = []; + + gate.run(() async { + events.add('outer start'); + // NOT awaited: awaiting a nested enqueue from inside the gate would + // deadlock by design (it runs only after the current task finishes). + gate.run(() async => events.add('inner')); + await Future.delayed(const Duration(milliseconds: 10)); + events.add('outer end'); + }); + async.elapse(const Duration(milliseconds: 50)); + + expect(events, ['outer start', 'outer end', 'inner']); + }); + }); +} From 322783c06344a4c6f11c30c3e585a6d6245f5519 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 29 Aug 2026 17:34:56 -0400 Subject: [PATCH 353/360] Unstick the GPS arrow when its layer install collides --- lib/utils/map_style_errors.dart | 11 +++++ lib/widgets/map_widget.dart | 59 +++++++++++++++++++++------ test/utils/map_style_errors_test.dart | 36 ++++++++++++++++ 3 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 lib/utils/map_style_errors.dart create mode 100644 test/utils/map_style_errors_test.dart diff --git a/lib/utils/map_style_errors.dart b/lib/utils/map_style_errors.dart new file mode 100644 index 00000000..3e6d1712 --- /dev/null +++ b/lib/utils/map_style_errors.dart @@ -0,0 +1,11 @@ +/// Classifiers for MapLibre style-mutation platform errors. +/// +/// Whether a native style object survived an earlier failed teardown is not +/// observable from Dart; the only signal is the add call throwing "already +/// exists" (Android `CannotAddLayerException` / `CannotAddSourceException`, +/// surfaced as a PlatformException). Install paths use this to ADOPT the +/// existing object instead of reporting failure: reporting failure left the +/// installed-flag false forever, and the still-rendering native layer froze +/// at its last pushed data (#482, the stuck GPS arrow). +bool mapStyleObjectAlreadyExists(Object error) => + error.toString().toLowerCase().contains('already exists'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index cdccb85b..5ae2c29a 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -25,6 +25,7 @@ import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import '../utils/repeater_format.dart'; +import '../utils/map_style_errors.dart'; import '../utils/serial_task_gate.dart'; import 'cell_summary_sheet.dart'; import 'repeater_id_chip.dart'; @@ -604,8 +605,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// multi-await sequences whose ready-flags are checked before the first /// await; two of them interleaving (the zone-transfer rebuild racing the /// post-upload patch refresh) double-added the patch source and crashed at - /// a region boundary (#495). Entry points enqueue; the *Locked bodies and - /// the helpers they call must never enqueue, or they deadlock the gate. + /// a region boundary (#495), and the same class of race froze the GPS puck + /// when its duplicate install failed permanently (#482). Entry points + /// enqueue; the *Locked bodies and the helpers they call must never + /// enqueue, or they deadlock the gate. final SerialTaskGate _coverageGate = SerialTaskGate(); // Tap-to-highlight overlay: a fill layer painting the clicked cell's @@ -1593,7 +1596,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.preferences.gpsMarkerStyle, ); if (gpsVersion != _lastGpsSyncVersion) { - _lastGpsSyncVersion = gpsVersion; + // NOT recorded here: _syncGpsSymbol records the version of what it + // actually pushed. Recording eagerly marked dropped updates (in-flight + // skip, failed install) as applied, and with GpsService's + // distanceFilter there is no periodic tick to paper over the gap, so + // the puck stayed wherever the drop left it until the next real + // movement (#482). WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; if (_gpsSyncInFlight) return; @@ -3034,15 +3042,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // — that double-sync was racing the first sync's symbol refs and // throwing "you can only set existing annotations" errors twice. _lastMarkerDataVersion = _computeMarkerDataVersion(appState); - // Same idea for the GPS-only sync gate: _syncAllAnnotations already - // ran _syncGpsSymbol, so capture the current GPS version to keep the - // next build from scheduling a redundant updateSymbol call. - _lastGpsSyncVersion = Object.hash( - appState.currentPosition?.latitude, - appState.currentPosition?.longitude, - _computedHeading, - appState.preferences.gpsMarkerStyle, - ); + // The GPS-only sync gate needs no capture here: _syncAllAnnotations + // ran _syncGpsSymbol, which records the version itself when its push + // lands (and deliberately doesn't when it bails, so the build-driven + // sync retries). if (mounted) setState(() {}); } } finally { @@ -4781,6 +4784,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController! .setGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + _recordGpsSyncVersion(appState, null); } catch (e) { debugError('[MAP] clear gps puck failed: $e'); } @@ -4799,11 +4803,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _buildGpsPuckFeatureCollection( pos.latitude, pos.longitude, style, iconRotate), ); + _recordGpsSyncVersion(appState, pos); } catch (e) { debugError('[MAP] update gps puck failed: $e'); } } + /// Mark the puck as in sync with [pos] (null = cleared). Only called once a + /// push has actually landed; a bailed or failed sync leaves the version + /// unrecorded so the next provider notify retries it (#482). + void _recordGpsSyncVersion(AppStateProvider appState, Position? pos) { + _lastGpsSyncVersion = Object.hash( + pos?.latitude, + pos?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + } + /// One-Point FeatureCollection for the GPS puck source. `iconImage` and /// `iconRotate` are read by the puck symbol layer's data-driven expressions. Map _buildGpsPuckFeatureCollection( @@ -4833,7 +4850,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// order, with no symbol-sort-key contention. enableInteraction:false so taps /// pass through to the repeaters/clusters underneath. Mirrors /// [_ensureCoverageLinesLayer]. - Future _ensureGpsPuckLayer() async { + /// Gated entry point; see [_coverageGate]. + Future _ensureGpsPuckLayer() => + _coverageGate.run(_ensureGpsPuckLayerLocked); + + Future _ensureGpsPuckLayerLocked() async { if (_gpsPuckLayerInstalled) return true; if (_mapController == null) return false; try { @@ -4860,6 +4881,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _gpsPuckLayerInstalled = true; return true; } catch (e) { + // "Already exists" means a previous install's native objects survived a + // silently-failed teardown. Adopt them: the layer's paint properties are + // constant data-driven expressions, so the existing layer is identical + // to the one this add would have created, and the caller's + // setGeoJsonSource repositions it. Reporting failure here left the + // installed-flag false for the life of the map session while the stale + // layer kept rendering the last pushed position (#482). + if (mapStyleObjectAlreadyExists(e)) { + debugWarn('[MAP] gps-puck layer already present, adopting it: $e'); + _gpsPuckLayerInstalled = true; + return true; + } debugError('[MAP] gps-puck layer create failed: $e'); return false; } diff --git a/test/utils/map_style_errors_test.dart b/test/utils/map_style_errors_test.dart new file mode 100644 index 00000000..2640bcdf --- /dev/null +++ b/test/utils/map_style_errors_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/map_style_errors.dart'; + +/// #482: a duplicate-install race left the native gps-puck layer alive while +/// the Dart-side "installed" flag stayed false, so every retry re-threw +/// "already exists" and the puck was never repositioned again. The classifier +/// lets install paths adopt the existing native object instead of failing. +void main() { + test('recognises the Android CannotAddLayerException shape', () { + final e = PlatformException( + code: 'error', + message: 'Layer gps-puck-layer already exists', + details: 'org.maplibre.android.style.layers.CannotAddLayerException', + ); + expect(mapStyleObjectAlreadyExists(e), isTrue); + }); + + test('recognises the Android CannotAddSourceException shape', () { + final e = PlatformException( + code: 'error', + message: 'Source meshmapper-coverage-patch already exists', + details: 'org.maplibre.android.style.sources.CannotAddSourceException', + ); + expect(mapStyleObjectAlreadyExists(e), isTrue); + }); + + test('does not match unrelated platform errors', () { + final e = PlatformException( + code: 'error', + message: 'Style is not fully loaded', + ); + expect(mapStyleObjectAlreadyExists(e), isFalse); + expect(mapStyleObjectAlreadyExists(StateError('boom')), isFalse); + }); +} From 21392a29547aa0f61944573a5a7b455c0466c12b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 30 Aug 2026 07:34:43 -0400 Subject: [PATCH 354/360] Retry more BLE connect failures and re-run a dying handshake --- lib/providers/app_state_provider.dart | 234 +++++++++++------- .../bluetooth/ble_connect_retry_policy.dart | 84 +++++++ lib/services/bluetooth/mobile_bluetooth.dart | 99 ++++---- .../ble_connect_retry_policy_test.dart | 170 +++++++++++++ 4 files changed, 452 insertions(+), 135 deletions(-) create mode 100644 lib/services/bluetooth/ble_connect_retry_policy.dart create mode 100644 test/services/bluetooth/ble_connect_retry_policy_test.dart diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b83c44de..be38090f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -29,6 +29,7 @@ import '../services/audio_service.dart'; import '../services/background_service.dart'; import '../services/debug_file_logger.dart'; import '../services/offline_session_service.dart'; +import '../services/bluetooth/ble_connect_retry_policy.dart'; import '../services/bluetooth/bluetooth_service.dart'; import '../services/transport/android_serial_service.dart'; import '../services/transport/companion_transport.dart'; @@ -3869,103 +3870,154 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAuthError = false; _isNetworkError = false; notifyListeners(); - try { - // Clean up any previous connection first - if (_meshCoreConnection != null) { - debugLog('[APP] Disposing previous MeshCoreConnection'); - _meshCoreConnection!.dispose(); - _meshCoreConnection = null; - } - // ALWAYS START FRESH - clear any stale pings before connecting - await _apiQueueService.clearBeforeConnect(); + // #500: a handshake that dies right after the transport connects gets one + // automatic re-run of the whole workflow, the same thing the manual re-tap + // that users report always works would do. + var workflowRerunUsed = false; + while (true) { + DateTime? transportConnectedAt; + try { + // Clean up any previous connection first + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } - debugLog('[APP] Connecting BLE transport to ${device.id}'); - await _bluetoothService.connect(device.id); - _activeTransport = _bluetoothService; - debugLog('[APP] Creating new MeshCoreConnection'); - _meshCoreConnection = MeshCoreConnection(transport: _bluetoothService); + // ALWAYS START FRESH - clear any stale pings before connecting + await _apiQueueService.clearBeforeConnect(); - if (!_preferences.offlineMode) { - _meshCoreConnection!.onRequestAuth = _createAuthCallback(); - } else { - _meshCoreConnection!.onRequestAuth = null; - debugLog('[APP] Offline mode: skipping API auth'); - } + debugLog('[APP] Connecting BLE transport to ${device.id}'); + await _bluetoothService.connect(device.id); + transportConnectedAt = DateTime.now(); + _activeTransport = _bluetoothService; + debugLog('[APP] Creating new MeshCoreConnection'); + _meshCoreConnection = MeshCoreConnection(transport: _bluetoothService); - // Listen for step changes - _meshCoreConnection!.stepStream.listen((step) { - _connectionStep = step; - if (step == ConnectionStep.connected) { - // Update device info - _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = - _meshCoreConnection!.deviceInfo?.firmwareVersionString; - _deviceModel = _meshCoreConnection!.deviceModel; - _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog( - '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - // Persist device info for bug reports when disconnected - // Use original name (not "Anonymous") for bug report identification - var lastDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); - if (lastDeviceName != null) { - lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); - } - // Cascade guard: never persist "Anonymous" as the last connected device - if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { - lastDeviceName = - _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + // Listen for step changes + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + // Update device info + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + + // Persist device info for bug reports when disconnected + // Use original name (not "Anonymous") for bug report identification + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); + } + + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } } - if (lastDeviceName != null && - lastDeviceName.isNotEmpty && - _devicePublicKey != null) { - _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); + notifyListeners(); + }); + + // Listen for noise floor updates — only rebuild UI when value changes + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); } + }); - // In offline mode, fetch signed contact URI for later registration during upload - if (_preferences.offlineMode && _meshCoreConnection != null) { - _meshCoreConnection!.exportContact().then((uri) { - _offlineContactUri = uri; - debugLog('[OFFLINE] Stored contact URI for offline session'); - }).catchError((e) { - debugWarn('[OFFLINE] Failed to get contact URI: $e'); - }); + // Listen for battery updates — only rebuild UI when value changes + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); } - } - notifyListeners(); - }); + }); - // Listen for noise floor updates — only rebuild UI when value changes - _noiseFloorSubscription = - _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { - _recordNoiseFloorSample(noiseFloor); - if (noiseFloor != _currentNoiseFloor) { - _currentNoiseFloor = noiseFloor; - notifyListeners(); - } - }); + // Execute connection workflow (transport already connected above) + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); - // Listen for battery updates — only rebuild UI when value changes - _batterySubscription = - _meshCoreConnection!.batteryStream.listen((batteryPercent) { - if (batteryPercent != _currentBatteryPercent) { - _currentBatteryPercent = batteryPercent; - notifyListeners(); + await _postConnectionSetup(connectionResult, device); + _isConnecting = false; + return; + } catch (e) { + final sinceTransportConnect = transportConnectedAt == null + ? null + : DateTime.now().difference(transportConnectedAt); + if (!shouldRerunConnectionWorkflow( + transportConnected: transportConnectedAt != null, + sinceTransportConnect: sinceTransportConnect, + errorString: e.toString(), + isAutoReconnecting: _isAutoReconnecting, + userRequestedDisconnect: _userRequestedDisconnect, + alreadyRerun: workflowRerunUsed, + )) { + await _handleConnectionError(e); + return; } - }); - - // Execute connection workflow (transport already connected above) - final connectionResult = await _meshCoreConnection!.connect( - _deviceModelService.models, - ); - - await _postConnectionSetup(connectionResult, device); - _isConnecting = false; - } catch (e) { - await _handleConnectionError(e); + workflowRerunUsed = true; + debugLog( + '[CONN] Handshake died ${sinceTransportConnect!.inSeconds}s after transport connect, re-running connection workflow once: $e'); + // The failed run's transport disconnect emits a 'disconnected' event + // whose listener starts _fullDisconnectCleanup. That event was queued + // before this timer, and microtasks drain before timers fire, so after + // the delay the tracked future (if any) is the cleanup for THIS + // failure. Waiting it out means the cleanup cannot tear down the + // re-run's fresh connection. + await Future.delayed(const Duration(milliseconds: 100)); + final cleanupInFlight = _fullCleanupInFlight; + if (cleanupInFlight != null) { + await cleanupInFlight; + } + if (_isConnecting) { + // The cleanup reset this flag; true means a newer connection attempt + // started while waiting. Let it own the transport. + debugLog( + '[CONN] Skipping workflow re-run: another connection is in progress'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + // The disconnect cleanup cleared the BLE scan cache; restore the + // device name for the re-run. + _bluetoothService.cacheDeviceInfo(device); + notifyListeners(); + } } } @@ -5498,7 +5550,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pendingPathHashWarning = null; } - Future _fullDisconnectCleanup() async { + /// In-flight full-cleanup future so a connection workflow re-run (#500) + /// can wait for the disconnect-event cleanup to settle before reconnecting. + Future? _fullCleanupInFlight; + + Future _fullDisconnectCleanup() { + final cleanup = _fullDisconnectCleanupImpl(); + _fullCleanupInFlight = cleanup; + return cleanup; + } + + Future _fullDisconnectCleanupImpl() async { _finishLiveActivitySession(); // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) if (_connectionStep == ConnectionStep.disconnected) { diff --git a/lib/services/bluetooth/ble_connect_retry_policy.dart b/lib/services/bluetooth/ble_connect_retry_policy.dart new file mode 100644 index 00000000..dc2a0f59 --- /dev/null +++ b/lib/services/bluetooth/ble_connect_retry_policy.dart @@ -0,0 +1,84 @@ +/// Pure retry-policy decisions for BLE connection failures (#500). +/// +/// connect() used to retry only Android error 133, so the 15s GATT connect +/// timeout, transient GATT errors and service discovery failures all gave up +/// on attempt 1 of 3, and users re-tapped Connect to do the loop's work. +/// These decisions are pure functions so the policy is unit-testable without +/// flutter_blue_plus. +library; + +/// What connect() should do with a failed attempt. +enum BleConnectFailureAction { + /// Transient failure: clean up and run the next attempt. + retry, + + /// iOS stale-bond error (apple-code 14/15). Internal retries all fail the + /// same way and each hangs for the full connect timeout, so abort now and + /// let the auto-reconnect system apply a longer delay. + abortForBondError, + + /// Attempts exhausted: surface the error to the caller. + fail, +} + +/// Decide how connect() handles a failed attempt. +/// +/// Every failure is retryable while attempts remain, except iOS bond errors +/// which abort immediately (stale keys cannot be cleared programmatically). +BleConnectFailureAction classifyBleConnectFailure({ + required String errorString, + required bool isIos, + required int attempt, + required int maxRetries, +}) { + final isBondError = isIos && + (errorString.contains('apple-code: 14') || + errorString.contains('apple-code: 15') || + errorString.contains('Peer removed pairing information')); + if (isBondError) { + return BleConnectFailureAction.abortForBondError; + } + if (attempt < maxRetries) { + return BleConnectFailureAction.retry; + } + return BleConnectFailureAction.fail; +} + +/// How soon after a successful transport connect a workflow death still counts +/// as "the link died right after connecting". 20s covers a single 15s +/// writeCharacteristic timeout on the first protocol command. +const Duration handshakeRerunWindow = Duration(seconds: 20); + +/// Decide whether connectToDevice() re-runs the 9-step connection workflow +/// after it died shortly after a successful transport connect. +/// +/// Deliberately excluded: +/// - server auth verdicts (AUTH_FAILED:) are deterministic, a re-run just +/// hits the server again with the same answer; +/// - auto-reconnect already re-runs the whole workflow per attempt; +/// - a disposed connection means something else tore the attempt down. +bool shouldRerunConnectionWorkflow({ + required bool transportConnected, + required Duration? sinceTransportConnect, + required String errorString, + required bool isAutoReconnecting, + required bool userRequestedDisconnect, + required bool alreadyRerun, +}) { + if (alreadyRerun || isAutoReconnecting || userRequestedDisconnect) { + return false; + } + if (!transportConnected || sinceTransportConnect == null) { + return false; + } + if (sinceTransportConnect > handshakeRerunWindow) { + return false; + } + if (errorString.contains('AUTH_FAILED:')) { + return false; + } + if (errorString.contains('has been disposed')) { + return false; + } + return true; +} diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index bb32bc37..a108ffcd 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -9,12 +9,13 @@ import 'package:permission_handler/permission_handler.dart'; import '../../models/connection_state.dart'; import '../../utils/debug_logger_io.dart'; import '../meshcore/protocol_constants.dart'; +import 'ble_connect_retry_policy.dart'; import 'bluetooth_service.dart'; /// Mobile Bluetooth implementation using flutter_blue_plus /// For Android and iOS platforms class MobileBluetoothService implements BluetoothService { - // Retry constants for Android BLE error 133 (GATT_ERROR) + // Retry constants for transient connect failures (see ble_connect_retry_policy.dart) static const int _maxRetries = 3; static const Duration _retryDelay = Duration(milliseconds: 500); @@ -422,57 +423,37 @@ class MobileBluetoothService implements BluetoothService { _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop } catch (e, stackTrace) { - final errorStr = e.toString(); - - // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue - // that typically succeeds on retry - final isError133 = - Platform.isAndroid && errorStr.contains('android-code: 133'); - - // Check for iOS apple-code 14 (Peer removed pairing information) or - // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys - final isBondError = Platform.isIOS && - (errorStr.contains('apple-code: 14') || - errorStr.contains('apple-code: 15') || - errorStr.contains('Peer removed pairing information')); - - if ((isError133 || isBondError) && attempt < _maxRetries) { - if (isBondError) { - debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt'); - await removeBond(deviceId); - // On iOS, removeBond() is a no-op (not supported by Core Bluetooth). - // Don't burn internal retries against stale bond keys — they will all - // fail the same way and each hangs for the full connect timeout. - // Rethrow immediately so the auto-reconnect system can apply a longer - // delay (giving iOS time to resolve) or inform the user. - debugWarn( - '[BLE] iOS bond error — skipping internal retries (stale keys cannot be cleared programmatically)'); - try { - await _bleDevice?.disconnect(); - } catch (_) {} - _bleDevice = null; - _rxCharacteristic = null; - _txCharacteristic = null; - _updateStatus(ConnectionStatus.error); - rethrow; - } else { - debugLog( - '[BLE] Error 133 on attempt $attempt, retrying after delay...'); - await Future.delayed(_retryDelay); - } - // Force cleanup before retry - try { - await _bleDevice?.disconnect(); - } catch (e) { - debugWarn('[BLE] Failed to disconnect before retry: $e'); - } - _bleDevice = null; - _rxCharacteristic = null; - _txCharacteristic = null; + final action = classifyBleConnectFailure( + errorString: e.toString(), + isIos: Platform.isIOS, + attempt: attempt, + maxRetries: _maxRetries, + ); + + if (action == BleConnectFailureAction.abortForBondError) { + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt'); + await removeBond(deviceId); + // On iOS, removeBond() is a no-op (not supported by Core Bluetooth). + // Don't burn internal retries against stale bond keys: they will all + // fail the same way and each hangs for the full connect timeout. + // Rethrow immediately so the auto-reconnect system can apply a longer + // delay (giving iOS time to resolve) or inform the user. + debugWarn( + '[BLE] iOS bond error, skipping internal retries (stale keys cannot be cleared programmatically)'); + await _cleanupFailedAttempt(); + _updateStatus(ConnectionStatus.error); + rethrow; + } + + if (action == BleConnectFailureAction.retry) { + debugLog( + '[BLE] Attempt $attempt failed, retrying after delay... ($e)'); + await _cleanupFailedAttempt(); + await Future.delayed(_retryDelay); continue; // Retry } - // Final attempt failed or non-retryable error + // Final attempt failed debugLog('[BLE] Connection error: $e'); debugLog('[BLE] Stack trace: $stackTrace'); _updateStatus(ConnectionStatus.error); @@ -481,6 +462,26 @@ class MobileBluetoothService implements BluetoothService { } } + /// Tear down a failed connect attempt without emitting a disconnected event. + /// The state subscription is cancelled BEFORE the disconnect: attempts that + /// fail after the GATT link is up (discovery, notifications) have the + /// listener installed, and letting it fire _handleDisconnection would emit + /// 'disconnected' and start full provider cleanup mid-retry. + Future _cleanupFailedAttempt() async { + await _connectionStateSubscription?.cancel(); + _connectionStateSubscription = null; + await _notificationSubscription?.cancel(); + _notificationSubscription = null; + try { + await _bleDevice?.disconnect(); + } catch (e) { + debugWarn('[BLE] Failed to disconnect failed attempt: $e'); + } + _bleDevice = null; + _rxCharacteristic = null; + _txCharacteristic = null; + } + void _handleDisconnection() { // Guard against double-disconnect: when disconnect() is called, the BLE // connectionState listener fires first, then the finally block also calls diff --git a/test/services/bluetooth/ble_connect_retry_policy_test.dart b/test/services/bluetooth/ble_connect_retry_policy_test.dart new file mode 100644 index 00000000..fc6b3510 --- /dev/null +++ b/test/services/bluetooth/ble_connect_retry_policy_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/bluetooth/ble_connect_retry_policy.dart'; + +/// #500: connect() only treated Android error 133 as retryable, so the 15s +/// GATT connect timeout, transient GATT errors and service discovery failures +/// all rethrew on attempt 1 of 3, and a handshake that died right after the +/// transport connected aborted the whole connection. Users had to tap Connect +/// 4-7 times to do the work the retry loop was supposed to do. +void main() { + group('classifyBleConnectFailure', () { + BleConnectFailureAction classify(String error, + {bool isIos = false, int attempt = 1, int maxRetries = 3}) { + return classifyBleConnectFailure( + errorString: error, + isIos: isIos, + attempt: attempt, + maxRetries: maxRetries, + ); + } + + test('retries the 15s GATT connect timeout', () { + expect( + classify( + 'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s'), + BleConnectFailureAction.retry, + ); + }); + + test('still retries Android error 133', () { + expect( + classify( + 'FlutterBluePlusException | connect | android-code: 133 | GATT_ERROR'), + BleConnectFailureAction.retry, + ); + }); + + test('retries transient GATT errors from setNotifyValue', () { + expect( + classify( + 'FlutterBluePlusException | setNotifyValue | android-code: 28 | UNKNOWN_GATT_ERROR', + attempt: 2), + BleConnectFailureAction.retry, + ); + }); + + test('retries a device drop during setup', () { + expect( + classify( + 'FlutterBluePlusException | discoverServices | fbp-code: 6 | Device is disconnected'), + BleConnectFailureAction.retry, + ); + }); + + test('retries service discovery failures', () { + expect( + classify('Exception: MeshCore service not found'), + BleConnectFailureAction.retry, + ); + }); + + test('fails once attempts are exhausted', () { + expect( + classify( + 'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s', + attempt: 3), + BleConnectFailureAction.fail, + ); + }); + + test('aborts on iOS bond error apple-code 14 without burning retries', () { + expect( + classify( + 'FlutterBluePlusException | connect | apple-code: 14 | Peer removed pairing information', + isIos: true), + BleConnectFailureAction.abortForBondError, + ); + }); + + test('aborts on iOS bond error apple-code 15', () { + expect( + classify( + 'FlutterBluePlusException | connect | apple-code: 15 | Failed to encrypt the connection', + isIos: true), + BleConnectFailureAction.abortForBondError, + ); + }); + + test('bond error strings on Android are plain retryable errors', () { + expect( + classify('Exception: apple-code: 14', isIos: false), + BleConnectFailureAction.retry, + ); + }); + }); + + group('shouldRerunConnectionWorkflow', () { + bool rerun({ + bool transportConnected = true, + Duration? sinceTransportConnect = const Duration(seconds: 3), + String error = 'TimeoutException: Device query timed out', + bool isAutoReconnecting = false, + bool userRequestedDisconnect = false, + bool alreadyRerun = false, + }) { + return shouldRerunConnectionWorkflow( + transportConnected: transportConnected, + sinceTransportConnect: sinceTransportConnect, + errorString: error, + isAutoReconnecting: isAutoReconnecting, + userRequestedDisconnect: userRequestedDisconnect, + alreadyRerun: alreadyRerun, + ); + } + + test('reruns when the handshake dies right after transport connect', () { + expect(rerun(), isTrue); + }); + + test('covers a 15s writeCharacteristic timeout inside the window', () { + expect( + rerun( + sinceTransportConnect: const Duration(seconds: 16), + error: + 'FlutterBluePlusException | writeCharacteristic | fbp-code: 1 | Timed out after 15s', + ), + isTrue, + ); + }); + + test('does not rerun long after the transport connected', () { + expect( + rerun(sinceTransportConnect: const Duration(seconds: 30)), + isFalse, + ); + }); + + test('does not rerun when the transport never connected', () { + expect( + rerun(transportConnected: false, sinceTransportConnect: null), + isFalse, + ); + }); + + test('never reruns a server auth rejection', () { + expect( + rerun(error: 'Exception: AUTH_FAILED:zone_full:Zone is full'), + isFalse, + ); + }); + + test('never reruns a disposed connection', () { + expect( + rerun(error: 'Exception: Connection instance has been disposed'), + isFalse, + ); + }); + + test('reruns only once', () { + expect(rerun(alreadyRerun: true), isFalse); + }); + + test('defers to the auto-reconnect machinery', () { + expect(rerun(isAutoReconnecting: true), isFalse); + }); + + test('does not rerun after a user-requested disconnect', () { + expect(rerun(userRequestedDisconnect: true), isFalse); + }); + }); +} From d2a179557ee38bb345bf9dd1f6e9f278faab8995 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 30 Aug 2026 07:39:12 -0400 Subject: [PATCH 355/360] Anchor the distance readout to the last ping of any type --- lib/providers/app_state_provider.dart | 5 +- lib/services/gps_service.dart | 29 ++- lib/services/ping_service.dart | 8 + test/services/gps_service_anchor_test.dart | 53 ++++++ .../ping_service_activity_anchor_test.dart | 179 ++++++++++++++++++ 5 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 test/services/gps_service_anchor_test.dart create mode 100644 test/services/ping_service_activity_anchor_test.dart diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index be38090f..4e99de5b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1348,10 +1348,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int get offlinePingCount => _apiQueueService.offlinePingCount; OfflineSessionService get offlineSessionService => _offlineSessionService; - /// Distance in meters from last TX ping position (like wardrive.js) + /// Distance in meters from the last ping of any type (TX, discovery, + /// trace). Display only; the TX skip logic reads its own anchor (#501). double? get distanceFromLastPing { if (_currentPosition == null) return null; - final dist = _gpsService.distanceFromLastPing(_currentPosition!); + final dist = _gpsService.distanceFromLastActivity(_currentPosition!); return dist == double.infinity ? null : dist; } diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 4167116f..a90873d1 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -49,6 +49,13 @@ class GpsService { GpsStatus _status = GpsStatus.permissionDenied; Position? _lastPosition; Position? _lastPingPosition; + + /// Last ping of ANY type (TX, discovery, trace) for the map's + /// distance-to-previous-ping readout (#501). Kept separate from + /// _lastPingPosition, the TX skip anchor: feeding discovery/trace positions + /// into that would change canPingAtPosition() and let a Hybrid discovery + /// skip the following TX ping at walking pace. + Position? _lastActivityPosition; StreamSubscription? _positionSubscription; /// GPS Simulator for testing @@ -315,9 +322,29 @@ class GpsService { return distanceFromLastPing(position) >= _configuredMinDistance; } - /// Mark current position as ping location + /// Mark current position as TX ping location (skip anchor + readout) void markPingPosition(Position position) { _lastPingPosition = position; + _lastActivityPosition = position; + } + + /// Mark a non-TX ping (discovery, trace) for the distance readout only. + /// Does NOT move the TX skip anchor used by canPingAtPosition(). + void markActivityPosition(Position position) { + _lastActivityPosition = position; + } + + /// Calculate distance from the last ping of any type, for display + double distanceFromLastActivity(Position current) { + if (_lastActivityPosition == null) { + return double.infinity; + } + return Geolocator.distanceBetween( + _lastActivityPosition!.latitude, + _lastActivityPosition!.longitude, + current.latitude, + current.longitude, + ); } /// Check if GPS position is fresh enough for manual pings (< 60s old) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 44c014d6..d4f1efc0 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1584,6 +1584,10 @@ class PingService { // Update last discovery position for 25m check _lastDiscoveryPosition = position; + + // Follow with the display anchor so the map's distance readout tracks + // discovery pings too, not just TX (#501) + _gpsService.markActivityPosition(position); } catch (e) { _pingInProgress = false; debugError('[DISC] Failed to send discovery request: $e'); @@ -1900,6 +1904,10 @@ class PingService { // Update last targeted position for 25m check _lastTargetedPosition = position; + + // Follow with the display anchor so the map's distance readout tracks + // traces too, not just TX (#501) + _gpsService.markActivityPosition(position); } catch (e) { _pingInProgress = false; debugError('[TRACE] Failed to send trace: $e'); diff --git a/test/services/gps_service_anchor_test.dart b/test/services/gps_service_anchor_test.dart new file mode 100644 index 00000000..94230f34 --- /dev/null +++ b/test/services/gps_service_anchor_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mesh_mapper/services/gps_service.dart'; + +/// #501: the map's distance-to-previous-ping readout read the TX skip anchor, +/// which only TX pings update. In Trace and Passive modes it stayed anchored +/// to the last TX (typically the session start) and showed kilometers while +/// the real previous trace was 100 m away. The display now follows a separate +/// any-ping anchor; the TX skip anchor is untouched so feeding discovery and +/// trace positions into the readout cannot skip Hybrid TX pings. +Position _pos(double lat, double lon) => Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 0.0, + altitudeAccuracy: 1.0, + heading: 0.0, + headingAccuracy: 1.0, + speed: 0.0, + speedAccuracy: 1.0, + ); + +void main() { + test('no ping of any type yet reports infinity', () { + final gps = GpsService(); + expect(gps.distanceFromLastActivity(_pos(45.0, -75.0)), double.infinity); + }); + + test('a TX ping moves both the display and the skip anchor', () { + final gps = GpsService(); + final p0 = _pos(45.0, -75.0); + gps.markPingPosition(p0); + expect(gps.distanceFromLastActivity(p0), 0.0); + expect(gps.distanceFromLastPing(p0), 0.0); + }); + + test('a discovery or trace moves the display anchor only', () { + final gps = GpsService(); + final txPos = _pos(45.0, -75.0); + final tracePos = _pos(45.01, -75.0); // ~1.1 km north + gps.markPingPosition(txPos); + gps.markActivityPosition(tracePos); + + // Readout follows the trace. + expect(gps.distanceFromLastActivity(tracePos), 0.0); + + // TX skip logic still measures from the last TX ping. + expect(gps.distanceFromLastPing(tracePos), greaterThan(1000)); + expect(gps.canPingAtPosition(tracePos), isTrue); + expect(gps.canPingAtPosition(txPos), isFalse); + }); +} diff --git a/test/services/ping_service_activity_anchor_test.dart b/test/services/ping_service_activity_anchor_test.dart new file mode 100644 index 00000000..c882cbef --- /dev/null +++ b/test/services/ping_service_activity_anchor_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mesh_mapper/models/connection_state.dart'; +import 'package:mesh_mapper/models/device_model.dart'; +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/countdown_timer_service.dart'; +import 'package:mesh_mapper/services/gps_service.dart'; +import 'package:mesh_mapper/services/meshcore/connection.dart'; +import 'package:mesh_mapper/services/ping_service.dart'; +import 'package:mesh_mapper/services/wakelock_service.dart'; + +/// #501: only the TX path updated the shared display anchor, so the map's +/// distance-to-previous-ping readout stayed at the session start in Trace and +/// Passive modes. Discovery and trace sends must mark the display-only +/// activity anchor (and must NOT touch the TX skip anchor). + +class _FakeGps implements GpsService { + Position? position; + final List activityMarks = []; + final List pingMarks = []; + + @override + GpsStatus get status => GpsStatus.locked; + + @override + Position? get lastPosition => position; + + @override + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) => + Future.value(position); + + @override + bool isAccuracyAcceptableForPing(Position position) => true; + + @override + bool canPingAtPosition(Position position) => true; + + @override + double get configuredMinDistance => 25.0; + + @override + void markPingPosition(Position position) => pingMarks.add(position); + + @override + void markActivityPosition(Position position) => activityMarks.add(position); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('GpsService.${invocation.memberName}'); +} + +class _FakeConnection implements MeshCoreConnection { + @override + ConnectionStep get currentStep => ConnectionStep.connected; + + @override + DeviceModel? get deviceModel => null; + + @override + int? get lastNoiseFloor => null; + + @override + int? get wardrivingChannelIndex => null; + + @override + Uint8List? get wardrivingChannelKey => null; + + @override + int? get wardrivingChannelHash => null; + + @override + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + const Stream.empty(); + + @override + Stream get traceDataStream => const Stream.empty(); + + @override + Future sendDiscoveryRequest() => Future.value(Uint8List(4)); + + @override + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) => + Future.value(Uint8List(4)); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('MeshCoreConnection.${invocation.memberName}'); +} + +class _FakeApiQueue implements ApiQueueService { + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.memberName.toString().contains('enqueue')) { + return Future.value(); + } + throw UnimplementedError('ApiQueueService.${invocation.memberName}'); + } +} + +class _FakeWakelock implements WakelockService { + @override + bool get isEnabled => false; + + @override + Future enable() async {} + + @override + Future disable() async {} + + @override + Future dispose() async {} +} + +Position _pos(double lat, double lon) => Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 0.0, + altitudeAccuracy: 1.0, + heading: 0.0, + headingAccuracy: 1.0, + speed: 0.0, + speedAccuracy: 1.0, + ); + +PingService _buildService(_FakeGps gps, _FakeConnection conn) => PingService( + gpsService: gps, + connection: conn, + apiQueue: _FakeApiQueue(), + wakelockService: _FakeWakelock(), + cooldownTimer: CooldownTimer(), + manualPingCooldownTimer: ManualPingCooldownTimer(), + rxWindowTimer: RxWindowTimer(), + discoveryWindowTimer: DiscoveryWindowTimer(), + deviceId: 'TEST', + ); + +void main() { + test('a successful discovery send marks the display anchor', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn); + + ping.enableAutoPing(passiveMode: true); + async.flushMicrotasks(); + + expect(gps.activityMarks, [gps.position], + reason: 'the readout must follow the discovery position'); + expect(gps.pingMarks, isEmpty, + reason: 'a discovery must not move the TX skip anchor'); + ping.dispose(); + }); + }); + + test('a successful trace send marks the display anchor', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn); + + ping.enableAutoPing(targetedMode: true, targetRepeaterId: '4e'); + async.flushMicrotasks(); + + expect(gps.activityMarks, [gps.position], + reason: 'the readout must follow the trace position'); + expect(gps.pingMarks, isEmpty, + reason: 'a trace must not move the TX skip anchor'); + ping.dispose(); + }); + }); +} From f2bf51b54a1189712b24d0425c7453a44a08fc34 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 30 Aug 2026 08:15:21 -0400 Subject: [PATCH 356/360] Keep a throwing provider callback from escaping the disable drain --- lib/services/ping_service.dart | 11 +++++-- .../ping_service_pending_disable_test.dart | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index d4f1efc0..bbeff298 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1338,8 +1338,15 @@ class PingService { // Start cooldown immediately _cooldownTimer.start(_autoPingCooldown.inMilliseconds); debugLog('[PING] Pending disable complete, cooldown started'); - // Notify AppStateProvider to update its state and cleanup - await onPendingDisableComplete?.call(); + // Notify AppStateProvider to update its state and cleanup. Several callers + // run from void tracker callbacks or timer-driven send paths where nothing + // awaits this method, so an escaping error here would go unhandled. The + // local teardown above is already done by this point. + try { + await onPendingDisableComplete?.call(); + } catch (e) { + debugError('[PING] Pending disable provider cleanup failed: $e'); + } } /// Guarantee a parked disable is drained even when the RX window that was diff --git a/test/services/ping_service_pending_disable_test.dart b/test/services/ping_service_pending_disable_test.dart index 3e02ec7b..4f8efbad 100644 --- a/test/services/ping_service_pending_disable_test.dart +++ b/test/services/ping_service_pending_disable_test.dart @@ -14,7 +14,7 @@ import 'package:mesh_mapper/services/ping_service.dart'; import 'package:mesh_mapper/services/wakelock_service.dart'; /// #496: a disable queued while a ping is in flight must be executed by -/// whatever ends that ping's lifecycle — skip, failure, or listening window — +/// whatever ends that ping's lifecycle (skip, failure, or listening window), /// never left for the 12s timeout backstop (or, on v1.3.0, the next ping). /// /// The production capture: the user's stop landed during an auto ping's GPS @@ -274,6 +274,35 @@ void main() { }); }); + test('a throwing provider callback cannot escape the disable drain', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final discGate = Completer(); + conn.discoveryGate = discGate; + final ping = _buildService(gps, conn) + ..onPendingDisableComplete = + (() async => throw Exception('provider teardown failed')); + + ping.enableAutoPing(passiveMode: true); + async.flushMicrotasks(); + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + discGate.complete(Uint8List(4)); + async.flushMicrotasks(); + + // The window-complete drain runs from a void tracker callback: nothing + // awaits it, so an escaping error would be an unhandled async error. + async.elapse(const Duration(seconds: 8)); + + expect(ping.pendingDisable, isFalse); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + test('a successful TX ping still drains the disable at RX window end', () { fakeAsync((async) { final gps = _FakeGps()..position = _pos(45.0, -75.0); From 20b55ec13490888c0956bef1acb2b4c0ab98059d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 30 Aug 2026 08:15:21 -0400 Subject: [PATCH 357/360] Harden the connect retry against overlap and swallowed failures --- lib/providers/app_state_provider.dart | 44 ++++++++++++++++--- .../bluetooth/ble_connect_retry_policy.dart | 3 ++ lib/services/bluetooth/bluetooth_service.dart | 9 +++- lib/services/bluetooth/mobile_bluetooth.dart | 26 +++++++++-- lib/services/bluetooth/web_bluetooth.dart | 3 +- .../ble_connect_retry_policy_test.dart | 7 +++ 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4e99de5b..80280de4 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -443,6 +443,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Auto-reconnect state bool _userRequestedDisconnect = false; + + /// Bumped by every transport connect entry point. A connection attempt that + /// finds its captured generation stale has been superseded by a newer + /// attempt and must not touch shared provider state (#500 review). + int _connectGeneration = 0; bool _isAutoReconnecting = false; // ============================================ @@ -3866,6 +3871,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _isConnecting = true; + final myGeneration = ++_connectGeneration; _connectionStep = ConnectionStep.transportConnecting; _connectionError = null; _isAuthError = false; @@ -3890,7 +3896,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _apiQueueService.clearBeforeConnect(); debugLog('[APP] Connecting BLE transport to ${device.id}'); - await _bluetoothService.connect(device.id); + // During auto-reconnect the internal retry loop is capped to one + // attempt: the reconnect ladder retries on its own, and a 3x15s + // internal loop would eat the whole 30s reconnect budget in one call. + await _bluetoothService.connect(device.id, + maxAttempts: _isAutoReconnecting ? 1 : null); transportConnectedAt = DateTime.now(); _activeTransport = _bluetoothService; debugLog('[APP] Creating new MeshCoreConnection'); @@ -3976,6 +3986,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isConnecting = false; return; } catch (e) { + if (myGeneration != _connectGeneration) { + // A newer connection attempt owns the transport and the shared + // provider state; this stale attempt must not touch either. + debugLog('[CONN] Superseded connection attempt failed, ignoring: $e'); + return; + } final sinceTransportConnect = transportConnectedAt == null ? null : DateTime.now().difference(transportConnectedAt); @@ -4004,11 +4020,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (cleanupInFlight != null) { await cleanupInFlight; } - if (_isConnecting) { - // The cleanup reset this flag; true means a newer connection attempt - // started while waiting. Let it own the transport. + if (myGeneration != _connectGeneration) { debugLog( - '[CONN] Skipping workflow re-run: another connection is in progress'); + '[CONN] Skipping workflow re-run: a newer connection attempt owns the transport'); + return; + } + if (_isConnecting) { + // No disconnect-event cleanup ran for this failure: the transport is + // still up (e.g. _postConnectionSetup threw on a live link), so + // there is nothing safe to re-run on top of. Hand the error to the + // normal failure path instead of swallowing it. + await _handleConnectionError(e); return; } _isConnecting = true; @@ -4035,6 +4057,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _isConnecting = true; + _connectGeneration++; // invalidate any pending BLE workflow re-run _connectionStep = ConnectionStep.transportConnecting; _connectionError = null; _isAuthError = false; @@ -4151,6 +4174,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _isConnecting = true; + _connectGeneration++; // invalidate any pending BLE workflow re-run _connectionStep = ConnectionStep.transportConnecting; _connectionError = null; _isAuthError = false; @@ -4287,6 +4311,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _isConnecting = true; + _connectGeneration++; // invalidate any pending BLE workflow re-run _connectionStep = ConnectionStep.transportConnecting; _connectionError = null; _isAuthError = false; @@ -5553,10 +5578,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// In-flight full-cleanup future so a connection workflow re-run (#500) /// can wait for the disconnect-event cleanup to settle before reconnecting. + /// Cleared on completion so a long-finished cleanup is never mistaken for + /// one belonging to the current failure. Future? _fullCleanupInFlight; Future _fullDisconnectCleanup() { - final cleanup = _fullDisconnectCleanupImpl(); + late final Future cleanup; + cleanup = _fullDisconnectCleanupImpl().whenComplete(() { + if (identical(_fullCleanupInFlight, cleanup)) { + _fullCleanupInFlight = null; + } + }); _fullCleanupInFlight = cleanup; return cleanup; } diff --git a/lib/services/bluetooth/ble_connect_retry_policy.dart b/lib/services/bluetooth/ble_connect_retry_policy.dart index dc2a0f59..f05d05f5 100644 --- a/lib/services/bluetooth/ble_connect_retry_policy.dart +++ b/lib/services/bluetooth/ble_connect_retry_policy.dart @@ -80,5 +80,8 @@ bool shouldRerunConnectionWorkflow({ if (errorString.contains('has been disposed')) { return false; } + if (errorString.contains('superseded')) { + return false; + } return true; } diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index 47bc82fd..d6d0d871 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -78,8 +78,13 @@ abstract class BluetoothService implements CompanionTransport { /// Stop scanning for devices Future stopScan(); - /// Connect to a device by ID - Future connect(String deviceId); + /// Connect to a device by ID. + /// + /// [maxAttempts] caps the implementation's internal retry loop. The + /// auto-reconnect path passes 1: its own retry ladder and 30s budget + /// replace the internal retries, which would otherwise consume the whole + /// budget inside one call. + Future connect(String deviceId, {int? maxAttempts}); /// Disconnect from current device @override diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index a108ffcd..36f83be1 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -275,12 +275,24 @@ class MobileBluetoothService implements BluetoothService { // arrives after a new MeshCoreConnection is created and disposes it incorrectly. } + /// Bumped by every connect() call. A loop whose epoch is stale has been + /// superseded by a newer connect and must stop retrying without touching + /// the shared device state (_bleDevice, characteristics, subscriptions). + int _connectEpoch = 0; + @override - Future connect(String deviceId) async { + Future connect(String deviceId, {int? maxAttempts}) async { + final epoch = ++_connectEpoch; + final requested = maxAttempts ?? _maxRetries; + final attemptLimit = + requested < 1 ? 1 : (requested > _maxRetries ? _maxRetries : requested); int attempt = 0; - while (attempt < _maxRetries) { + while (attempt < attemptLimit) { attempt++; + if (epoch != _connectEpoch) { + throw Exception('Connection attempt superseded'); + } try { debugLog('[BLE] Connection attempt $attempt/$_maxRetries to $deviceId'); _updateStatus(ConnectionStatus.connecting); @@ -419,15 +431,23 @@ class MobileBluetoothService implements BluetoothService { '[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); } + if (epoch != _connectEpoch) { + throw Exception('Connection attempt superseded'); + } debugLog('[BLE] Connection complete'); _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop } catch (e, stackTrace) { + if (epoch != _connectEpoch) { + // A newer connect() owns the shared device state; retrying or + // cleaning up here would stomp its fields mid-attempt. + rethrow; + } final action = classifyBleConnectFailure( errorString: e.toString(), isIos: Platform.isIOS, attempt: attempt, - maxRetries: _maxRetries, + maxRetries: attemptLimit, ); if (action == BleConnectFailureAction.abortForBondError) { diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index e93d16f9..eff37be5 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -118,7 +118,8 @@ class WebBluetoothService implements BluetoothService { } @override - Future connect(String deviceId) async { + Future connect(String deviceId, {int? maxAttempts}) async { + // maxAttempts is a mobile concern; web connects are single-attempt. try { _updateStatus(ConnectionStatus.connecting); debugLog('[BLE] Connecting to device: $deviceId'); diff --git a/test/services/bluetooth/ble_connect_retry_policy_test.dart b/test/services/bluetooth/ble_connect_retry_policy_test.dart index fc6b3510..590956ef 100644 --- a/test/services/bluetooth/ble_connect_retry_policy_test.dart +++ b/test/services/bluetooth/ble_connect_retry_policy_test.dart @@ -166,5 +166,12 @@ void main() { test('does not rerun after a user-requested disconnect', () { expect(rerun(userRequestedDisconnect: true), isFalse); }); + + test('never reruns an attempt superseded by a newer connect', () { + expect( + rerun(error: 'Exception: Connection attempt superseded'), + isFalse, + ); + }); }); } From 30545327fdceb21cb23fd9d997db226b12bf73fa Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 30 Aug 2026 08:15:21 -0400 Subject: [PATCH 358/360] Adopt the puck source and layer separately --- lib/widgets/map_widget.dart | 70 +++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 5ae2c29a..d25befd0 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -4850,9 +4850,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// order, with no symbol-sort-key contention. enableInteraction:false so taps /// pass through to the repeaters/clusters underneath. Mirrors /// [_ensureCoverageLinesLayer]. - /// Gated entry point; see [_coverageGate]. - Future _ensureGpsPuckLayer() => - _coverageGate.run(_ensureGpsPuckLayerLocked); + /// Gated entry point; see [_coverageGate]. Skips the gate entirely once + /// installed so a slow coverage mutation cannot stall the per-tick GPS sync. + Future _ensureGpsPuckLayer() => _gpsPuckLayerInstalled + ? Future.value(true) + : _coverageGate.run(_ensureGpsPuckLayerLocked); Future _ensureGpsPuckLayerLocked() async { if (_gpsPuckLayerInstalled) return true; @@ -4864,35 +4866,43 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeSource(_gpsPuckSourceId); } catch (_) {} - await _mapController! - .addGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); - await _mapController!.addSymbolLayer( - _gpsPuckSourceId, - _gpsPuckLayerId, - const SymbolLayerProperties( - iconImage: ['get', 'iconImage'], - iconRotate: ['get', 'iconRotate'], - iconAllowOverlap: true, - iconIgnorePlacement: true, - ), - // No belowLayerId => topmost, above all coverage pings and repeaters. - enableInteraction: false, - ); + // "Already exists" on either add means a previous install's native + // object survived a silently-failed teardown. Adopt it PER OBJECT: the + // layer's paint properties are constant data-driven expressions, so an + // existing object is identical to the one the add would have created, + // and the caller's setGeoJsonSource repositions it. Reporting failure + // here left the installed-flag false for the life of the map session + // while the stale layer kept rendering the last pushed position (#482). + // The adopt must not cover both adds at once: removeLayer succeeding + // while removeSource fails would otherwise adopt the surviving source + // and never re-add the layer, leaving no arrow at all. + try { + await _mapController! + .addGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + } catch (e) { + if (!mapStyleObjectAlreadyExists(e)) rethrow; + debugWarn('[MAP] gps-puck source already present, adopting it: $e'); + } + try { + await _mapController!.addSymbolLayer( + _gpsPuckSourceId, + _gpsPuckLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconRotate: ['get', 'iconRotate'], + iconAllowOverlap: true, + iconIgnorePlacement: true, + ), + // No belowLayerId => topmost, above all coverage pings and repeaters. + enableInteraction: false, + ); + } catch (e) { + if (!mapStyleObjectAlreadyExists(e)) rethrow; + debugWarn('[MAP] gps-puck layer already present, adopting it: $e'); + } _gpsPuckLayerInstalled = true; return true; } catch (e) { - // "Already exists" means a previous install's native objects survived a - // silently-failed teardown. Adopt them: the layer's paint properties are - // constant data-driven expressions, so the existing layer is identical - // to the one this add would have created, and the caller's - // setGeoJsonSource repositions it. Reporting failure here left the - // installed-flag false for the life of the map session while the stale - // layer kept rendering the last pushed position (#482). - if (mapStyleObjectAlreadyExists(e)) { - debugWarn('[MAP] gps-puck layer already present, adopting it: $e'); - _gpsPuckLayerInstalled = true; - return true; - } debugError('[MAP] gps-puck layer create failed: $e'); return false; } @@ -6068,7 +6078,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), - // Distance since last TX ping (like wardrive.js) + // Distance since the last ping of any type (TX, discovery, trace) if (hasGps && distanceFromLastPing != null) ...[ const SizedBox(width: 12), const Icon( From 2e96c4f0fb5af8d956d8ec3eb032e1816ff905a6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 30 Aug 2026 09:32:17 -0400 Subject: [PATCH 359/360] Pace heartbeats so a fast device clock can't storm the server --- DEVELOPMENT.md | 2 +- lib/services/api_service.dart | 82 +++++++++- test/services/api_service_heartbeat_test.dart | 144 ++++++++++++++++++ 3 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 test/services/api_service_heartbeat_test.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e0dade28..2e798584 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -360,7 +360,7 @@ Sound notifications for TX pings and RX observations, configurable on/off. Prevents session timeout during long wardriving sessions by periodically refreshing the session expiry. - **Trigger**: Enabled when auto-ping mode starts (`enableHeartbeat()`), disabled on disconnect or leaving auto mode -- **Timing**: Heartbeat fires **1 minute before** session `expires_at`. If already expired, sends immediately. +- **Timing**: Heartbeat fires **1 minute before** session `expires_at`. If already expired, sends immediately, but never more than one send per 30s (`minHeartbeatSpacing`). The floor matters because `expires_at` is server-clock while the delay math runs on the device clock: a device clock 4+ minutes fast (server TTL is 300s) makes every fresh expiry read as already due, and without the floor the "send immediately" path re-fired one POST per network round trip (the 2026-08-29 storm: 361k POSTs in 64 minutes from one device). An in-flight guard keeps re-entrant `scheduleHeartbeat` callers (upload success, per-ping session check) from stacking concurrent send chains, and a circuit breaker (`maxHeartbeatsPerMinute` = 6) pauses the lane for 60s as a backstop. Regression tests: `test/services/api_service_heartbeat_test.dart`. - **Mechanism**: POST to `/wardrive-api.php/wardrive` with `heartbeat: true` flag and optional GPS coordinates - **Response**: Returns updated `expires_at`, which schedules the next heartbeat - **Flow**: Auth response sets initial `expires_at` → each wardrive POST or heartbeat updates it → timer reschedules automatically diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 5b32b876..52b10928 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -74,6 +74,20 @@ class ApiService { /// Heartbeat buffer - schedule heartbeat 1 minute before session expiry static const Duration heartbeatBuffer = Duration(minutes: 1); + /// Minimum spacing between heartbeat sends. expires_at is server-clock while + /// the delay math runs on the device clock, so a device running further + /// ahead of the server than TTL minus [heartbeatBuffer] sees every freshly + /// extended expiry as already due. Without this floor the "expired, send + /// immediately" path re-fired the next heartbeat straight from the success + /// response, one POST per network round trip (361k requests in 64 minutes + /// from a single device on 2026-08-29). + static const Duration minHeartbeatSpacing = Duration(seconds: 30); + + /// Circuit breaker: hard cap on scheduled heartbeat sends per minute, + /// independent of the spacing floor. Tripping it pauses the heartbeat lane + /// for a minute instead of letting any future bug storm the server. + static const int maxHeartbeatsPerMinute = 6; + final http.Client _client; bool _heartbeatEnabled = false; // Track if heartbeat mode is active String? _sessionId; @@ -88,6 +102,10 @@ class ApiService { int _heartbeatRetryCount = 0; static const int _maxHeartbeatRetries = 5; + bool _heartbeatInFlight = false; + DateTime? _lastHeartbeatSentAt; + DateTime? _heartbeatWindowStart; + int _heartbeatWindowCount = 0; Function? _onSessionExpiring; List _channels = []; List _scopes = []; @@ -854,11 +872,30 @@ class ApiService { final secondsUntilHeartbeat = secondsUntilExpiry - heartbeatBuffer.inSeconds; - if (secondsUntilHeartbeat <= 0) { + // Pacing floor (see minHeartbeatSpacing): a heartbeat may go out at most + // once per floor interval, no matter how expired the expiry reads. This + // is what stops a fast device clock from turning every success response + // into the trigger for the next send. + final lastSent = _lastHeartbeatSentAt; + final floorSeconds = lastSent == null + ? 0 + : minHeartbeatSpacing.inSeconds - + DateTime.now().difference(lastSent).inSeconds; + final delaySeconds = max(secondsUntilHeartbeat, max(floorSeconds, 0)); + + if (delaySeconds <= 0) { // Session is about to expire or already expired - send heartbeat immediately debugWarn( '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); + } else if (secondsUntilHeartbeat <= 0) { + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s but a heartbeat ' + 'just went out, next in ${delaySeconds}s'); + _heartbeatTimer = Timer(Duration(seconds: delaySeconds), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); } else { debugLog( '[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); @@ -872,9 +909,46 @@ class ApiService { /// Send scheduled heartbeat with GPS coordinates Future _sendScheduledHeartbeat() async { - // Get GPS coordinates from provider (matching wardrive.js behavior) - final coords = _gpsProvider?.call(); - final result = await sendHeartbeat(lat: coords?.lat, lon: coords?.lon); + // One send in flight at a time. scheduleHeartbeat() cancels pending + // timers but cannot cancel an already-awaiting send, so without this + // guard every re-entrant caller (upload success, per-ping session check) + // stacked another concurrent send chain that never died. Skipping is + // safe: the in-flight send's own response schedules the next heartbeat. + if (_heartbeatInFlight) { + debugLog('[HEARTBEAT] Send already in flight, skipping duplicate'); + return; + } + + // Circuit breaker (see maxHeartbeatsPerMinute): if the lane somehow still + // runs hot, pause it for a minute rather than storm the server. + final now = DateTime.now(); + final windowStart = _heartbeatWindowStart; + if (windowStart == null || + now.difference(windowStart) >= const Duration(minutes: 1)) { + _heartbeatWindowStart = now; + _heartbeatWindowCount = 0; + } + if (_heartbeatWindowCount >= maxHeartbeatsPerMinute) { + debugError( + '[HEARTBEAT] Circuit breaker tripped: $maxHeartbeatsPerMinute sends ' + 'inside a minute, pausing heartbeats for 60s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = + Timer(const Duration(seconds: 60), _sendScheduledHeartbeat); + return; + } + _heartbeatWindowCount++; + _heartbeatInFlight = true; + _lastHeartbeatSentAt = now; + + Map? result; + try { + // Get GPS coordinates from provider (matching wardrive.js behavior) + final coords = _gpsProvider?.call(); + result = await sendHeartbeat(lat: coords?.lat, lon: coords?.lon); + } finally { + _heartbeatInFlight = false; + } if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); diff --git a/test/services/api_service_heartbeat_test.dart b/test/services/api_service_heartbeat_test.dart new file mode 100644 index 00000000..5f11ed13 --- /dev/null +++ b/test/services/api_service_heartbeat_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +/// Regression tests for the 2026-08-29 heartbeat POST storm (361k requests in +/// 64 minutes from one device). +/// +/// The trigger: expires_at comes from the SERVER clock while the scheduling +/// math runs on the DEVICE clock. A device clock running further ahead of the +/// server than TTL minus the 1-minute buffer (4+ minutes at the server's 300s +/// TTL) makes every freshly-extended expiry look already due, and the old +/// "expired, send immediately" path re-fired the next heartbeat straight from +/// the success response with no minimum interval. scheduleHeartbeat() also +/// cancels only timers, never an in-flight send, so every upload success and +/// per-ping session check stacked another self-sustaining chain. +void main() { + /// Builds an ApiService whose mock server always answers success with the + /// given expires_at, counting heartbeat POSTs. After [maxHeartbeats] the + /// server kills the session so a hot-looping client cannot hang the test. + ({ApiService api, List heartbeats}) build({ + required int Function() expiresAt, + int maxHeartbeats = 100, + }) { + final heartbeats = []; + final api = ApiService( + client: MockClient((request) async { + final body = json.decode(request.body) as Map; + if (request.url.path.endsWith('/auth')) { + return http.Response( + json.encode({ + 'success': true, + 'session_id': 'YOW-20260830-0001', + 'tx_allowed': true, + 'rx_allowed': true, + 'expires_at': expiresAt(), + }), + 200, + ); + } + if (body['heartbeat'] == true) { + heartbeats.add(DateTime.now()); + if (heartbeats.length >= maxHeartbeats) { + return http.Response( + json.encode({ + 'success': false, + 'reason': 'session_expired', + 'message': 'killed by test backstop', + }), + 401, + ); + } + return http.Response( + json.encode({'success': true, 'expires_at': expiresAt()}), + 200, + ); + } + return http.Response( + json.encode({'success': true, 'expires_at': expiresAt()}), + 200, + ); + }), + ); + return (api: api, heartbeats: heartbeats); + } + + void connect(FakeAsync async, ApiService api) { + api.requestAuth( + reason: 'connect', + publicKey: 'AB', + lat: 45.0, + lon: -75.0, + ); + async.flushMicrotasks(); + } + + test('a stale expires_at never hot-loops heartbeats on success', () { + fakeAsync((async) { + // Device clock "ahead": the server's answer always looks expired. + final built = build( + expiresAt: () => + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100); + connect(async, built.api); + + built.api.enableHeartbeat(); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 5)); + + // One immediate catch-up send, then paced at the spacing floor: at a + // 30s floor that is at most 11 in 5 minutes. The unfixed loop reaches + // the 100-send backstop within the first flush. + expect(built.heartbeats.length, inInclusiveRange(1, 12), + reason: 'heartbeats must be paced by a minimum interval even when ' + 'expires_at always reads as already expired'); + }); + }); + + test('repeated scheduleHeartbeat calls do not stack concurrent chains', () { + fakeAsync((async) { + final built = build( + expiresAt: () => + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100); + connect(async, built.api); + + built.api.enableHeartbeat(); + async.flushMicrotasks(); + // Simulate what the storm did: every upload success and per-ping + // session check re-enters scheduleHeartbeat while state reads expired. + for (var i = 0; i < 5; i++) { + built.api.scheduleHeartbeat( + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100); + async.flushMicrotasks(); + } + async.elapse(const Duration(minutes: 2)); + + expect(built.heartbeats.length, inInclusiveRange(1, 6), + reason: 're-entrant scheduling must coalesce into one paced chain, ' + 'not one chain per caller'); + }); + }); + + test('a healthy expiry still schedules the normal keepalive', () { + fakeAsync((async) { + final built = build( + expiresAt: () => + DateTime.now().millisecondsSinceEpoch ~/ 1000 + 300); + connect(async, built.api); + + built.api.enableHeartbeat(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 230)); + expect(built.heartbeats, isEmpty, + reason: 'keepalive fires 1 minute before expiry, not earlier'); + + async.elapse(const Duration(seconds: 20)); + expect(built.heartbeats.length, 1, + reason: 'the ordinary pre-expiry keepalive must still go out'); + }); + }); +} From cd736a775c5c84def2b116039078ed941b1811d9 Mon Sep 17 00:00:00 2001 From: Nicholas Feldman Date: Mon, 31 Aug 2026 19:37:30 -0400 Subject: [PATCH 360/360] Add an Android Auto Surface --- DEVELOPMENT.md | 124 +- android/app/build.gradle.kts | 3 + android/app/src/main/AndroidManifest.xml | 31 +- .../plugins/GeneratedPluginRegistrant.java | 5 + .../net/meshmapper/app/CarMapCoverage.kt | 9 + .../net/meshmapper/app/CarMapTimerBar.kt | 48 + .../kotlin/net/meshmapper/app/MainActivity.kt | 13 +- .../meshmapper/app/MeshMapperApplication.kt | 13 + .../net/meshmapper/app/MeshMapperCarMap.kt | 510 ++++++ .../meshmapper/app/MeshMapperCarMapChannel.kt | 107 ++ .../net/meshmapper/app/MeshMapperEngine.kt | 30 + .../meshmapper/app/MeshMapperUsbService.kt | 15 +- .../src/main/res/xml/automotive_app_desc.xml | 5 + assets/car_start.png | Bin 0 -> 469 bytes assets/car_stop.png | Bin 0 -> 354 bytes lib/main.dart | 33 +- lib/providers/app_state_provider.dart | 103 ++ lib/services/auto/android_auto_service.dart | 318 ++++ lib/services/auto/auto_glance_view.dart | 90 ++ lib/services/auto/car_map_channel.dart | 261 +++ .../external_command_models.dart | 3 +- lib/widgets/map_widget.dart | 17 + pubspec.lock | 79 +- pubspec.yaml | 27 +- .../auto/android_auto_service_test.dart | 423 +++++ .../auto/auto_command_routing_test.dart | 255 +++ test/services/auto/auto_glance_view_test.dart | 275 ++++ test/services/auto/car_map_channel_test.dart | 376 +++++ third_party/flutter_carplay/CHANGELOG.md | 202 +++ third_party/flutter_carplay/LICENSE | 21 + third_party/flutter_carplay/README.md | 1438 +++++++++++++++++ .../flutter_carplay/analysis_options.yaml | 14 + .../flutter_carplay/android/build.gradle | 54 + .../gradle/wrapper/gradle-wrapper.properties | 7 + .../flutter_carplay/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../AndroidAutoService.kt | 103 ++ .../flutter_android_auto/FAAConstants.kt | 5 + .../flutter_android_auto/FAAEnums.kt | 35 + .../flutter_android_auto/FAAHelpers.kt | 118 ++ .../FlutterAndroidAutoPlugin.kt | 1058 ++++++++++++ .../flutter_android_auto/MethodCallResult.kt | 35 + .../oguzhnatly/flutter_android_auto/Screen.kt | 36 + .../flutter_android_auto/Session.kt | 49 + .../models/alert/FAAAlertAction.kt | 16 + .../models/alert/FAAAlertTemplate.kt | 21 + .../models/common/FAAImageTint.kt | 78 + .../models/grid/FAAGridButton.kt | 33 + .../models/grid/FAAGridTemplate.kt | 24 + .../models/list/FAAListItem.kt | 73 + .../models/list/FAAListSection.kt | 30 + .../models/list/FAAListTemplate.kt | 31 + .../models/message/FAAMessageTemplate.kt | 17 + .../models/pane/FAAPaneAction.kt | 33 + .../models/pane/FAAPaneItem.kt | 23 + .../models/pane/FAAPaneTemplate.kt | 42 + .../models/tabbar/FAATabBarItem.kt | 26 + .../models/tabbar/FAATabBarTemplate.kt | 22 + .../FAATabBarTemplateTest.kt | 60 + .../MethodCallResultTest.kt | 79 + .../lib/aa_models/alert/alert_action.dart | 37 + .../lib/aa_models/alert/alert_template.dart | 49 + .../lib/aa_models/alert/all.dart | 2 + .../flutter_carplay/lib/aa_models/all.dart | 8 + .../lib/aa_models/grid/all.dart | 2 + .../lib/aa_models/grid/grid_button.dart | 52 + .../lib/aa_models/grid/grid_template.dart | 66 + .../lib/aa_models/list/all.dart | 3 + .../lib/aa_models/list/list_item.dart | 91 ++ .../lib/aa_models/list/list_section.dart | 54 + .../lib/aa_models/list/list_template.dart | 91 ++ .../lib/aa_models/map/all.dart | 2 + .../lib/aa_models/map/map_action.dart | 42 + .../map/map_with_content_template.dart | 56 + .../lib/aa_models/message/all.dart | 2 + .../message/long_message_template.dart | 18 + .../aa_models/message/message_template.dart | 14 + .../message/message_template_base.dart | 76 + .../lib/aa_models/pane/all.dart | 3 + .../lib/aa_models/pane/pane_action.dart | 39 + .../lib/aa_models/pane/pane_item.dart | 40 + .../lib/aa_models/pane/pane_template.dart | 80 + .../lib/aa_models/tabbar/all.dart | 1 + .../lib/aa_models/tabbar/tabbar_template.dart | 59 + .../lib/aa_models/template.dart | 7 + .../lib/android_auto_worker.dart | 268 +++ .../flutter_carplay/lib/carplay_worker.dart | 455 ++++++ .../flutter_carplay/lib/constants/all.dart | 2 + .../lib/constants/constants.dart | 1 + .../lib/constants/private_constants.dart | 67 + .../flutter_carplay/lib/controllers/all.dart | 2 + .../controllers/android_auto_controller.dart | 194 +++ .../lib/controllers/carplay_controller.dart | 443 +++++ .../flutter_carplay/lib/flutter_carplay.dart | 16 + .../flutter_carplay/lib/helpers/all.dart | 4 + .../lib/helpers/auto_android_helper.dart | 103 ++ .../lib/helpers/carplay_helper.dart | 41 + .../lib/helpers/enum_utils.dart | 9 + .../lib/helpers/svg_rasterizer.dart | 245 +++ .../action_sheet/action_sheet_template.dart | 53 + .../lib/models/action_sheet/all.dart | 1 + .../lib/models/alert/alert_action.dart | 42 + .../lib/models/alert/alert_constants.dart | 3 + .../lib/models/alert/alert_template.dart | 55 + .../flutter_carplay/lib/models/alert/all.dart | 3 + .../flutter_carplay/lib/models/all.dart | 11 + .../lib/models/button/all.dart | 3 + .../lib/models/button/bar_button.dart | 43 + .../lib/models/button/button_constants.dart | 5 + .../lib/models/button/text_button.dart | 43 + .../lib/models/common/all.dart | 2 + .../lib/models/common/image_tint.dart | 130 ++ .../lib/models/common/ui_color.dart | 43 + .../flutter_carplay/lib/models/grid/all.dart | 2 + .../lib/models/grid/grid_button.dart | 62 + .../lib/models/grid/grid_template.dart | 50 + .../lib/models/information/all.dart | 3 + .../information/information_constants.dart | 3 + .../models/information/information_item.dart | 35 + .../information/information_template.dart | 75 + .../flutter_carplay/lib/models/list/all.dart | 7 + .../lib/models/list/list_constants.dart | 7 + .../lib/models/list/list_image_row_item.dart | 103 ++ .../models/list/list_image_row_item/all.dart | 7 + .../list_image_row_item_card_element.dart | 114 ++ ...list_image_row_item_condensed_element.dart | 115 ++ .../list_image_row_item_constants.dart | 19 + .../list_image_row_item_element.dart | 28 + .../list_image_row_item_grid_element.dart | 67 + ...ist_image_row_item_image_grid_element.dart | 102 ++ .../list_image_row_item_row_element.dart | 93 ++ .../lib/models/list/list_item.dart | 235 +++ .../lib/models/list/list_section.dart | 46 + .../lib/models/list/list_template.dart | 89 + .../lib/models/list/list_template_item.dart | 16 + .../flutter_carplay/lib/models/poi/all.dart | 2 + .../flutter_carplay/lib/models/poi/poi.dart | 102 ++ .../lib/models/poi/poi_template.dart | 47 + .../lib/models/search/all.dart | 1 + .../lib/models/search/search_template.dart | 59 + .../lib/models/tabbar/all.dart | 1 + .../lib/models/tabbar/tabbar_template.dart | 65 + .../flutter_carplay/lib/models/template.dart | 50 + third_party/flutter_carplay/pubspec.yaml | 35 + 144 files changed, 11734 insertions(+), 47 deletions(-) create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 assets/car_start.png create mode 100644 assets/car_stop.png create mode 100644 lib/services/auto/android_auto_service.dart create mode 100644 lib/services/auto/auto_glance_view.dart create mode 100644 lib/services/auto/car_map_channel.dart create mode 100644 test/services/auto/android_auto_service_test.dart create mode 100644 test/services/auto/auto_command_routing_test.dart create mode 100644 test/services/auto/auto_glance_view_test.dart create mode 100644 test/services/auto/car_map_channel_test.dart create mode 100644 third_party/flutter_carplay/CHANGELOG.md create mode 100644 third_party/flutter_carplay/LICENSE create mode 100644 third_party/flutter_carplay/README.md create mode 100644 third_party/flutter_carplay/analysis_options.yaml create mode 100644 third_party/flutter_carplay/android/build.gradle create mode 100644 third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 third_party/flutter_carplay/android/settings.gradle create mode 100644 third_party/flutter_carplay/android/src/main/AndroidManifest.xml create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt create mode 100644 third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt create mode 100644 third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt create mode 100644 third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt create mode 100644 third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/alert/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/grid/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/list/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/list/list_item.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/list/list_section.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/list/list_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/map/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/map/map_action.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/message/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/message/message_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/pane/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/tabbar/all.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart create mode 100644 third_party/flutter_carplay/lib/aa_models/template.dart create mode 100644 third_party/flutter_carplay/lib/android_auto_worker.dart create mode 100644 third_party/flutter_carplay/lib/carplay_worker.dart create mode 100644 third_party/flutter_carplay/lib/constants/all.dart create mode 100644 third_party/flutter_carplay/lib/constants/constants.dart create mode 100644 third_party/flutter_carplay/lib/constants/private_constants.dart create mode 100644 third_party/flutter_carplay/lib/controllers/all.dart create mode 100644 third_party/flutter_carplay/lib/controllers/android_auto_controller.dart create mode 100644 third_party/flutter_carplay/lib/controllers/carplay_controller.dart create mode 100644 third_party/flutter_carplay/lib/flutter_carplay.dart create mode 100644 third_party/flutter_carplay/lib/helpers/all.dart create mode 100644 third_party/flutter_carplay/lib/helpers/auto_android_helper.dart create mode 100644 third_party/flutter_carplay/lib/helpers/carplay_helper.dart create mode 100644 third_party/flutter_carplay/lib/helpers/enum_utils.dart create mode 100644 third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart create mode 100644 third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart create mode 100644 third_party/flutter_carplay/lib/models/action_sheet/all.dart create mode 100644 third_party/flutter_carplay/lib/models/alert/alert_action.dart create mode 100644 third_party/flutter_carplay/lib/models/alert/alert_constants.dart create mode 100644 third_party/flutter_carplay/lib/models/alert/alert_template.dart create mode 100644 third_party/flutter_carplay/lib/models/alert/all.dart create mode 100644 third_party/flutter_carplay/lib/models/all.dart create mode 100644 third_party/flutter_carplay/lib/models/button/all.dart create mode 100644 third_party/flutter_carplay/lib/models/button/bar_button.dart create mode 100644 third_party/flutter_carplay/lib/models/button/button_constants.dart create mode 100644 third_party/flutter_carplay/lib/models/button/text_button.dart create mode 100644 third_party/flutter_carplay/lib/models/common/all.dart create mode 100644 third_party/flutter_carplay/lib/models/common/image_tint.dart create mode 100644 third_party/flutter_carplay/lib/models/common/ui_color.dart create mode 100644 third_party/flutter_carplay/lib/models/grid/all.dart create mode 100644 third_party/flutter_carplay/lib/models/grid/grid_button.dart create mode 100644 third_party/flutter_carplay/lib/models/grid/grid_template.dart create mode 100644 third_party/flutter_carplay/lib/models/information/all.dart create mode 100644 third_party/flutter_carplay/lib/models/information/information_constants.dart create mode 100644 third_party/flutter_carplay/lib/models/information/information_item.dart create mode 100644 third_party/flutter_carplay/lib/models/information/information_template.dart create mode 100644 third_party/flutter_carplay/lib/models/list/all.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_constants.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_item.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_section.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_template.dart create mode 100644 third_party/flutter_carplay/lib/models/list/list_template_item.dart create mode 100644 third_party/flutter_carplay/lib/models/poi/all.dart create mode 100644 third_party/flutter_carplay/lib/models/poi/poi.dart create mode 100644 third_party/flutter_carplay/lib/models/poi/poi_template.dart create mode 100644 third_party/flutter_carplay/lib/models/search/all.dart create mode 100644 third_party/flutter_carplay/lib/models/search/search_template.dart create mode 100644 third_party/flutter_carplay/lib/models/tabbar/all.dart create mode 100644 third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart create mode 100644 third_party/flutter_carplay/lib/models/template.dart create mode 100644 third_party/flutter_carplay/pubspec.yaml diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2e798584..83683586 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -986,6 +986,7 @@ when MeshMapper is disconnected. ### Android - Requires permissions: Bluetooth, Location (for BLE scanning) - minSdkVersion: 24 (Flutter's `flutter.minSdkVersion` default; MapLibre GL needs 23+) +- Supports Android Auto. Testing needs Desktop Head Unit, and can not be tested against a physical head unit. - Background location permission for continuous tracking - Uses `flutter_blue_plus` package - URL scheme `meshmapper-auth` (host `callback`) registered on MainActivity via a VIEW/DEFAULT/BROWSABLE intent-filter — the portal sign-in return. The bare `meshmapper://` scheme is deliberately NOT registered: it is a paste-only clipboard format (`docs/CUSTOM_API_ENDPOINT.md`). @@ -1004,6 +1005,7 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) +- `flutter_carplay`: Android Auto templates — **vendored & patched**, see below - `geolocator`: GPS/Location - `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) — **vendored & patched**, see below - `hive`: Local storage @@ -1041,6 +1043,115 @@ burning. See the `_canAnimateCamera` getter and `_onMapIdle`. **On upgrade:** re-apply the `MESHMAPPER GUARD` blocks to the new plugin version (or drop the override if upstream gains an equivalent guard). +### Vendored `flutter_carplay` (`third_party/flutter_carplay`) + +Consumed from an in-repo copy of the pub.dev `1.6.5` release via `dependency_overrides`, **not** +from pub. Despite the name it is used for its **Android Auto** half only — CarPlay is not shipped. +Three deltas from upstream, each tagged `DELTA` in the vendored source: + +- **DELTA A** (`third_party/flutter_carplay/pubspec.yaml`): the `ios:` plugin platform entry and + the whole `ios/` directory are removed. Upstream would link `SwiftFlutterCarplayPlugin` into the + App Store build — a CarPlay scene-delegate and entitlement surface we neither want nor hold + Apple's CarPlay entitlement for. The Dart `AA*` classes are pure Dart plus a MethodChannel and + still compile on iOS, where `AndroidAutoService.isSupportedPlatform` is false. +- **DELTA B** (`AndroidAutoService.kt`): adds `FAAEngineProvider`, letting the host app supply the + engine. Upstream builds a bare headless `FlutterEngine` whenever the cache is empty, which in + this app is the **wrong** engine — ours owns the USB serial and tile cache channels (see + `MeshMapperEngine.kt`), and two engines means two copies of every plugin fighting over this + plugin's own static template state. +- **DELTA C** (`AndroidAutoService.kt`): `createHostValidator()` no longer returns + `ALLOW_ALL_HOSTS_VALIDATOR` unconditionally. Android documents that as debug-only — it lets any + app on the device bind the service and drive the car surface — so release builds validate against + the car-app library's bundled host allowlist. Debug builds keep the permissive path, which is + what the Desktop Head Unit needs. +- **DELTA D** (`FlutterAndroidAutoPlugin.kt`, `AndroidAutoService.kt`, `Session.kt`, + `lib/aa_models/map/`): adds `MapWithContentTemplate`, its map action strip + (`MapController` + `AAMapAction` + an `onMapActionPressed` event), and a `FAASurfaceProvider` + hook. It also makes `AAPaneTemplate`'s title optional: upstream requires a + non-empty one, but `PaneTemplate.Builder.build()` does not — it validates only rows and + actions — and the title is what draws the header. Upstream + supports six templates, none of which can show a map. The hook lets the host app supply the + `SurfaceCallback`, so MeshMapper's MapLibre renderer lives in the app rather than teaching the + plugin about a map SDK — the same shape as DELTA B. + +`example/`, `previews/` and `test/` are not vendored. + +**On upgrade:** re-apply all three deltas. + +### Android Auto (`lib/services/auto/`) + +The coverage map on the head unit, plus a glance at session state, counters, and one-touch +start/stop. Category is **POI** (`androidx.car.app.category.POI`), because drawing a map is limited +to the navigation, POI and weather categories. + +```bash +adb shell cmd package query-services -a androidx.car.app.CarAppService | grep -A3 meshmapper +``` + +Expect `AndroidAutoService`, `exported=true`, `enabled=true`, and +`Category: "androidx.car.app.category.IOT"`. + +**One-time phone setup.** In the Android Auto settings screen, tap the version header 10× to unlock +developer mode, then from the ⋮ menu: + +- **Unknown sources** — ON. Required. An unpublished car app is *not listed at all* without it. +- **Start head unit server**. + +**After every install of a changed build** — including every `flutter run` — restart the Android +Auto session so it rescans: + +```bash +adb shell am force-stop com.google.android.projection.gearhead +``` + +Not a superstition. Android Auto builds its car-app list by querying `PackageManager` when a +session starts and caches it; `dumpsys package com.google.android.projection.gearhead` shows it +registers only `MY_PACKAGE_REPLACED` (for itself) and **no** `PACKAGE_ADDED`/`PACKAGE_CHANGED` +receiver for other packages. It therefore cannot notice a car app installed after it started. A +freshly installed or updated MeshMapper stays invisible until Android Auto is restarted. + +**Connect:** + +```bash +adb forward tcp:5277 tcp:5277 +$ANDROID_HOME/extras/google/auto/desktop-head-unit # ~/Android/Sdk/extras/google/auto/ +``` + +The phone screen must be unlocked. MeshMapper appears in the DHU launcher and opens to the four-row +pane — briefly a loading spinner first if Dart has not published a template yet, since the plugin's +`MainScreen.onGetTemplate` falls back to a loading `ListTemplate`. + +**`flutter run` and the DHU.** The DHU is **not** a Flutter device: it never appears in +`flutter devices` and is never a `flutter run` target. `flutter run` targets the phone; the car pane +is drawn by that same isolate. + +**Order matters.** Run `flutter run` *first*, then connect the DHU: `MainActivity` creates the +engine, `flutter run` attaches to it, and `FAAEngineProvider` (DELTA B) hands that same engine to +the car service — so **hot reload reaches the pane**. Connect the DHU first and Dart starts headless +in a process `flutter run` never launched; use `flutter attach` to pick it up. + +Debug builds accept any car host, because DELTA C keeps `ALLOW_ALL_HOSTS_VALIDATOR` for debuggable +builds — that is what lets the DHU bind. Release builds validate against the car-app library's +bundled allowlist, which Android Auto is on, so the DHU works there too. + +**The two checks that matter:** + +- **Template quota.** Turn on **Developer settings → Enable debug overlay** and watch the template + counter across a full auto-ping session. If counter updates consume steps, the row layout is + wrong — see the fixed-layout contract above. +- **One engine.** Cold start with `adb shell am force-stop net.meshmapper.app`, then connect. + `[APP] MeshMapper starting...` must appear in logcat **exactly once**. Twice means a second engine + and the `MeshMapperEngine` ownership rule has a hole. + +**Logs:** `adb logcat -s CarApp.H CarApp.H.Dis flutter`, after +`adb shell setprop log.tag.CarApp.H.Dis VERBOSE`. + +**Play submission:** car support needs Google's Android Auto review, declared in Play Console → +*Declare car compatibility* → POI. While a car submission is under review, subsequent app updates +are blocked — so ship it in its own release, not bundled with an urgent fix. Everything +car-specific is one `` block in the manifest plus `lib/services/auto/`; deleting the +service element disables the surface without touching Dart. + ## Development Workflow Requirements ### Debug Logging Convention (MANDATORY) @@ -1236,13 +1347,22 @@ All API endpoints may return maintenance mode: - `lib/services/watch/watch_color.dart` - Wire colour projection shared with the phone map - `lib/services/live_activity/live_activity_service.dart` - ActivityKit bridge: preflight urgency, throttle, dedupe, unavailable backoff - `lib/services/live_activity/live_activity_models.dart` - Live Activity snapshot model and urgency keys -- `lib/services/external_surfaces/external_surface_publisher.dart` - Shared publish pipeline (preflight dedupe, throttle, retry) behind watch, Live Activity, and Siri snapshots +- `lib/services/external_surfaces/external_surface_publisher.dart` - Shared publish pipeline (preflight dedupe, throttle, retry) behind watch, Live Activity, Siri snapshots, and the Android Auto pane - `lib/services/external_surfaces/geo/external_surface_geo_builder.dart` - Ping/repeater/heard geography for external surfaces, with wire caps (was watch_geo_builder) -- `lib/services/external_commands/external_session_commands.dart` - Shared Siri/watch session-command admission and deadline rules +- `lib/services/external_commands/external_session_commands.dart` - Shared Siri/watch/car session-command admission and deadline rules - `lib/services/external_commands/external_command_models.dart` - External command wire model, refusal reasons, and voice copy - `lib/services/app_intents/app_intent_bridge_service.dart` - Siri method channel: command decode, dedupe, snapshot publish - `lib/services/app_intents/siri_snapshot_builder.dart` - App Group snapshot content (recent heard, repeater catalogue, counts) - `lib/services/app_intents/last_companion_connection.dart` - Connect-last-companion admission for the Siri intent +- `lib/services/auto/android_auto_service.dart` - Android Auto surface: connection lifecycle, pane publisher, serialized map sync, action routing +- `lib/services/auto/auto_glance_view.dart` - Pure WatchSnapshot to head-unit-pane projection (fixed 4-row layout) +- `lib/services/auto/car_map_channel.dart` - Dart side of the car map: camera, style and coverage overlay, deduped +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt` - Native MapLibre map on the car Surface (VirtualDisplay + Presentation) +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt` - Holds the renderer and bridges it to Dart +- `android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt` - Tile URL plus finished paint expressions, as Dart describes them +- `android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt` - The depleting next-ping bar, animated locally from a deadline +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt` - Sole owner of the process FlutterEngine and its app-scoped channels +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt` - Installs the engine factory the car service uses - `lib/screens/watch_diagnostics_screen.dart` - Watch transport diagnostics (Settings) - `lib/services/meshcore/packet_validator.dart` - Packet validation and carpeater filtering - `lib/models/noise_floor_session.dart` - Noise floor session data models diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 38066d36..bf900a17 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -78,4 +78,7 @@ dependencies { // OfflineManager for the tile cache MethodChannel handlers. Version must // match maplibre_gl-0.25.0's transitive dep. implementation("org.maplibre.gl:android-sdk:12.3.1") + + // Android for Cars App Library. + implementation("androidx.car.app:app:1.7.0") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cba07486..0cde108a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,15 @@ + + + + + + + @@ -31,8 +40,8 @@ @@ -73,6 +82,24 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + + + + + + 0) postInvalidateOnAnimation() + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index c96604d9..c6e10efc 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -1,5 +1,6 @@ package net.meshmapper.app +import android.content.Context import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -9,14 +10,14 @@ import org.maplibre.android.offline.OfflineRegionStatus import java.io.File class MainActivity : FlutterActivity() { - private var usbService: MeshMapperUsbService? = null + override fun provideFlutterEngine(context: Context): FlutterEngine = + MeshMapperEngine.obtain(context) + + override fun shouldDestroyEngineWithHost(): Boolean = false override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - usbService = MeshMapperUsbService(this) - usbService!!.configureFlutterEngine(flutterEngine) - // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS // implementation. Called from Dart's TileCacheService by the Offline // Maps screen's Tile Cache card. @@ -96,8 +97,4 @@ class MainActivity : FlutterActivity() { ) } - override fun onDestroy() { - usbService?.dispose() - super.onDestroy() - } } diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt new file mode 100644 index 00000000..09fa37f1 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt @@ -0,0 +1,13 @@ +package net.meshmapper.app + +import android.app.Application +import com.oguzhnatly.flutter_android_auto.FAAEngineProvider +import com.oguzhnatly.flutter_android_auto.FAASurfaceProvider + +class MeshMapperApplication : Application() { + override fun onCreate() { + super.onCreate() + FAAEngineProvider.factory = { context -> MeshMapperEngine.obtain(context) } + FAASurfaceProvider.factory = { carContext -> MeshMapperCarMapChannel.create(carContext) } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt new file mode 100644 index 00000000..1f92ef09 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt @@ -0,0 +1,510 @@ +package net.meshmapper.app + +import android.app.Presentation +import android.content.Context +import android.graphics.Rect +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Bundle +import android.util.Log +import android.view.Display +import android.view.Gravity +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapLibreMapOptions +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.TileSet +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.VectorSource + +/// Renders MeshMapper's map onto the android auto display +class MeshMapperCarMap(private val carContext: CarContext) : SurfaceCallback { + + private companion object { + const val TAG = "MeshMapperCarMap" + + /// The car reports its own density; this is only the fallback when a + /// SurfaceContainer arrives without one. + const val FALLBACK_DPI = 160 + + const val DEFAULT_ZOOM = 14.0 + + const val COVERAGE_SOURCE_ID = "meshmapper-car-coverage" + const val COVERAGE_LAYER_ID = "meshmapper-car-coverage-layer" + + /// The layer name inside the vector tiles vector_tile.php emits. + const val COVERAGE_SOURCE_LAYER = "coverage" + + const val PINGS_SOURCE_ID = "meshmapper-car-pings" + const val PINGS_LAYER_ID = "meshmapper-car-pings-layer" + + /// Big enough to see at a glance from the driver's seat, small enough + /// that a dense run does not become one solid blob. + const val PING_RADIUS_DP = 12.0f + const val PING_STROKE_DP = 1.5f + const val PING_STROKE_OPACITY = 0.6f + + const val POSITION_SOURCE_ID = "meshmapper-car-position" + const val POSITION_LAYER_ID = "meshmapper-car-position-layer" + const val POSITION_IMAGE_ID = "meshmapper-car-position-icon" + } + + private var virtualDisplay: VirtualDisplay? = null + private var presentation: MapPresentation? = null + + private var pendingCamera: CameraPosition? = null + private var pendingStyleUrl: String? = null + private var appliedStyleUrl: String? = null + private var pendingCoverage: CarMapCoverage? = null + private var pendingPings: String? = null + private var pendingTimer: Triple? = null + private var pendingMarker: Bitmap? = null + private var pendingMarkerFacesHeading = true + private var pendingHeading: Double? = null + + // ---------------------------------------------------------------- surface + + override fun onSurfaceAvailable(container: SurfaceContainer) { + val surface = container.surface + if (surface == null || container.width <= 0 || container.height <= 0) { + Log.w(TAG, "Surface available with nothing to draw on") + return + } + tearDown() + + val dpi = if (container.dpi > 0) container.dpi else FALLBACK_DPI + val display = DisplayManager::class.java.let { + (carContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) + .createVirtualDisplay( + "MeshMapperCarMap", + container.width, + container.height, + dpi, + surface, + // PRESENTATION marks it as a display we intend to show a presentation on. + // OWN_CONTENT_ONLY keeps the phone from being mirrored to the car screen. + DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) + } + if (display == null) { + Log.e(TAG, "Could not create a virtual display for the car surface") + return + } + virtualDisplay = display + + presentation = MapPresentation(carContext, display.display).also { + it.show() + pendingMarker?.let { bmp -> + it.setPositionMarker(bmp, pendingMarkerFacesHeading) + } + it.applyPending(pendingStyleUrl, pendingCamera, pendingCoverage, pendingPings) + pendingCamera?.let { cam -> + it.setPosition(cam.target!!.latitude, cam.target!!.longitude, pendingHeading) + } + pendingTimer.let { t -> it.setTimer(t?.first, t?.second, t?.third) } + } + Log.i(TAG, "Car map attached (${container.width}x${container.height} @ ${dpi}dpi)") + } + + override fun onSurfaceDestroyed(container: SurfaceContainer) { + Log.i(TAG, "Car map detached") + tearDown() + } + + /// The region the host guarantees is available. + override fun onVisibleAreaChanged(visibleArea: Rect) { + val map = presentation?.map ?: return + val view = presentation?.mapView ?: return + if (visibleArea.isEmpty) return + + val rightInset = (view.width - visibleArea.right).coerceAtLeast(0) + map.setPadding( + visibleArea.left, + visibleArea.top, + rightInset, + (view.height - visibleArea.bottom).coerceAtLeast(0), + ) + presentation?.setTimerInsets( + right = rightInset, + top = visibleArea.top.coerceAtLeast(0), + bottom = (view.height - visibleArea.bottom).coerceAtLeast(0), + ) + } + + override fun onStableAreaChanged(stableArea: Rect) { + // NO-OP + } + + // --------------------------------------------------------------- gestures + + override fun onScroll(distanceX: Float, distanceY: Float) { + presentation?.map?.let { it.scrollBy(-distanceX, -distanceY) } + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + val map = presentation?.map ?: return + val zoom = map.cameraPosition.zoom + kotlin.math.log2(scaleFactor.toDouble()) + map.moveCamera(CameraUpdateFactory.zoomTo(zoom)) + } + + override fun onFling(velocityX: Float, velocityY: Float) { + // NO-OP + } + + // ------------------------------------------------------------------- data + + /// Called from Dart via [MeshMapperCarMapChannel]. + fun setCamera( + lat: Double, + lon: Double, + bearing: Double?, + heading: Double?, + zoom: Double?, + ) { + val position = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(zoom ?: DEFAULT_ZOOM) + .apply { bearing?.let { bearing(it) } } + .build() + pendingCamera = position + pendingHeading = heading + presentation?.map?.moveCamera(CameraUpdateFactory.newCameraPosition(position)) + presentation?.setPosition(lat, lon, heading) + } + + fun setPositionMarker(png: ByteArray, facesHeading: Boolean) { + val bitmap = BitmapFactory.decodeByteArray(png, 0, png.size) ?: run { + Log.e(TAG, "Could not decode the position marker") + return + } + pendingMarker = bitmap + pendingMarkerFacesHeading = facesHeading + presentation?.setPositionMarker(bitmap, facesHeading) + } + + /// The style the phone map is currently using + fun setStyle(url: String) { + if (url == appliedStyleUrl) return + pendingStyleUrl = url + presentation?.setStyle(url) { appliedStyleUrl = url } + } + + /// The coverage overlay, the same vector tiles the phone map draws. + fun setCoverage(coverage: CarMapCoverage?) { + if (coverage == pendingCoverage) return + pendingCoverage = coverage + presentation?.setCoverage(coverage) + } + + /// The ping markers, a GeoJSON FeatureCollection built by Dart. + fun setPings(geoJson: String) { + if (geoJson == pendingPings) return + pendingPings = geoJson + presentation?.setPings(geoJson) + } + + /// The next-ping countdown. Null will clear it. + fun setTimer(endsAtMs: Long?, durationMs: Long?, color: Int?) { + pendingTimer = if (endsAtMs != null && durationMs != null) { + Triple(endsAtMs, durationMs, color) + } else { + null + } + presentation?.setTimer(endsAtMs, durationMs, color) + } + + private fun tearDown() { + presentation?.let { + runCatching { it.dismiss() } + } + presentation = null + virtualDisplay?.release() + virtualDisplay = null + } + + /// The window that actually lives on the car display. + private class MapPresentation( + context: Context, + display: Display, + ) : Presentation(context, display) { + + var mapView: MapView? = null + private set + var map: MapLibreMap? = null + private set + + private var styleUrl: String? = null + private var camera: CameraPosition? = null + private var onStyleLoaded: (() -> Unit)? = null + private var coverage: CarMapCoverage? = null + private var pings: String? = null + private var timerBar: CarMapTimerBar? = null + private var markerBitmap: Bitmap? = null + private var markerFacesHeading = true + private var position: Triple? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + MapLibre.getInstance(context) + + val options = MapLibreMapOptions.createFromAttributes(context) + .compassEnabled(false) + .logoEnabled(false) + .attributionEnabled(false) + .rotateGesturesEnabled(false) + .scrollGesturesEnabled(false) + .tiltGesturesEnabled(false) + .zoomGesturesEnabled(false) + + val view = MapView(context, options) + mapView = view + view.onCreate(savedInstanceState) + view.onStart() + view.onResume() + view.getMapAsync { ready -> + map = ready + camera?.let { ready.moveCamera(CameraUpdateFactory.newCameraPosition(it)) } + styleUrl?.let { url -> + val loaded = onStyleLoaded + ready.setStyle(styleBuilder(url)) { style -> + applyOverlays(style) + loaded?.invoke() + } + } + } + + val bar = CarMapTimerBar(context).apply { visibility = View.GONE } + timerBar = bar + + setContentView( + FrameLayout(context).apply { + layoutParams = ViewGroup.LayoutParams(MATCH, MATCH) + addView(view, FrameLayout.LayoutParams(MATCH, MATCH, Gravity.CENTER)) + addView( + bar, + FrameLayout.LayoutParams( + (TIMER_BAR_DP * context.resources.displayMetrics.density).toInt(), + MATCH, + Gravity.END, + ), + ) + }, + ) + } + + fun applyPending( + style: String?, + position: CameraPosition?, + pendingCoverage: CarMapCoverage?, + pendingPings: String?, + ) { + coverage = pendingCoverage + pings = pendingPings + position?.let { + camera = it + map?.moveCamera(CameraUpdateFactory.newCameraPosition(it)) + } + style?.let { setStyle(it, onStyleLoaded) } + } + + fun setStyle(url: String, onLoaded: (() -> Unit)? = null) { + styleUrl = url + onStyleLoaded = onLoaded + map?.setStyle(styleBuilder(url)) { style -> + applyOverlays(style) + onLoaded?.invoke() + } + } + + private fun styleBuilder(style: String): Style.Builder { + val trimmed = style.trimStart() + return if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + Style.Builder().fromJson(style) + } else { + Style.Builder().fromUri(style) + } + } + + fun setCoverage(next: CarMapCoverage?) { + coverage = next + map?.style?.let { applyOverlays(it) } + } + + fun setTimer(endsAtMs: Long?, durationMs: Long?, color: Int?) { + timerBar?.setPhase(endsAtMs, durationMs, color) + } + + /// Keep the bar inside the area the host is not drawing over. + fun setTimerInsets(right: Int, top: Int, bottom: Int) { + val bar = timerBar ?: return + val params = bar.layoutParams as? FrameLayout.LayoutParams ?: return + if (params.rightMargin == right && + params.topMargin == top && + params.bottomMargin == bottom + ) { + return + } + params.rightMargin = right + params.topMargin = top + params.bottomMargin = bottom + bar.layoutParams = params + } + + fun setPositionMarker(bitmap: Bitmap, facesHeading: Boolean) { + markerBitmap = bitmap + markerFacesHeading = facesHeading + map?.style?.let { applyOverlays(it) } + } + + fun setPosition(lat: Double, lon: Double, heading: Double?) { + position = Triple(lat, lon, heading) + val style = map?.style ?: return + val source = style.getSourceAs(POSITION_SOURCE_ID) + if (source == null) { + applyOverlays(style) + return + } + source.setGeoJson(positionGeoJson(lat, lon)) + style.getLayerAs(POSITION_LAYER_ID) + ?.setProperties(PropertyFactory.iconRotate(markerRotation(heading))) + } + + private fun markerRotation(heading: Double?): Float { + if (!markerFacesHeading || heading == null) return 0f + val bearing = map?.cameraPosition?.bearing ?: 0.0 + return ((heading - bearing).toFloat() % 360f + 360f) % 360f + } + + private fun positionGeoJson(lat: Double, lon: Double): String = + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]}}""" + + fun setPings(next: String) { + pings = next + val source = map?.style?.getSourceAs(PINGS_SOURCE_ID) + if (source != null) source.setGeoJson(next) else map?.style?.let { applyOverlays(it) } + } + + private fun applyOverlays(style: Style) { + applyCoverage(style) + applyPings(style) + applyPositionMarker(style) + } + + private fun applyPositionMarker(style: Style) { + style.getLayer(POSITION_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(POSITION_SOURCE_ID)?.let { style.removeSource(it) } + + val bitmap = markerBitmap ?: return + val here = position ?: return + try { + style.addImage(POSITION_IMAGE_ID, bitmap) + style.addSource( + GeoJsonSource(POSITION_SOURCE_ID, positionGeoJson(here.first, here.second)), + ) + val layer = SymbolLayer(POSITION_LAYER_ID, POSITION_SOURCE_ID) + layer.setProperties( + PropertyFactory.iconImage(POSITION_IMAGE_ID), + PropertyFactory.iconRotate(markerRotation(here.third)), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + ) + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the position marker", e) + } + } + + /// Ping markers, above the coverage. + private fun applyPings(style: Style) { + style.getLayer(PINGS_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(PINGS_SOURCE_ID)?.let { style.removeSource(it) } + + val data = pings ?: return + try { + style.addSource(GeoJsonSource(PINGS_SOURCE_ID, data)) + val layer = CircleLayer(PINGS_LAYER_ID, PINGS_SOURCE_ID) + layer.setProperties( + PropertyFactory.circleColor(Expression.get("color")), + PropertyFactory.circleRadius(PING_RADIUS_DP), + PropertyFactory.circleStrokeWidth(PING_STROKE_DP), + PropertyFactory.circleStrokeColor("#000000"), + PropertyFactory.circleStrokeOpacity(PING_STROKE_OPACITY), + ) + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the ping markers", e) + } + } + + private fun applyCoverage(style: Style) { + style.getLayer(COVERAGE_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(COVERAGE_SOURCE_ID)?.let { style.removeSource(it) } + + val config = coverage ?: return + try { + style.addSource( + VectorSource( + COVERAGE_SOURCE_ID, + TileSet(TILEJSON_VERSION, config.tileUrl).apply { + minZoom = config.minZoom + maxZoom = config.maxZoom + }, + ), + ) + + val layer = FillLayer(COVERAGE_LAYER_ID, COVERAGE_SOURCE_ID) + .withSourceLayer(COVERAGE_SOURCE_LAYER) + layer.setProperties( + PropertyFactory.fillColor( + Expression.Converter.convert(config.fillColorExpression), + ), + PropertyFactory.fillOutlineColor( + Expression.Converter.convert(config.outlineColorExpression), + ), + PropertyFactory.fillOpacity(config.opacity), + ) + + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the coverage overlay", e) + } + } + + override fun onStop() { + mapView?.let { + it.onPause() + it.onStop() + it.onDestroy() + } + mapView = null + map = null + super.onStop() + } + + private companion object { + const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT + const val TILEJSON_VERSION = "2.2.0" + const val TIMER_BAR_DP = 8 + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt new file mode 100644 index 00000000..94f0e2de --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt @@ -0,0 +1,107 @@ +package net.meshmapper.app + +import androidx.car.app.CarContext +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/// Dart's channel for the car map. +object MeshMapperCarMapChannel { + private const val CHANNEL = "meshmapper/car_map" + + @Volatile + private var renderer: MeshMapperCarMap? = null + + /// Installed into `FAASurfaceProvider.factory` by [MeshMapperApplication]. + fun create(carContext: CarContext): MeshMapperCarMap = + MeshMapperCarMap(carContext).also { renderer = it } + + fun register(engine: FlutterEngine) { + MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + val map = renderer + when (call.method) { + // True if there is a car screen to draw on. + "isAttached" -> result.success(map != null) + + "setCamera" -> { + val lat = call.argument("lat") + val lon = call.argument("lon") + if (lat == null || lon == null) { + result.error("bad_args", "lat and lon are required", null) + } else { + map?.setCamera( + lat, + lon, + call.argument("bearing"), + call.argument("heading"), + call.argument("zoom"), + ) + result.success(map != null) + } + } + + "setStyle" -> { + val url = call.argument("url") + if (url.isNullOrEmpty()) { + result.error("bad_args", "url is required", null) + } else { + map?.setStyle(url) + result.success(map != null) + } + } + + "setCoverage" -> { + val tileUrl = call.argument("tileUrl") + map?.setCoverage( + if (tileUrl.isNullOrEmpty()) { + null + } else { + CarMapCoverage( + tileUrl = tileUrl, + fillColorExpression = + call.argument("fillColor") ?: "", + outlineColorExpression = + call.argument("outlineColor") ?: "", + opacity = + (call.argument("opacity") ?: 0.7).toFloat(), + minZoom = + (call.argument("minZoom") ?: 7.0).toFloat(), + maxZoom = + (call.argument("maxZoom") ?: 14.0).toFloat(), + ) + }, + ) + result.success(map != null) + } + + "setPositionMarker" -> { + val png = call.argument("png") + if (png == null) { + result.error("bad_args", "png is required", null) + } else { + map?.setPositionMarker( + png, + call.argument("facesHeading") ?: true, + ) + result.success(map != null) + } + } + + "setPings" -> { + map?.setPings(call.argument("geoJson") ?: "") + result.success(map != null) + } + + "setTimer" -> { + val endsAt = (call.argument("endsAtMs"))?.toLong() + val duration = (call.argument("durationMs"))?.toLong() + val color = (call.argument("color"))?.toInt() + map?.setTimer(endsAt, duration, color) + result.success(map != null) + } + + else -> result.notImplemented() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt new file mode 100644 index 00000000..cbcc019c --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt @@ -0,0 +1,30 @@ +package net.meshmapper.app + +import android.content.Context +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor + + +object MeshMapperEngine { + + const val ENGINE_ID = "meshmapper_main" + + private var usbService: MeshMapperUsbService? = null + + @Synchronized + fun obtain(context: Context): FlutterEngine { + FlutterEngineCache.getInstance().get(ENGINE_ID)?.let { return it } + val app = context.applicationContext + val engine = FlutterEngine(app) + registerAppScopedChannels(app, engine) + engine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault()) + FlutterEngineCache.getInstance().put(ENGINE_ID, engine) + return engine + } + + private fun registerAppScopedChannels(app: Context, engine: FlutterEngine) { + usbService = MeshMapperUsbService(app).also { it.configureFlutterEngine(engine) } + MeshMapperCarMapChannel.register(engine) + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt index c2279c47..d99fb03b 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt @@ -18,7 +18,6 @@ import android.hardware.usb.UsbManager import android.os.Build import android.os.Handler import android.os.Looper -import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -28,7 +27,7 @@ import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong class MeshMapperUsbService( - private val activity: FlutterActivity, + private val context: Context, ) { private companion object { const val USB_RECIPIENT_INTERFACE = 0x01 @@ -38,7 +37,7 @@ class MeshMapperUsbService( } private val usbManager by lazy { - activity.getSystemService(Context.USB_SERVICE) as UsbManager + context.getSystemService(Context.USB_SERVICE) as UsbManager } private val mainHandler = Handler(Looper.getMainLooper()) private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() @@ -148,7 +147,7 @@ class MeshMapperUsbService( closeUsbConnection() usbIoExecutor.shutdownNow() try { - activity.unregisterReceiver(permissionReceiver) + context.unregisterReceiver(permissionReceiver) } catch (_: IllegalArgumentException) {} } @@ -158,10 +157,10 @@ class MeshMapperUsbService( addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) + context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) } else { @Suppress("DEPRECATION") - activity.registerReceiver(permissionReceiver, filter) + context.registerReceiver(permissionReceiver, filter) } } @@ -207,9 +206,9 @@ class MeshMapperUsbService( pendingConnectBaudRate = baudRate val permissionIntent = PendingIntent.getBroadcast( - activity, + context, 0, - Intent(USB_PERMISSION_ACTION).setPackage(activity.packageName), + Intent(USB_PERMISSION_ACTION).setPackage(context.packageName), pendingIntentFlags(), ) usbManager.requestPermission(device, permissionIntent) diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..1a5248e1 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/car_start.png b/assets/car_start.png new file mode 100644 index 0000000000000000000000000000000000000000..3e41042886980a006878e1581468b3620b6800fe GIT binary patch literal 469 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV4UOW;uumf=j|Owzrzj!t_KYt z{R=mn<*FaK;K7lTtF0Kg{{{D}s$63N>VShE<IdB)WL3G!~K!L?7D*70RbnQ2E~>NhQr?-Z5qy+0;T-wQ?l3% zPMvRJcy!%?;iCY{hi)$xrjB@K#v{%G2jpv#5S-?FwoHsi);BUdQsj8R^j-wWfM^lm zcVzG{+s?rvu%C^oqg|mvx*`>!&DT~Nr0wAMsrGY@%N#FU)4h}7;YV|E1*<;-4kbSx za&q+V-^pR=JV*;vS0_wJ5Ty6btReXc8O^p@9;r96qkKOef9xi5-u#;+d zc=$fUvHm!Q!|u!mH9t5F>b@{Rg#8;nHpl&4AHoO^Vg`l>)9M)B#&e`_vTsQP346Nw KxvXxpM5u|W4@xi=gPj`m1Y)F+nS{YvB@-p$|?k5iz ekvz`8@MD2|M5jr|r@emhAaPGuKbLh*2~7a5^mt1E literal 0 HcmV?d00001 diff --git a/lib/main.dart b/lib/main.dart index 98cf00f0..badf9739 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -67,9 +68,16 @@ void main() async { } debugLog('[APP] Noise floor session adapters registered'); - // Request permissions on startup for mobile platforms + // Deferred rather than awaited. MeshMapperEngine starts this isolate before + // any Activity attaches — an Android Auto head unit can start the process with + // none at all — and permission_handler needs an Activity to raise its dialog + // from. The first frame is the earliest point one is guaranteed. In a headless + // car start no frame is produced, which is exactly when we should not prompt; + // this callback stays pending and fires if the user later opens the app. if (!kIsWeb) { - await _requestPermissions(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_requestPermissions()); + }); } // Clean up any orphaned background service from a previous session @@ -111,13 +119,20 @@ Future _loadInitialThemeMode() async { Future _requestPermissions() async { debugLog('[APP] Requesting permissions...'); - if (Platform.isIOS) { - // iOS: Use Geolocator for location (permission_handler is unreliable on iOS) - // and trigger Core Bluetooth to prompt for Bluetooth permission - await _requestiOSPermissions(); - } else { - // Android: Use permission_handler - await _requestAndroidPermissions(); + try { + if (Platform.isIOS) { + // iOS: Use Geolocator for location (permission_handler is unreliable on iOS) + // and trigger Core Bluetooth to prompt for Bluetooth permission + await _requestiOSPermissions(); + } else { + // Android: Use permission_handler + await _requestAndroidPermissions(); + } + } catch (e) { + // Never fatal. A permission we could not ask for surfaces later as a + // capability the user can grant from Settings; taking startup down over it + // would cost far more than the missing prompt. + debugLog('[APP] Permission request failed (non-fatal): $e'); } } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 80280de4..8e600f77 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -58,7 +58,11 @@ import '../services/external_commands/external_session_commands.dart'; import '../services/external_surfaces/geo/external_surface_geo_builder.dart'; import '../services/live_activity/live_activity_models.dart'; import '../services/live_activity/live_activity_service.dart'; +import '../services/auto/android_auto_service.dart'; +import '../services/auto/car_map_channel.dart'; import '../services/watch/watch_bridge_service.dart'; +import '../widgets/map_widget.dart' + show MapStyleExtension, gpsMarkerFacesHeading; import '../services/watch/watch_models.dart'; import '../services/custom_api_service.dart'; import '../services/portal_account_service.dart'; @@ -166,6 +170,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); final AppIntentBridgeService _appIntentBridge = AppIntentBridgeService(); + final AndroidAutoService _androidAuto = AndroidAutoService(); bool _hasEverPairedWatch = false; /// Last position handed to the watch, kept so a dropped GPS fix leaves the @@ -625,6 +630,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { _autoSaveOfflinePings(); } + } else if (state == AppLifecycleState.detached) { + // The engine now outlives MainActivity (see MeshMapperEngine), so the + // Activity going away no longer tears anything down on its own. Swiping + // the app from Recents with no session running used to end the process and + // with it the BLE link; it now leaves the radio connected to a device + // nobody is watching, draining both batteries until something else + // disconnects it. + // + // Deliberately conditional on there being no session: a wardriving run is + // pinned by flutter_background_service's foreground service and is + // *expected* to outlive the UI — that is the whole point of it, and of the + // Android Auto surface that can now drive it with no Activity at all. + if (!_autoPingEnabled) { + debugLog('[APP] Detached with no session — releasing connection'); + unawaited(disconnect()); + } else { + debugLog('[APP] Detached during a session — keeping connection'); + } } } @@ -1455,6 +1478,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // The watch mirrors state even with no session running — otherwise you // could never start one from the wrist. _scheduleWatchSync(immediate: immediate); + _androidAuto.schedule(immediate: immediate); if (!_liveActivityService.isSupportedPlatform) return; _liveActivityService.schedule( _buildLiveActivitySnapshot, @@ -2504,6 +2528,61 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } + /// A tap on the head unit, admitted exactly as a wrist tap or a Siri phrase + /// is. + /// + /// The only car-specific work is resolving the start mode. The strip shows + /// one button and no mode picker — choosing a mode is setup, which the + /// distraction rules forbid while driving — so the command arrives with none + /// and inherits whatever the status row is already promising. That has to + /// happen here rather than inside [resolveExternalSessionTransition]: the + /// shared resolver refuses a modeless start on purpose, and teaching it a + /// third surface's default would put a phone preference inside the rules + /// every surface shares. + ExternalCommandReason? _handleAutoCommand(ExternalSessionCommand command) { + var admitted = command; + if (command.kind == ExternalSessionCommandKind.startSession && + command.mode == null) { + final requested = resolveWatchRequestedStartMode( + requestedMode: null, + isConnected: isConnected, + txAllowed: txAllowed, + ); + final refusal = requested.refusal; + if (refusal != null) return ExternalCommandReason.other(refusal); + admitted = ExternalSessionCommand( + id: command.id, + source: command.source, + kind: command.kind, + issuedAt: command.issuedAt, + expiresAt: command.expiresAt, + mode: resolveLegacyWatchStartMode( + currentMode: _resolvedWatchSessionMode.name, + isConnected: isConnected, + txAllowed: txAllowed, + ), + sessionId: command.sessionId, + ); + } + + final admission = admitExternalSessionCommand(admitted); + if (admission.disposition == ExternalCommandDisposition.refused) { + return admission.reason; + } + if (admission.shouldExecute) { + unawaited(executeExternalSessionCommand(admitted, admission).then( + (completion) { + if (!completion.success) { + _emitWatchFailure( + completion.message?.compactText ?? 'Command failed', + ); + } + }, + )); + } + return null; + } + void _emitWatchFailure(String message) { if (_isDisposed) return; _watchCue = WatchHapticCue( @@ -2513,6 +2592,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { message: message, ); _scheduleWatchSync(immediate: true); + _androidAuto.schedule(immediate: true); } void _handleWatchDiagnosticsChanged() { @@ -3042,6 +3122,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.addListener(_handleLiveActivityTimerChange); _timerListenerAttached = true; } + if (_androidAuto.isSupportedPlatform) { + _androidAuto.attach( + snapshotBuilder: _buildWatchSnapshot, + commandHandler: _handleAutoCommand, + onRefusal: (reason) => _emitWatchFailure(reason.compactText), + carMapSettingsBuilder: () => CarMapSettings.from( + styleUrl: + MapStyleExtension.fromString(_preferences.mapStyle).styleUrl, + zoneCode: zoneCode, + tilesEnabled: _preferences.mapTilesEnabled, + gridSize: _preferences.coverageGridSize, + colorVisionType: _preferences.colorVisionType, + opacity: _preferences.coverageOverlayOpacity, + mapAlwaysNorth: _preferences.mapAlwaysNorth, + mapRotationLocked: _preferences.mapRotationLocked, + markerStyle: _preferences.gpsMarkerStyle, + markerFacesHeading: + gpsMarkerFacesHeading(_preferences.gpsMarkerStyle), + nodeName: displayDeviceName, + ), + ); + } if (_watchBridge.isSupportedPlatform) { _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged); _watchBridge.attachCommandHandler( @@ -10667,6 +10769,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _appIntentBridge.dispose(); _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); + _androidAuto.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); _connectionSubscription?.cancel(); diff --git a/lib/services/auto/android_auto_service.dart b/lib/services/auto/android_auto_service.dart new file mode 100644 index 00000000..47d3f105 --- /dev/null +++ b/lib/services/auto/android_auto_service.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:uuid/uuid.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../../utils/serial_task_gate.dart'; +import '../../widgets/map_widget.dart' show renderGpsMarkerPng; +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/external_surface_color.dart'; +import '../external_surfaces/external_surface_publisher.dart'; +import '../watch/watch_models.dart'; +import 'auto_glance_view.dart'; +import 'car_map_channel.dart'; + +typedef AutoCommandHandler = FutureOr Function( + ExternalSessionCommand command, +); +typedef AutoCommandRefusalHandler = void Function(ExternalCommandReason reason); + +class AndroidAutoService { + AndroidAutoService({ + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + @visibleForTesting CarMapChannel? carMap, + @visibleForTesting Future Function(String style)? markerRenderer, + }) : _debounceDelay = debounceDelay, + _carMap = carMap ?? CarMapChannel(), + _renderMarker = markerRenderer ?? renderGpsMarkerPng { + _pane = ExternalSurfacePublisher( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform && _connected && !_isDisposed, + preflightPolicy: + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency, + payloadBuilder: _buildView, + fingerprintBuilder: (view) => view.fingerprint, + urgencyKeyBuilder: (_) => _viewUnderConsideration?.urgencyKey, + publish: _publishPane, + onQueueError: (error) { + debugLog('[AUTO] Update queue failed: $error'); + }, + restartDebounce: false, + ); + } + + static const Duration defaultDebounceDelay = Duration(milliseconds: 250); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 1); + + static const Duration _markerRenderTimeout = Duration(seconds: 5); + + final Duration _debounceDelay; + final CarMapChannel _carMap; + + final Future Function(String style) _renderMarker; + + late final ExternalSurfacePublisher _pane; + + final SerialTaskGate _mapGate = SerialTaskGate(); + Timer? _mapDebounce; + + AutoGlanceView? _viewUnderConsideration; + + CarMapSettingsBuilder? _carMapSettingsBuilder; + + FlutterAndroidAuto? _auto; + + ExternalSurfaceSnapshotBuilder? _snapshotBuilder; + AutoCommandHandler? _commandHandler; + AutoCommandRefusalHandler? _onRefusal; + + String? _lastMarkerStyle; + + bool _connected = false; + bool _isDisposed = false; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + @visibleForTesting + bool get isConnected => _connected; + + void attach({ + required ExternalSurfaceSnapshotBuilder snapshotBuilder, + required AutoCommandHandler commandHandler, + AutoCommandRefusalHandler? onRefusal, + CarMapSettingsBuilder? carMapSettingsBuilder, + }) { + if (!isSupportedPlatform || _isDisposed) return; + + _snapshotBuilder = snapshotBuilder; + _commandHandler = commandHandler; + _onRefusal = onRefusal; + _carMapSettingsBuilder = carMapSettingsBuilder; + + _auto = FlutterAndroidAuto() + ..addListenerOnConnectionChange(_handleConnectionChange); + debugLog('[AUTO] Android Auto surface attached'); + } + + void _handleConnectionChange(ConnectionStatusTypes status) { + if (_isDisposed) return; + debugLog('[AUTO] Connection status: ${status.name}'); + + // `background` means the car is still attached but showing another app. + // Keep publishing: the driver can switch back at any moment and must not + // find a stale pane waiting. + final connected = status == ConnectionStatusTypes.connected || + status == ConnectionStatusTypes.background; + if (connected == _connected) return; + _connected = connected; + + if (!connected) { + _mapDebounce?.cancel(); + _mapDebounce = null; + // Force the next connection to send a fresh root template rather than an + // update against a template history the host no longer has. + _pane.resetPublishedState(); + return; + } + + schedule(immediate: true); + } + + /// Ask for a redraw. Cheap to call on every provider notification. + void schedule({bool immediate = false}) { + if (!isSupportedPlatform || _isDisposed || !_connected) return; + final snapshotBuilder = _snapshotBuilder; + if (snapshotBuilder == null) return; + _pane.schedule( + snapshotBuilder, + preflightKeyBuilder: _noPreflightKey, + immediate: immediate, + ); + _scheduleMapSync(immediate: immediate); + } + + static Object? _noPreflightKey() => null; + + AutoGlanceView _buildView(WatchSnapshot snapshot) { + final view = buildAutoGlanceView( + snapshot, + now: DateTime.now(), + nodeName: _carMapSettingsBuilder?.call().nodeName, + ); + _viewUnderConsideration = view; + return view; + } + + Future _publishPane( + ExternalSurfacePublication publication, + ) async { + try { + await FlutterAndroidAuto.setRootTemplate( + template: AAMapWithContentTemplate( + id: autoGlanceMapTemplateId, + contentTemplate: _buildTemplate(publication.payload), + mapActions: _buildMapActions(publication.payload), + ), + ); + return const ExternalSurfacePublishResult.published(); + } catch (e) { + debugLog('[AUTO] Publish failed: $e'); + return const ExternalSurfacePublishResult.rejected(); + } + } + + void _scheduleMapSync({required bool immediate}) { + if (immediate) { + _mapDebounce?.cancel(); + _mapDebounce = null; + unawaited(_enqueueMapSync()); + return; + } + _mapDebounce ??= Timer(_debounceDelay, () { + _mapDebounce = null; + unawaited(_enqueueMapSync()); + }); + } + + Future _enqueueMapSync() => _mapGate.run(() async { + if (_isDisposed || !_connected) return; + final snapshot = _snapshotBuilder?.call(); + final settings = _carMapSettingsBuilder?.call(); + if (snapshot == null || settings == null) return; + await _syncMap(snapshot, settings); + }).catchError((Object e) { + debugLog('[AUTO] Map sync failed: $e'); + }); + + Future _syncMap(WatchSnapshot snapshot, CarMapSettings settings) async { + final you = snapshot.geo.you; + if (you != null) { + await _carMap.setCamera( + lat: you.lat, + lon: you.lon, + bearing: settings.northUp ? null : you.headingDeg, + heading: you.headingDeg, + ); + } + + await _carMap.setStyle(settings.styleUrl); + await _carMap.setCoverage(settings.coverage); + await _carMap.setPings(snapshot.geo.pings); + + final endsAt = snapshot.core.phaseEndsAt; + final durationMs = snapshot.phaseDurationMs; + await _carMap.setTimer( + endsAt: durationMs == null ? null : endsAt, + durationMs: endsAt == null ? null : durationMs, + argbColor: _argb(snapshot.pingColor), + ); + + await _syncPositionMarker(settings); + } + + Future _syncPositionMarker(CarMapSettings settings) async { + if (settings.markerStyle == _lastMarkerStyle) return; + try { + final png = await _renderMarker(settings.markerStyle) + .timeout(_markerRenderTimeout); + await _carMap.setPositionMarker( + style: settings.markerStyle, + png: png, + facesHeading: settings.markerFacesHeading, + ); + _lastMarkerStyle = settings.markerStyle; + } catch (e) { + + debugLog('[AUTO] Could not render the position marker: $e'); + } + } + + AAPaneTemplate _buildTemplate(AutoGlanceView view) { + return AAPaneTemplate( + id: autoGlanceTemplateId, + title: autoGlanceTitle, + items: [ + for (final row in view.rows) + AAPaneItem(title: row.title, detail: row.detail), + ], + ); + } + + List _buildMapActions(AutoGlanceView view) { + final renderedSessionId = view.sessionId; + final running = renderedSessionId != null; + + return [ + AAMapAction( + id: autoGlanceToggleActionId, + title: running ? 'Stop' : 'Start', + imageUrl: running ? 'assets/car_stop.png' : 'assets/car_start.png', + isPrimary: true, + onPress: () => _send( + ExternalSessionCommand( + id: const Uuid().v4(), + source: ExternalCommandSource.androidAuto, + kind: running + ? ExternalSessionCommandKind.stopSession + : ExternalSessionCommandKind.startSession, + issuedAt: DateTime.now(), + sessionId: running ? renderedSessionId : null, + ), + ), + ), + ]; + } + + void _send(ExternalSessionCommand command) { + final handler = _commandHandler; + if (handler == null || _isDisposed) return; + debugLog('[AUTO] Command: ${command.kind.name}'); + + final refusal = handler(command); + if (refusal is Future) { + unawaited(refusal.then(_handleRefusal)); + return; + } + _handleRefusal(refusal); + } + + void _handleRefusal(ExternalCommandReason? reason) { + if (reason == null || _isDisposed) return; + debugLog('[AUTO] Refused: ${reason.compactText}'); + _onRefusal?.call(reason); + schedule(immediate: true); + } + + void dispose() { + _isDisposed = true; + _mapDebounce?.cancel(); + _mapDebounce = null; + _pane.dispose(); + if (isSupportedPlatform) { + try { + _auto?.closeConnection(); + } catch (_) {} + } + _auto = null; + _snapshotBuilder = null; + _commandHandler = null; + _onRefusal = null; + _carMapSettingsBuilder = null; + _viewUnderConsideration = null; + } +} + +int? _argb(ExternalSurfaceColor? color) { + if (color == null) return null; + int channel(double v) => (v * 255.0).round().clamp(0, 255); + return (0xFF << 24) | + (channel(color.r) << 16) | + (channel(color.g) << 8) | + channel(color.b); +} diff --git a/lib/services/auto/auto_glance_view.dart b/lib/services/auto/auto_glance_view.dart new file mode 100644 index 00000000..bafe28b3 --- /dev/null +++ b/lib/services/auto/auto_glance_view.dart @@ -0,0 +1,90 @@ +import '../live_activity/live_activity_models.dart'; +import '../watch/watch_models.dart'; + +class AutoGlanceView { + const AutoGlanceView({ + required this.rows, + required this.sessionId, + required this.fingerprint, + required this.urgencyKey, + }); + + final List rows; + final String? sessionId; + final String fingerprint; + final String urgencyKey; +} + +class AutoGlanceRow { + const AutoGlanceRow({required this.title, required this.detail}); + + final String title; + final String detail; +} + +const String autoGlanceFallbackTitle = 'MeshMapper'; +const String autoGlanceTemplateId = 'meshmapper-glance'; +const String autoGlanceMapTemplateId = 'meshmapper-glance-map'; +const String autoGlanceToggleActionId = 'meshmapper-glance-toggle'; +const String autoGlanceTitle = ''; + +AutoGlanceView buildAutoGlanceView( + WatchSnapshot snapshot, { + required DateTime now, + String? nodeName, +}) { + final core = snapshot.core; + final controls = snapshot.controls; + + final title = (nodeName == null || nodeName.isEmpty) + ? autoGlanceFallbackTitle + : nodeName; + + final rows = [ + AutoGlanceRow( + title: title, + detail: [ + _sessionDetail(snapshot, now: now), + _trafficDetail(core), + if (!core.isConnected) 'Disconnected', + if (controls.blockedReason case final reason? + when reason.isNotEmpty && core.isConnected) + reason, + ].join(' · '), + ), + ]; + + final cue = snapshot.cue; + return AutoGlanceView( + rows: rows, + sessionId: controls.isSessionActive ? core.sessionId : null, + fingerprint: rows.map((row) => '${row.title}=${row.detail}').join('|'), + urgencyKey: [ + core.sessionId, + controls.isSessionActive.toString(), + core.phase.wireValue, + core.isConnected.toString(), + if (cue != null && cue.isPresentableAt(now)) cue.id else '', + controls.blockedReason ?? '', + ].join('|'), + ); +} + +String _sessionDetail(WatchSnapshot snapshot, {required DateTime now}) { + final core = snapshot.core; + final cue = snapshot.cue; + final message = cue != null && cue.isPresentableAt(now) ? cue.message : null; + final detail = message ?? core.phaseDetail; + if (detail == null || detail.isEmpty) return core.phaseTitle; + return '${core.phaseTitle} · $detail'; +} + +String _trafficDetail(LiveActivitySnapshot core) { + final parts = [ + 'TX ${core.txCount}', + 'RX ${core.rxCount}', + 'Disc ${core.discoveryCount}', + ]; + if (core.queueSize > 0) parts.add('Queue ${core.queueSize}'); + return parts.join(' · '); +} diff --git a/lib/services/auto/car_map_channel.dart b/lib/services/auto/car_map_channel.dart new file mode 100644 index 00000000..e31c6933 --- /dev/null +++ b/lib/services/auto/car_map_channel.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/coverage_tile_palette.dart'; +import '../external_surfaces/external_surface_color.dart'; +import '../external_surfaces/geo/external_surface_geo_models.dart'; + +import '../../utils/debug_logger_io.dart'; + +typedef CarMapSettingsBuilder = CarMapSettings Function(); + +@immutable +class CarMapSettings { + const CarMapSettings({ + required this.styleUrl, + required this.coverage, + required this.northUp, + required this.markerStyle, + required this.markerFacesHeading, + required this.nodeName, + }); + + + final String styleUrl; + final CarMapCoverage? coverage; + final bool northUp; + final String markerStyle; + final bool markerFacesHeading; + final String? nodeName; + + static CarMapSettings from({ + required String styleUrl, + required String? zoneCode, + required bool tilesEnabled, + required int gridSize, + required String colorVisionType, + required double opacity, + required bool mapAlwaysNorth, + required bool mapRotationLocked, + required String markerStyle, + required bool markerFacesHeading, + String? nodeName, + }) => + CarMapSettings( + styleUrl: styleUrl, + coverage: CarMapCoverage.forZone( + zoneCode: zoneCode, + tilesEnabled: tilesEnabled, + gridSize: gridSize, + colorVisionType: colorVisionType, + opacity: opacity, + ), + northUp: mapAlwaysNorth || mapRotationLocked, + markerStyle: markerStyle, + markerFacesHeading: markerFacesHeading, + nodeName: nodeName, + ); +} + +@immutable +class CarMapCoverage { + const CarMapCoverage({ + required this.tileUrl, + required this.fillColor, + required this.outlineColor, + required this.opacity, + this.minZoom = 7, + this.maxZoom = 14, + }); + + final String tileUrl; + + final String fillColor; + final String outlineColor; + + final double opacity; + final double minZoom; + final double maxZoom; + + static CarMapCoverage? forZone({ + required String? zoneCode, + required int gridSize, + required String colorVisionType, + required double opacity, + bool tilesEnabled = true, + }) { + + if (!tilesEnabled) return null; + if (zoneCode == null || zoneCode.isEmpty || opacity <= 0) return null; + final zone = zoneCode.toLowerCase(); + return CarMapCoverage( + tileUrl: + 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize', + fillColor: jsonEncode( + CoverageTilePalette.fillColorExpression(colorVisionType), + ), + outlineColor: jsonEncode( + CoverageTilePalette.borderColorExpression(colorVisionType), + ), + opacity: opacity, + ); + } + + Map toArguments() => { + 'tileUrl': tileUrl, + 'fillColor': fillColor, + 'outlineColor': outlineColor, + 'opacity': opacity, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + }; + + @override + bool operator ==(Object other) => + other is CarMapCoverage && + other.tileUrl == tileUrl && + other.fillColor == fillColor && + other.outlineColor == outlineColor && + other.opacity == opacity && + other.minZoom == minZoom && + other.maxZoom == maxZoom; + + @override + int get hashCode => Object.hash( + tileUrl, + fillColor, + outlineColor, + opacity, + minZoom, + maxZoom, + ); +} + +class CarMapChannel { + CarMapChannel({@visibleForTesting MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(_channelName); + + static const String _channelName = 'meshmapper/car_map'; + + final MethodChannel _channel; + + double? _lastLat; + double? _lastLon; + double? _lastHeading; + String? _lastMarkerStyle; + CarMapCoverage? _lastCoverage; + bool _hasSentCoverage = false; + String? _lastPings; + String? _lastTimer; + + Future setCamera({ + required double lat, + required double lon, + double? bearing, + double? heading, + double? zoom, + }) async { + // Heading is part of the dedupe: standing still while turning still has to + // move the marker. + if (_sameToFiveDecimals(lat, _lastLat) && + _sameToFiveDecimals(lon, _lastLon) && + heading == _lastHeading) { + return; + } + _lastLat = lat; + _lastLon = lon; + _lastHeading = heading; + await _invoke('setCamera', { + 'lat': lat, + 'lon': lon, + 'bearing': bearing, + 'heading': heading, + 'zoom': zoom, + }); + } + + Future setStyle(String url) async { + if (url.isEmpty) return; + await _invoke('setStyle', {'url': url}); + } + + Future setCoverage(CarMapCoverage? coverage) async { + if (_hasSentCoverage && coverage == _lastCoverage) return; + _hasSentCoverage = true; + _lastCoverage = coverage; + await _invoke('setCoverage', coverage?.toArguments() ?? const {}); + } + + Future setPings(List pings) async { + final geoJson = encodePingsGeoJson(pings); + if (geoJson == _lastPings) return; + _lastPings = geoJson; + await _invoke('setPings', {'geoJson': geoJson}); + } + + Future setTimer({ + DateTime? endsAt, + int? durationMs, + int? argbColor, + }) async { + final key = '${endsAt?.millisecondsSinceEpoch}|$durationMs|$argbColor'; + if (key == _lastTimer) return; + _lastTimer = key; + await _invoke('setTimer', { + 'endsAtMs': endsAt?.millisecondsSinceEpoch, + 'durationMs': durationMs, + 'color': argbColor, + }); + } + + Future setPositionMarker({ + required String style, + required Uint8List png, + required bool facesHeading, + }) async { + if (style == _lastMarkerStyle) return; + _lastMarkerStyle = style; + await _invoke('setPositionMarker', { + 'png': png, + 'facesHeading': facesHeading, + }); + } + + Future _invoke(String method, Map args) async { + try { + await _channel.invokeMethod(method, args); + } on MissingPluginException { + } catch (e) { + debugLog('[AUTO] Car map $method failed: $e'); + } + } + + static bool _sameToFiveDecimals(double value, double? previous) => + previous != null && (value - previous).abs() < 0.00001; +} + +String encodePingsGeoJson(List pings) => jsonEncode({ + 'type': 'FeatureCollection', + 'features': [ + for (final ping in pings) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [ping.lon, ping.lat], + }, + 'properties': { + 'color': _hex(ping.color), + 'kind': ping.kind, + }, + }, + ], + }); + +String _hex(ExternalSurfaceColor color) { + String channel(double v) => + (v * 255.0).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + return '#${channel(color.r)}${channel(color.g)}${channel(color.b)}'; +} diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart index f9e943ed..ed84a400 100644 --- a/lib/services/external_commands/external_command_models.dart +++ b/lib/services/external_commands/external_command_models.dart @@ -2,7 +2,8 @@ import 'package:flutter/foundation.dart'; enum ExternalCommandSource { watch, - siri; + siri, + androidAuto; static ExternalCommandSource? fromWire(String value) { for (final source in values) { diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d25befd0..904a03ba 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -44,6 +44,23 @@ const _satelliteStyleJson = /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). const _defaultFontStack = ['Noto Sans Regular']; +Future renderGpsMarkerPng(String style) => _renderPainterToPng( + _gpsMarkerPainter(style), + const Size(48, 48), + ); + +bool gpsMarkerFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'chomper'; + +CustomPainter _gpsMarkerPainter(String style) => switch (style) { + 'car' => const _CarMarkerPainter(), + 'bike' => const _BikeMarkerPainter(), + 'boat' => const _BoatMarkerPainter(), + 'walk' => const _WalkMarkerPainter(), + 'chomper' => const _ChomperMarkerPainter(), + _ => const _ArrowPainter(), + }; + /// Image-name constants for the marker bitmaps registered via /// `controller.addImage()` and referenced by `SymbolOptions.iconImage`. /// diff --git a/pubspec.lock b/pubspec.lock index 86d9a5f9..0bb6f3ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: app_links_platform_interface - sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.0.2" app_links_web: dependency: transitive description: @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -430,6 +430,13 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_carplay: + dependency: "direct main" + description: + path: "third_party/flutter_carplay" + relative: true + source: path + version: "1.6.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -526,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -819,26 +834,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -903,6 +918,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1248,10 +1271,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.7" timezone: dependency: transitive description: @@ -1344,10 +1367,34 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1445,5 +1492,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 4e38cac8..4f01c386 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=3.3.0 <4.0.0' + sdk: '>=3.6.0 <4.0.0' dependencies: flutter: @@ -44,6 +44,7 @@ dependencies: app_links: ^6.3.0 # Keychain / Android Keystore storage for the portal app token flutter_secure_storage: ^9.2.0 + flutter_carplay: ^1.6.5 dev_dependencies: flutter_test: @@ -62,9 +63,27 @@ dev_dependencies: # an uncaught C++ std::domain_error (SIGABRT) when it is degenerate/zero-sized. # That throw cannot be caught from Dart, so the fix must live in the plugin. # Re-apply the guard when bumping maplibre_gl. See DEVELOPMENT.md. +# Vendored, patched copy of flutter_carplay 1.6.5, used for its Android Auto +# half only. Three deltas from the upstream pub.dev release, all marked "DELTA" +# in the vendored source: +# A. The `ios:` plugin platform entry and the ios/ directory are removed. +# MeshMapper ships on the App Store; upstream would link +# SwiftFlutterCarplayPlugin into Runner for a CarPlay surface we neither +# want nor hold Apple's entitlement for. +# B. AndroidAutoService gains FAAEngineProvider, so the app can supply the +# engine. Upstream builds a bare headless one when the cache is empty, +# which in this app is the wrong engine — ours owns the USB serial and +# tile cache channels (see MeshMapperEngine.kt). +# C. createHostValidator no longer returns ALLOW_ALL_HOSTS_VALIDATOR in +# release builds; that is debug-only per Android's docs and lets any app +# on the device drive the car surface. +# example/, previews/ and test/ are not vendored. Re-apply all three deltas when +# bumping flutter_carplay. See DEVELOPMENT.md. dependency_overrides: maplibre_gl: path: third_party/maplibre_gl + flutter_carplay: + path: third_party/flutter_carplay flutter_launcher_icons: android: true @@ -82,3 +101,9 @@ flutter: - assets/wardrive.png - assets/transmitted_packet.mp3 - assets/received_packet.mp3 + # Android Auto map action strip icons. The strip is icon-only — + # ACTIONS_CONSTRAINTS_MAP leaves maxCustomTitles at 0 — so these are not + # decoration, they are the buttons. White on transparent so the host tints + # them to its own theme. + - assets/car_start.png + - assets/car_stop.png diff --git a/test/services/auto/android_auto_service_test.dart b/test/services/auto/android_auto_service_test.dart new file mode 100644 index 00000000..8a7310e1 --- /dev/null +++ b/test/services/auto/android_auto_service_test.dart @@ -0,0 +1,423 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/android_auto_service.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/auto/car_map_channel.dart'; +import 'package:mesh_mapper/services/external_surfaces/geo/external_surface_geo_models.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +const String _methodChannelName = 'com.oguzhnatly.flutter_android_auto'; +const String _carMapChannelName = 'meshmapper/car_map_ordering_test'; +const String _eventChannelName = 'com.oguzhnatly.flutter_android_auto/event'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + bool withPosition = false, + int txCount = 0, + bool isSessionActive = true, + String sessionId = 'session-1', + LiveActivityPhase phase = LiveActivityPhase.listening, + String phaseTitle = 'Listening', +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: 'Active', + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: 'Waiting for echoes', + isConnected: true, + zoneCode: 'SEA', + txCount: txCount, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: ExternalSurfaceGeo( + pings: const [], + repeaters: const [], + heard: const [], + linkedRepeaterIds: const [], + you: withPosition + ? ExternalSurfacePosition(lat: 47.6, lon: -122.3, fixedAt: _now) + : null, + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + ), + updatedAt: _now, + ); + +/// Records what the surface sends native, so a test can assert on the sequence +/// rather than on the plugin's internals. +class _NativeRecorder { + final List calls = []; + final List> templates = >[]; + + void install() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel(_methodChannelName), + (MethodCall call) async { + calls.add(call.method); + final args = call.arguments; + if (args is Map && args['template'] is Map) { + templates.add(args['template'] as Map); + } + return true; + }, + ); + // The event channel's listen/cancel arrive as method calls on a channel of + // the same name. + messenger.setMockMethodCallHandler( + const MethodChannel(_eventChannelName), + (MethodCall call) async => null, + ); + } + + void clear() { + calls.clear(); + templates.clear(); + } +} + +Future _emit(Object event) async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _eventChannelName, + codec.encodeSuccessEnvelope(event), + (_) {}, + ); +} + +Future _connect() => _emit({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'connected'}, + }); + +Future _disconnect() => _emit({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'disconnected'}, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _NativeRecorder native; + late AndroidAutoService service; + late WatchSnapshot current; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + // Static across instances in the plugin; a leftover entry would make an + // update look like it matched when it did not. + FlutterAndroidAutoController.templateHistory.clear(); + native = _NativeRecorder()..install(); + current = _snapshot(); + }); + + tearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + + AndroidAutoService build({ + Duration debounce = Duration.zero, + Duration floor = const Duration(seconds: 1), + AutoCommandHandler? onCommand, + AutoCommandRefusalHandler? onRefusal, + CarMapSettingsBuilder? settings, + Future Function(String)? markerRenderer, + }) { + service = AndroidAutoService( + debounceDelay: debounce, + minimumNonUrgentInterval: floor, + carMap: CarMapChannel(channel: const MethodChannel(_carMapChannelName)), + markerRenderer: markerRenderer, + )..attach( + snapshotBuilder: () => current, + commandHandler: onCommand ?? (_) => null, + onRefusal: onRefusal, + carMapSettingsBuilder: settings, + ); + return service; + } + + /// The ordering this pins is load-bearing and easy to lose. + /// + /// `_publish` runs `_syncMap` *above* the fingerprint gate. A settings change + /// alters no pane text, so its fingerprint is unchanged and the gate returns + /// early — if the map sync sat below it, every settings change would be + /// swallowed and the head unit would quietly keep the old style, palette and + /// coverage. That failure is invisible in tests and only shows up in a car. + group('settings reach the map even when the pane does not change', () { + late List carMapCalls; + + setUp(() { + carMapCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(_carMapChannelName), + (call) async { + carMapCalls.add(call.method); + return true; + }, + ); + }); + + /// Rendering the marker rasterizes a CustomPainter, which needs the + /// engine's raster thread — and a backgrounded phone, which is exactly the + /// car case, may not be pumping it. When that render ran before the camera, + /// one stall left the camera unset and the head unit showed a whole-world + /// view over null island. Nothing cosmetic may precede the thing that + /// decides where the map looks. + test('the camera is sent even when the marker cannot be rendered', + () async { + build( + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + markerRenderer: (_) => Future.error( + StateError('no rasterizer'), + ), + ); + current = _snapshot(withPosition: true); + await _connect(); + await pumpEventQueue(); + + expect(carMapCalls, contains('setCamera')); + }); + + test('a publish with an unchanged fingerprint still syncs the map', + () async { + build( + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + ); + await _connect(); + await pumpEventQueue(); + + // Everything the pane renders is identical, so the template is skipped. + native.clear(); + carMapCalls.clear(); + service.schedule(immediate: true); + await pumpEventQueue(); + + expect(native.calls, isEmpty, reason: 'the pane really is unchanged'); + expect(carMapCalls, contains('setStyle'), + reason: 'but the map must still be told'); + }); + + /// The pane's update floor is quota insurance for template refreshes. The + /// map costs no quota, and a driver's camera must not inherit a text + /// throttle: at a one-second floor the map would lag a moving vehicle by up + /// to a second, and at the thirty here it would look frozen. This is why + /// the two run on separate paths rather than sharing one publisher. + test('the pane floor does not throttle the map', () async { + build( + floor: const Duration(seconds: 30), + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + ); + await _connect(); + await pumpEventQueue(); + native.clear(); + carMapCalls.clear(); + + // A counter change: the pane's text differs, but nothing in it is urgent, + // so the floor holds the template back. + current = _snapshot(txCount: 1); + service.schedule(immediate: true); + await pumpEventQueue(); + + expect(native.calls, isEmpty, reason: 'the pane is inside its floor'); + expect(carMapCalls, contains('setStyle'), + reason: 'the map is not, and never was'); + }); + }); + + test('publishes nothing until the car connects', () async { + build().schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('always re-sets the root template, never updatePaneTemplate', () async { + build(); + await _connect(); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate']); + + native.clear(); + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + // updatePaneTemplate rebuilds the addressed element *as* the root, which + // would swap the map out for a bare pane. + expect(native.calls, ['setRootTemplate']); + }); + + test('the root is a map template wrapping the pane', () async { + build(); + await _connect(); + await pumpEventQueue(); + + final root = native.templates.single; + expect(root['contentRuntimeType'], 'FAAPaneTemplate'); + final content = root['contentTemplate'] as Map; + expect(content['_elementId'], autoGlanceTemplateId); + // An empty title means no header row. The panel's width is the host's, so + // the header was the only part of it we could give back to the map — and a + // stray title would quietly take it again. + expect(content['title'], isEmpty); + expect((content['items'] as List), hasLength(1), + reason: 'one status row, not a menu — the map needs the space'); + expect((content['actions'] as List), isEmpty, + reason: 'the controls are in the map action strip'); + expect((root['mapActions'] as List), hasLength(1), + reason: 'one toggle button, not a permanent Start and Stop pair'); + }); + + test('every update reuses the root template id', () async { + build(); + await _connect(); + await pumpEventQueue(); + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + + // A fresh uuid per rebuild would leave the plugin matching nothing in its + // template history, and the surface would silently desync. + expect(native.templates, hasLength(2)); + for (final template in native.templates) { + expect(template['_elementId'], autoGlanceMapTemplateId); + expect( + (template['contentTemplate'] as Map)['_elementId'], + autoGlanceTemplateId, + ); + } + }); + + test('identical content is not sent twice', () async { + build(); + await _connect(); + await pumpEventQueue(); + native.clear(); + + service.schedule(immediate: true); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('counter churn waits out the floor', () async { + build(floor: const Duration(milliseconds: 200)); + await _connect(); + await pumpEventQueue(); + native.clear(); + + current = _snapshot(txCount: 1); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty, reason: 'inside the floor'); + + await Future.delayed(const Duration(milliseconds: 260)); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate'], + reason: 'deferred, not dropped — the last pings of a run still draw'); + }); + + test('an urgent change bypasses the floor', () async { + build(floor: const Duration(seconds: 30)); + await _connect(); + await pumpEventQueue(); + native.clear(); + + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate']); + }); + + test('a reconnect sends a fresh root template', () async { + build(); + await _connect(); + await pumpEventQueue(); + native.clear(); + + await _disconnect(); + await _connect(); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate'], + reason: + 'the host no longer holds the template history we updated into'); + }); + + test('a disconnected surface publishes nothing', () async { + build(); + await _connect(); + await pumpEventQueue(); + await _disconnect(); + native.clear(); + + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('is inert off Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + build(); + expect(service.isSupportedPlatform, isFalse); + await _connect(); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); +} diff --git a/test/services/auto/auto_command_routing_test.dart b/test/services/auto/auto_command_routing_test.dart new file mode 100644 index 00000000..75588009 --- /dev/null +++ b/test/services/auto/auto_command_routing_test.dart @@ -0,0 +1,255 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/android_auto_service.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +const String _methodChannelName = 'com.oguzhnatly.flutter_android_auto'; +const String _eventChannelName = 'com.oguzhnatly.flutter_android_auto/event'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + required String sessionId, + required bool isSessionActive, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: 'Active', + phase: LiveActivityPhase.listening, + phaseTitle: isSessionActive ? 'Listening' : 'Ready', + phaseDetail: isSessionActive ? 'Waiting for echoes' : null, + isConnected: true, + zoneCode: 'SEA', + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + ), + updatedAt: _now, + ); + +/// The last template the surface sent native, decoded back into the action +/// closures the plugin would invoke on a tap. +/// +/// The controls live in the map action strip, not the pane: the pane was filling +/// half the head unit, and the strip is the vertical bar the host draws down the +/// right edge of the map. +List _renderedActions() => + (FlutterAndroidAutoController.templateHistory.last + as AAMapWithContentTemplate) + .mapActions; + +void _tap(String actionId) { + _renderedActions() + .firstWhere((action) => action.uniqueId == actionId) + .onPress!(); +} + +Future _emitConnected() async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _eventChannelName, + codec.encodeSuccessEnvelope({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'connected'}, + }), + (_) {}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AndroidAutoService service; + late List received; + late List refusals; + late WatchSnapshot current; + ExternalCommandReason? nextRefusal; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + FlutterAndroidAutoController.templateHistory.clear(); + received = []; + refusals = []; + nextRefusal = null; + current = _snapshot(sessionId: 'session-1', isSessionActive: true); + + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel(_methodChannelName), + (MethodCall call) async => true, + ); + messenger.setMockMethodCallHandler( + const MethodChannel(_eventChannelName), + (MethodCall call) async => null, + ); + + service = AndroidAutoService( + debounceDelay: Duration.zero, + minimumNonUrgentInterval: Duration.zero, + )..attach( + snapshotBuilder: () => current, + commandHandler: (command) { + received.add(command); + return nextRefusal; + }, + onRefusal: refusals.add, + ); + }); + + tearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + + test('the strip carries one toggle action', () async { + await _emitConnected(); + await pumpEventQueue(); + + // One button, not two: Start and Stop are mutually exclusive, so showing + // both meant one of them was always the wrong thing to press. + final actions = _renderedActions(); + expect(actions, hasLength(1)); + expect(actions.single.uniqueId, autoGlanceToggleActionId); + expect(actions.single.isPrimary, isTrue); + // The map strip is icon-only — ACTIONS_CONSTRAINTS_MAP leaves + // maxCustomTitles at zero, so an action without an icon is rejected and the + // whole template goes with it. + for (final action in actions) { + expect(action.imageUrl, isNotEmpty); + } + }); + + test('Start sends no mode, deferring to the mode the screen promised', + () async { + // Idle, so the toggle points at Start. + current = _snapshot(sessionId: 'session-1', isSessionActive: false); + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + expect(received, hasLength(1)); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + expect(received.single.source, ExternalCommandSource.androidAuto); + // Null defers to the phone, which resolves it to the same value the + // status row renders. Naming a mode here would let the button start + // something other than what the driver was shown. + expect(received.single.mode, isNull); + }); + + test('Stop names the session the screen was rendering', () async { + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + expect(received.single.kind, ExternalSessionCommandKind.stopSession); + expect(received.single.sessionId, 'session-1'); + }); + + test('with no session the button starts one instead', () async { + current = _snapshot(sessionId: 'session-1', isSessionActive: false); + await _emitConnected(); + await pumpEventQueue(); + + // With no session the button points the other way: it starts one. + _tap(autoGlanceToggleActionId); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + }); + + test('a Stop from a stale screen is refused rather than stopping the new run', + () async { + await _emitConnected(); + await pumpEventQueue(); + + // The driver is looking at a screen for session-1. It ends and session-2 + // begins; a fresh snapshot is published. + final staleActions = _renderedActions(); + current = _snapshot(sessionId: 'session-2', isSessionActive: true); + service.schedule(immediate: true); + await pumpEventQueue(); + + // They tap the Stop they were looking at. + staleActions + .firstWhere((action) => action.uniqueId == autoGlanceToggleActionId) + .onPress!(); + + final command = received.single; + expect(command.sessionId, 'session-1'); + + // Which is exactly what the provider's guard needs to refuse it — and it + // is the shared guard, reached with the very command the strip built. + final admission = resolveExternalSessionTransition( + command: command, + isSessionActive: true, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-2', + currentModeLabel: 'Passive', + ); + expect(admission.shouldExecute, isFalse); + expect(admission.reason, ExternalCommandReason.sessionAlreadyEnded); + }); + + test('a refusal reaches the refusal handler', () async { + await _emitConnected(); + await pumpEventQueue(); + + nextRefusal = ExternalCommandReason.notConnected; + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, [ExternalCommandReason.notConnected]); + }); + + test('an admitted command reports no refusal', () async { + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, isEmpty); + }); + + test('an asynchronous refusal still reaches the handler', () async { + service.dispose(); + service = AndroidAutoService( + debounceDelay: Duration.zero, + minimumNonUrgentInterval: Duration.zero, + )..attach( + snapshotBuilder: () => current, + commandHandler: (command) async => ExternalCommandReason.couldNotStart, + onRefusal: refusals.add, + ); + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, [ExternalCommandReason.couldNotStart]); + }); +} diff --git a/test/services/auto/auto_glance_view_test.dart b/test/services/auto/auto_glance_view_test.dart new file mode 100644 index 00000000..edc22e8c --- /dev/null +++ b/test/services/auto/auto_glance_view_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + WatchHapticCue? cue, + String phaseTitle = 'Listening', + String? phaseDetail = 'Waiting for echoes', + LiveActivityPhase phase = LiveActivityPhase.listening, + bool isConnected = true, + bool isSessionActive = true, + String sessionId = 'session-1', + String mode = 'Active', + String? zoneCode = 'SEA', + String? blockedReason, + int txCount = 3, + int rxCount = 2, + int discoveryCount = 1, + int queueSize = 4, + WatchGeo? geo, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: phaseDetail, + isConnected: isConnected, + zoneCode: zoneCode, + txCount: txCount, + rxCount: rxCount, + discoveryCount: discoveryCount, + traceCount: 0, + queueSize: queueSize, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: geo ?? + const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + blockedReason: blockedReason, + ), + cue: cue, + updatedAt: _now, + ); + +void main() { + group('layout contract', () { + // These are not cosmetic assertions. Android Auto allows a pane update to be + // a free "refresh" only while the row count and row titles are unchanged; + // anything else counts against a five-template-per-task quota, and a driver + // on a long wardrive would watch the surface stop updating. Changing what + // these pin means re-checking the quota on the Desktop Head Unit's debug + // overlay, not just re-recording the expectation. + // + // One row, not four: the content panel is what crowds the map, and the + // controls moved to the map action strip. + test('always renders exactly one row', () { + for (final snapshot in [ + _snapshot(), + _snapshot(isSessionActive: false, phase: LiveActivityPhase.idle), + _snapshot(isConnected: false, zoneCode: null, phaseDetail: null), + _snapshot(blockedReason: 'Not connected', queueSize: 0), + ]) { + final view = buildAutoGlanceView(snapshot, now: _now); + expect(view.rows, hasLength(1)); + for (final row in view.rows) { + expect(row.detail, isNotEmpty, + reason: + 'a blank row still occupies the slot but reads as broken'); + } + } + }); + }); + + group('the row title', () { + // The node name, so the driver can see which radio the numbers belong to. + // It changes only on connect, disconnect or a rename — a handful of + // templates over a session, not one per publish, so the refresh quota that + // the fixed row count protects still holds. + test('is the connected node', () { + final view = + buildAutoGlanceView(_snapshot(), now: _now, nodeName: 'Alders0n'); + expect(view.rows.single.title, 'Alders0n'); + }); + + test('falls back when nothing is connected', () { + expect( + buildAutoGlanceView(_snapshot(), now: _now).rows.single.title, + autoGlanceFallbackTitle, + ); + expect( + buildAutoGlanceView(_snapshot(), now: _now, nodeName: '') + .rows + .single + .title, + autoGlanceFallbackTitle, + ); + }); + }); + + group('the status line', () { + String lineOf(WatchSnapshot snapshot) => + buildAutoGlanceView(snapshot, now: _now).rows.single.detail; + + test('carries the phase and the counters', () { + expect( + lineOf(_snapshot( + txCount: 12, + rxCount: 7, + discoveryCount: 3, + queueSize: 0, + )), + 'Listening · Waiting for echoes · TX 12 · RX 7 · Disc 3', + ); + }); + + test('a queue backlog is worth the space, an empty queue is not', () { + expect(lineOf(_snapshot(queueSize: 5)), contains('Queue 5')); + expect(lineOf(_snapshot(queueSize: 0)), isNot(contains('Queue'))); + }); + + test('falls back to the phase title alone with no detail', () { + expect( + lineOf(_snapshot(phaseTitle: 'Ready', phaseDetail: null)), + startsWith('Ready · TX'), + ); + }); + + test('a live cue outranks the phase detail', () { + expect( + lineOf(_snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now, + message: 'Not connected', + ), + )), + startsWith('Listening · Not connected'), + ); + }); + + test('a cue stops outranking it once it is no longer readable', () { + expect( + lineOf(_snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now.subtract(WatchWire.cueReadableFor), + message: 'Not connected', + ), + )), + startsWith('Listening · Waiting for echoes'), + ); + }); + + // A disconnected radio is the one state where the phase alone can mislead, + // so it earns the extra words. + test('says so when the radio is disconnected', () { + expect(lineOf(_snapshot(isConnected: false)), contains('Disconnected')); + expect(lineOf(_snapshot()), isNot(contains('Disconnected'))); + }); + + test('surfaces why the next tap would be refused', () { + expect( + lineOf(_snapshot(blockedReason: 'Outside zone')), + endsWith('Outside zone'), + ); + }); + }); + + group('sessionId capture', () { + test('is the running session', () { + final view = buildAutoGlanceView(_snapshot(sessionId: 'abc'), now: _now); + expect(view.sessionId, 'abc'); + }); + + // Carrying an id past the end of its session would let a Stop name a run + // that has already stopped, which is exactly what the admission guard exists + // to catch — so do not hand it one. + test('is null when no session is running', () { + final view = buildAutoGlanceView( + _snapshot(isSessionActive: false), + now: _now, + ); + expect(view.sessionId, isNull); + }); + }); + + group('urgencyKey', () { + String keyOf(WatchSnapshot s) => + buildAutoGlanceView(s, now: _now).urgencyKey; + + // Counters move constantly during a session. If they were urgent, the floor + // that keeps the pane inside its template quota would never apply. + test('does not change on counters alone', () { + expect( + keyOf(_snapshot(txCount: 99, rxCount: 98, discoveryCount: 97)), + keyOf(_snapshot()), + ); + }); + + test('changes when the session changes', () { + expect(keyOf(_snapshot(sessionId: 'other')), isNot(keyOf(_snapshot()))); + }); + + test('changes when the session stops', () { + expect( + keyOf(_snapshot(isSessionActive: false)), + isNot(keyOf(_snapshot())), + ); + }); + + test('changes when the phase changes', () { + expect( + keyOf(_snapshot(phase: LiveActivityPhase.sending)), + isNot(keyOf(_snapshot())), + ); + }); + + test('changes when the radio connects or drops', () { + expect(keyOf(_snapshot(isConnected: false)), isNot(keyOf(_snapshot()))); + }); + + test('changes when a new cue arrives', () { + final cued = _snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now, + message: 'Could not start', + ), + ); + expect(keyOf(cued), isNot(keyOf(_snapshot()))); + }); + + test('changes when the blocked reason changes', () { + expect( + keyOf(_snapshot(blockedReason: 'Not connected')), + isNot(keyOf(_snapshot())), + ); + }); + }); + + group('fingerprint', () { + test('is identical for identical content', () { + expect( + buildAutoGlanceView(_snapshot(), now: _now).fingerprint, + buildAutoGlanceView(_snapshot(), now: _now).fingerprint, + ); + }); + + test('changes when a counter changes', () { + expect( + buildAutoGlanceView(_snapshot(txCount: 4), now: _now).fingerprint, + isNot(buildAutoGlanceView(_snapshot(), now: _now).fingerprint), + ); + }); + }); +} diff --git a/test/services/auto/car_map_channel_test.dart b/test/services/auto/car_map_channel_test.dart new file mode 100644 index 00000000..de41ac16 --- /dev/null +++ b/test/services/auto/car_map_channel_test.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/car_map_channel.dart'; +import 'package:mesh_mapper/widgets/map_widget.dart' show gpsMarkerFacesHeading; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('meshmapper/car_map_test'); + late List calls; + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + }); + + CarMapChannel build() => CarMapChannel(channel: channel); + + test('sends the camera through to native', () async { + await build().setCamera(lat: 47.6, lon: -122.3, bearing: 90); + expect(calls.single.method, 'setCamera'); + expect(calls.single.arguments['lat'], 47.6); + expect(calls.single.arguments['lon'], -122.3); + expect(calls.single.arguments['bearing'], 90); + }); + + // GPS ticks at 1–2 Hz and jitters below a metre. Forwarding every one would + // be a platform round-trip per tick for a camera move no driver can see. + test('sub-metre jitter does not reach native', () async { + final map = build(); + await map.setCamera(lat: 47.600000, lon: -122.300000); + await map.setCamera(lat: 47.600001, lon: -122.300001); + expect(calls, hasLength(1)); + }); + + test('real movement does reach native', () async { + final map = build(); + await map.setCamera(lat: 47.6000, lon: -122.3000); + await map.setCamera(lat: 47.6010, lon: -122.3000); + expect(calls, hasLength(2)); + }); + + test('a style change is sent', () async { + final map = build(); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + await map.setStyle('https://tiles.openfreemap.org/styles/bright'); + expect(calls, hasLength(2)); + }); + + test('an empty style is ignored', () async { + await build().setStyle(''); + expect(calls, isEmpty); + }); + + group('settings', () { + CarMapSettings settings({ + bool northUp = true, + bool rotationLocked = false, + String style = 'https://tiles.openfreemap.org/styles/dark', + String marker = 'arrow', + }) => + CarMapSettings.from( + styleUrl: style, + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: northUp, + mapRotationLocked: rotationLocked, + markerStyle: marker, + markerFacesHeading: gpsMarkerFacesHeading(marker), + ); + + // mapAlwaysNorth defaults true, so getting this wrong rotates the car map + // for most people who never asked for it. + test('north-up is honoured', () { + expect(settings(northUp: true).northUp, isTrue); + expect(settings(northUp: false).northUp, isFalse); + }); + + test('the marker style and its rotation rule ride along', () { + expect(settings(marker: 'arrow').markerStyle, 'arrow'); + expect(settings(marker: 'arrow').markerFacesHeading, isTrue); + // Car, bike and boat stay upright on a rotated map. + expect(settings(marker: 'car').markerFacesHeading, isFalse); + }); + + test('a rotation lock also means north-up', () { + expect(settings(northUp: false, rotationLocked: true).northUp, isTrue); + }); + + // The satellite style is an inline style document, not a URL. Anything that + // trims, normalises or url-encodes it on the way through breaks it. + test('an inline style document passes through unmangled', () { + const doc = '{"version":8,"sources":{}}'; + expect(settings(style: doc).styleUrl, doc); + }); + }); + + group('pings', () { + WatchPing ping(String id, double lat, double lon, WatchColor color) => + WatchPing( + id: id, + lat: lat, + lon: lon, + kind: 'tx', + color: color, + at: DateTime.fromMillisecondsSinceEpoch(1760000000000), + ); + + test('one feature per ping, with its resolved colour', () { + final json = jsonDecode(encodePingsGeoJson([ + ping('a', 47.6, -122.3, const WatchColor(1, 0, 0)), + ping('b', 47.7, -122.4, const WatchColor(0, 1, 0)), + ])) as Map; + + expect(json['type'], 'FeatureCollection'); + final features = json['features'] as List; + expect(features, hasLength(2)); + expect((features.first as Map)['properties'], { + 'color': '#ff0000', + 'kind': 'tx', + }); + expect((features.last as Map)['properties'], contains('color')); + }); + + // GeoJSON is lon,lat. Getting it backwards produces a map that looks + // plausible and is wrong. + test('coordinates are lon,lat', () { + final json = jsonDecode( + encodePingsGeoJson( + [ping('a', 47.6, -122.3, const WatchColor(1, 1, 1))]), + ) as Map; + final geometry = + ((json['features'] as List).single as Map)['geometry'] as Map; + expect(geometry['coordinates'], [-122.3, 47.6]); + }); + + test('no pings is an empty collection, not a missing one', () { + final json = jsonDecode(encodePingsGeoJson([])) as Map; + expect(json['features'], isEmpty); + }); + + test('is sent, then deduped', () async { + final map = build(); + final one = [ping('a', 47.6, -122.3, const WatchColor(1, 0, 0))]; + await map.setPings(one); + await map.setPings([...one]); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setPings'); + }); + + test('a new ping is sent', () async { + final map = build(); + await map.setPings([ping('a', 47.6, -122.3, const WatchColor(1, 0, 0))]); + await map.setPings([ + ping('a', 47.6, -122.3, const WatchColor(1, 0, 0)), + ping('b', 47.7, -122.4, const WatchColor(0, 1, 0)), + ]); + expect(calls, hasLength(2)); + }); + }); + + group('timer', () { + final deadline = DateTime.fromMillisecondsSinceEpoch(1760000030000); + + test('deadline and duration cross, nothing per-second', () async { + await build() + .setTimer(endsAt: deadline, durationMs: 30000, argbColor: 0xFFFF0000); + expect(calls.single.method, 'setTimer'); + expect(calls.single.arguments['endsAtMs'], 1760000030000); + expect(calls.single.arguments['durationMs'], 30000); + }); + + // A phase with no deadline must clear the bar, not leave a stuck one. + test('a deadline-less phase clears it', () async { + await build().setTimer(); + expect(calls.single.arguments['endsAtMs'], isNull); + expect(calls.single.arguments['durationMs'], isNull); + }); + + test('an unchanged phase is not re-sent', () async { + final map = build(); + await map.setTimer(endsAt: deadline, durationMs: 30000); + await map.setTimer(endsAt: deadline, durationMs: 30000); + expect(calls, hasLength(1)); + }); + + test('a new cycle is sent', () async { + final map = build(); + await map.setTimer(endsAt: deadline, durationMs: 30000); + await map.setTimer( + endsAt: deadline.add(const Duration(seconds: 30)), + durationMs: 30000, + ); + expect(calls, hasLength(2)); + }); + }); + + // The style is deliberately not deduped on this side: a load is asynchronous + // and can fail, and caching what we sent would remember a failure as a + // success and never retry. Native dedupes on what actually loaded. + test('the style is re-sent every time', () async { + final map = build(); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + expect(calls, hasLength(2)); + }); + + group('the position marker', () { + final png = Uint8List.fromList(const [1, 2, 3, 4]); + + test('is sent once, then deduped on the style name', () async { + final map = build(); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setPositionMarker'); + expect(calls.single.arguments['facesHeading'], isTrue); + }); + + test('a style change re-sends it', () async { + final map = build(); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + await map.setPositionMarker(style: 'car', png: png, facesHeading: false); + expect(calls, hasLength(2)); + expect(calls.last.arguments['facesHeading'], isFalse); + }); + }); + + group('camera bearing versus marker heading', () { + // The subtle one. In north-up mode the camera bearing is deliberately null + // so the map does not turn, but the marker still has to point along the + // direction of travel. Collapsing these into one field leaves the arrow + // stuck pointing north on the setting most people have on by default. + test('heading survives while bearing is nulled', () async { + await build().setCamera( + lat: 47.6, + lon: -122.3, + bearing: null, + heading: 90, + ); + expect(calls.single.arguments['bearing'], isNull); + expect(calls.single.arguments['heading'], 90); + }); + + test('turning on the spot still moves the marker', () async { + final map = build(); + await map.setCamera(lat: 47.6, lon: -122.3, heading: 0); + await map.setCamera(lat: 47.6, lon: -122.3, heading: 90); + expect(calls, hasLength(2), + reason: 'position deduping must not swallow a heading change'); + }); + }); + + // The channel exists only on Android, and only once the engine has registered + // it. Every other platform must be a silent no-op, not a crash on a GPS tick. + group('coverage', () { + CarMapCoverage? forZone({ + String? zone = 'sea', + int gridSize = 300, + String cvd = 'none', + double opacity = 0.7, + bool tilesEnabled = true, + }) => + CarMapCoverage.forZone( + zoneCode: zone, + gridSize: gridSize, + colorVisionType: cvd, + opacity: opacity, + tilesEnabled: tilesEnabled, + ); + + // The user turns tiles off to stop spending a tethered connection. + // Honouring that only on the phone spends it anyway, where they cannot see. + test('tiles switched off means nothing to draw', () { + expect(forZone(tilesEnabled: false), isNull); + }); + + test('the tile url carries the zone and grid size', () { + final coverage = forZone(zone: 'SEA', gridSize: 500)!; + expect( + coverage.tileUrl, + 'https://sea.meshmapper.net/vector_tile.php' + '?z={z}&x={x}&y={y}&gsize=500', + ); + }); + + // Zone and grid size live in the URL, so either changing is a different + // source — not a restyled one. The dedupe below has to see that. + test('a grid change is a different overlay', () { + expect(forZone(gridSize: 300), isNot(forZone(gridSize: 500))); + }); + + test('a zone change is a different overlay', () { + expect(forZone(zone: 'sea'), isNot(forZone(zone: 'pdx'))); + }); + + test('there is nothing to draw before a zone is known', () { + expect(forZone(zone: null), isNull); + expect(forZone(zone: ''), isNull); + }); + + test('a fully transparent overlay is nothing to draw', () { + expect(forZone(opacity: 0), isNull); + }); + + // Dart owns the palette; native must never hold a second copy that drifts. + // What crosses the channel is a finished MapLibre expression. + test('the palette crosses as a MapLibre match expression', () { + final coverage = forZone()!; + final fill = jsonDecode(coverage.fillColor) as List; + expect(fill.first, 'match'); + expect(fill[1], ['get', 'st']); + expect(fill, contains('#1e7e34'), reason: 'st 1 green, from the palette'); + expect( + jsonDecode(coverage.outlineColor) as List, + contains('#14522d'), + reason: 'st 1 border, from the palette', + ); + }); + + test('a colour-vision mode changes the expression', () { + expect(forZone(cvd: 'none')!.fillColor, + isNot(forZone(cvd: 'protanopia')!.fillColor)); + }); + + test('is sent to native', () async { + await build().setCoverage(forZone()); + expect(calls.single.method, 'setCoverage'); + expect(calls.single.arguments['tileUrl'], contains('sea.meshmapper.net')); + expect(calls.single.arguments['opacity'], 0.7); + }); + + // Re-applying rebuilds the layer, which restarts every tile request — + // expensive on a tether and visible as a flash of empty map. + test('an unchanged overlay is not re-sent', () async { + final map = build(); + await map.setCoverage(forZone()); + await map.setCoverage(forZone()); + expect(calls, hasLength(1)); + }); + + test('a changed overlay is re-sent', () async { + final map = build(); + await map.setCoverage(forZone(gridSize: 300)); + await map.setCoverage(forZone(gridSize: 500)); + expect(calls, hasLength(2)); + }); + + test('clearing is sent once, then deduped', () async { + final map = build(); + await map.setCoverage(null); + await map.setCoverage(null); + expect(calls, hasLength(1)); + expect(calls.single.arguments, isEmpty); + }); + }); + + test('a missing native side is not fatal', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await expectLater( + build().setCamera(lat: 47.6, lon: -122.3), + completes, + ); + }); +} diff --git a/third_party/flutter_carplay/CHANGELOG.md b/third_party/flutter_carplay/CHANGELOG.md new file mode 100644 index 00000000..afb05e6f --- /dev/null +++ b/third_party/flutter_carplay/CHANGELOG.md @@ -0,0 +1,202 @@ +## 1.6.5 - 2026-08-21 + +- Fix Android Auto list template startup by validating selectable lists before they reach the host (#120) (ty @JulianBissekkou) +- Complete Android Auto method-channel calls when coroutine handlers throw (#133, #134) (ty @JulianBissekkou) +- Restore the README star history chart (#135) + +## 1.6.4 - 2026-07-06 + +- Move the Android Auto message template docs into the Android Auto API usage section (#129) (ty @EArminjon) + +## 1.6.3 - 2026-06-22 + +- Fix Android Auto example release launches by allowing root template refresh before the car screen is attached (#127, #128) (ty @deandreamatias) +- Keep the Android Auto selectable list demo in its own template so it follows host validation rules (#128) + +## 1.6.2 - 2026-06-16 + +- Support setting `CPSearchTemplate` as the CarPlay root template. +- Add a Search Template row to the example app and a README preview image. +- Document CarPlay entitlement options while keeping parking as the example default for Point of Interest support. + +## 1.6.1 - 2026-06-15 + +- Fix Swift Package Manager builds by keeping `FCPSearchTemplate.swift` inside the SwiftPM source path (#123, #124) (ty @Gabriellsp) +- Reject unsupported `CPSearchTemplate` root templates before CarPlay receives an invalid root template. + +## 1.6.0 - 2026-06-13 + +- Add Swift Package Manager support for iOS package consumers (#111) (ty @justinbeatz) +- Keep `FCPImageTint.swift` in the SwiftPM source path and align CocoaPods source files with the new package layout (#111) (ty @justinbeatz) + +## 1.5.1 + +- Fix CarPlay tab templates when the same list item ID appears in multiple tabs (#121) (ty @Gabriellsp) + +## 1.5.0 + +- Add Android Auto alert, grid, and tab bar templates (#102) (ty @Gabriellsp) +- Add Android Auto modal alert presentation and dismissal APIs (#102) (ty @Gabriellsp) +- Add Android Auto list loading messages and empty view titles (#102) (ty @Gabriellsp) + +## 1.4.0 + +- Add `CPSearchTemplate` support for CarPlay search flows (#96) (ty @sINFdorako) +- Add Android Auto list item, section, and template IDs, including selection and toggle handling (#105) (ty @JulianBissekkou) +- Add Flutter asset SVG support across CarPlay and Android Auto image fields (#112) (ty @JulianBissekkou) +- Add Android Auto `AAMessageTemplate` and `AALongMessageTemplate` support with update APIs (#116) (ty @JulianBissekkou) +- Add Android Auto `AAPaneTemplate` support with update APIs (#119) (ty @JulianBissekkou) +- Clarify the difference between Android Auto and Android Automotive OS in the README (#115) (ty @JulianBissekkou) + +## 1.3.3 + +- Expose `onPop` callbacks on pushed CarPlay templates (#106) (ty @sINFdorako) +- Add Android Auto list item, section, and template IDs (#103) (ty @JulianBissekkou) + +## 1.3.2 + +- Avoid iOS crash around image management (#101) (ty @EArminjon) + +## 1.3.1 + +- Fix crash on iOS 18 and earlier when `CPListImageRowItem` is truncated by the host and fewer image slots are available than requested (#98) (ty @EArminjon) +- Add update methods for CPInformationTemplate items and actions (#97) (ty @sINFdorako) +- Document `CPListImageRowItem` support and `CPInformationTemplate` update methods in the README +- Update the security policy supported versions to include `1.3.x` + +## 1.3.0 + +- Implement CPListImageRowItem with full support for cardElements, condensedElements, elements, gridElements, and imageGridElements (#94) (ty @EArminjon) +- Support individual update for CPListImageRowItem and CPListImageRowItemElement +- Expose tabTitle, systemIcon, and showsTabBadges on CPTabBarTemplate +- Rename enums and classes to be 1:1 with Apple documentation (breaking change) +- Rework UUID logic to allow custom elementId on all models +- Expose convenient update methods on CPListItem and other templates +- Improve image loading error handling with proper error callbacks +- Fix app crash when images are empty string +- Add missing GridButton handler as optional (not required) +- Update uuid dependency to 4.5.3 + +## 1.2.11 + +- Fix `List` is not a subtype of `Iterable` in `updateTemplates` (#92) (ty @EArminjon) +- Fix failed cast on `updateCPListItem` when item is not in root template (#91) (ty @RedC4ke) + +## 1.2.10 + +- Add `@objc(FlutterCarPlaySceneDelegate)` annotation for iOS 26 compatibility (#87) + +This enables apps to reference the delegate class as `flutter_carplay.FlutterCarPlaySceneDelegate` in their scene manifest configuration. Required for runtime class discovery via `NSClassFromString`. Thanks @APIUM! + +## 1.2.9 + +- Add missing `@available(iOS 14.0, *)` annotations to `makeUIImage` and `loadUIImageAsync` (#84) + +These functions reference `SwiftFlutterCarplayPlugin` which requires iOS 14.0+, so Swift requires the availability annotation to propagate. This was missing since v1.2.5. + +## 1.2.8 + +- Fix build failure on Xcode without iOS 26 SDK (follow up to #84) + +The v1.2.7 fix using `#if compiler(>=6.0)` didn't work because Swift 6.0 shipped with Xcode 16 (iOS 18), before iOS 26. Now uses dynamic selector invocation to avoid compile time symbol lookup for `updateImage`. + +## 1.2.7 + +- Fix build failure on Xcode versions without iOS 26 SDK (#84) + +The `CPGridButton.updateImage()` API introduced in 1.2.5 is only available in iOS 26+. This caused compile errors on older Xcode versions since `#available` only handles runtime checks, not compile time SDK availability. iOS 26 specific code is now wrapped in `#if compiler(>=6.0)` to ensure older toolchains skip it entirely. + +- Fix type mismatch in `updateTabBarTemplates` that prevented compilation + +## 1.2.6 + +- Fix compatibility with Dart's `--obfuscate` flag by using explicit type checks instead of `runtimeType.toString()` (fixes #28) +- Add security policy (SECURITY.md) + +## 1.2.5 + +- Fix main thread image loading crash for CPListItem and CPGridButton in https://github.com/oguzhnatly/flutter_carplay/pull/79 (ty @EArminjon) + +This fixes a crash caused by creating UIImage on background threads. Network images are now loaded asynchronously using URLSession, and placeholder images are shown until the actual image loads. For iOS 26+, CPGridButton uses the new `updateImage()` API for async updates. + +## 1.2.4 + +- Fix file URI percent encoding for album art paths with spaces in https://github.com/oguzhnatly/flutter_carplay/pull/82 (ty @APIUM) +- Add `sectionIndexEnabled` option to CPListTemplate for hiding section index letters in https://github.com/oguzhnatly/flutter_carplay/pull/83 (ty @APIUM) + +## 1.2.3 + +- Update tab bar template to support mixed template types in https://github.com/oguzhnatly/flutter_carplay/pull/81 + +This enhances the tab bar template by enabling support for multiple template types (not just list templates) as tab bar children. Supported template types: CPListTemplate, CPPointOfInterestTemplate, CPGridTemplate, CPInformationTemplate (ty @shihabkandil). + +## 1.2.2 + +**Issues:** +Calling `updateTemplates` or `updateSections` updates the layout correctly when the CarPlay is already active, but fail to do when CarPlay not yet started. Using `updateTemplates` or `updateSections` doesn’t refresh ListItem's handler properly, causing missing callbacks. This results in items showing a loading indicator for several seconds because the end event never fires. + +It's been updated by @EArminjon in https://github.com/oguzhnatly/flutter_carplay/pull/77 + +**Fixes :** +- Ensure `updateTemplate` and `updateSections` correctly refresh all relevant data and update the `final _super.handler`. +- Reformatted the code. +- Reuse existing `CPTemplate` instances instead of recreating them. +- Renamed variables to improve clarity. + +## 1.2.1 + +- Update tabBar templates in https://github.com/oguzhnatly/flutter_carplay/pull/71 + +This allow updating a tabBar without removing entire stack. This is useful to add, update or remove tabs. + +**Bug fixes :** +- Ensure that updateSections only recreate necessary entries. +- Ensure that updateSections take and memorise new entries (by using List.from). + +## 1.2.0 + +- Add early support for Android Auto under a new controller `FlutterAndroidAuto`. Not all features are supported yet, see the README for more details. ([#71](https://github.com/oguzhnatly/flutter_carplay/pull/71)) (ty @EArminjon). +- History have been reworked to ensure that all templates are well ordered, presents and synchronized. +- Rename some classes to avoid confusion between Android Auto and CarPlay (breaking change) + - `CPConnectionStatusTypes` -> `ConnectionStatusTypes` + - `CPEnumUtils` -> `EnumUtils` + +## 1.1.3 + +- Documentation and packaging improvements +- Automated publishing setup with GitHub Actions + +## 1.1.1 + +- Add automated publishing support to pub.dev + +## 1.1.0 + +- Add showNowPlaying, it can be called multiple times safely ([#33](https://github.com/oguzhnatly/flutter_carplay/issues/33)) (ty @vanlooverenkoen, @EArminjon) +- Add support for HTTP(s) images (ty @vanlooverenkoen) +- Add support to launch CarPlay without manually launch the iOS app ([#25](https://github.com/oguzhnatly/flutter_carplay/pull/25)) (ty @vanlooverenkoen) +- Update the iOS integration and its doc to fix various issues ([#17](https://github.com/oguzhnatly/flutter_carplay/issues/17), [#35](https://github.com/oguzhnatly/flutter_carplay/issues/35), [#38](https://github.com/oguzhnatly/flutter_carplay/issues/38), [#61](https://github.com/oguzhnatly/flutter_carplay/issues/61), [#67](https://github.com/oguzhnatly/flutter_carplay/issues/67)) (ty @EArminjon, @snipd-mikel) + +## 1.0.3 + +- Build fix for the issue [#7](https://github.com/oguzhnatly/flutter_carplay/issues/7) + +## 1.0.2+1 + +- Point Of Interest and Information Template added. Previews added to README.md. + +## 1.0.2 + +- Point Of Interest and Information Template added. + +## 1.0.1 + +- CarPlay List Template issue #4 fixed. + +## 1.0.0+1 + +- Initial release of Flutter Apple CarPlay Package. Previews added to README.md. + +## 1.0.0 + +- Initial release of Flutter Apple CarPlay Package. diff --git a/third_party/flutter_carplay/LICENSE b/third_party/flutter_carplay/LICENSE new file mode 100644 index 00000000..1ae99106 --- /dev/null +++ b/third_party/flutter_carplay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Oğuzhan Atalay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/flutter_carplay/README.md b/third_party/flutter_carplay/README.md new file mode 100644 index 00000000..87bd9133 --- /dev/null +++ b/third_party/flutter_carplay/README.md @@ -0,0 +1,1438 @@ +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/banner.png) + +# CarPlay and Android Auto with Flutter 🚗 + +[![License: MIT](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT) +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/flutter_carplay?include_prereleases) +![Dart Pub Likes](https://badgen.net/pub/likes/flutter_carplay) +![Dart Pub Multi-Platform](https://badgen.net/pub/flutter-platform/flutter_carplay) +![DartPub Dart SDK](https://badgen.net/pub/sdk-version/flutter_carplay) + +Flutter Apps now on Apple CarPlay and Android Auto ! `flutter_carplay` aims to make it safe to use apps made with Flutter in the car by integrating with CarPlay and Android Auto. The package takes the things you want to do while driving and puts them on the car's built-in display. + +**✨ New in v1.5.0**: Android Auto alert, grid, and tab bar templates, modal alert APIs, and richer Android Auto list loading states. + +**✨ New in v1.1.0**: CarPlay apps can now launch automatically without requiring the Flutter app to be opened first, supporting true background launch capabilities. + +> Apple announced some great features in iOS 14, one of which is users download CarPlay apps from the App Store and use them on iPhone like any other app. When an iPhone with a CarPlay app is connected to a CarPlay vehicle, the app icon appears on the CarPlay home screen. CarPlay apps are not separate apps—you add CarPlay support to an existing app. +> +> Your app uses the CarPlay framework to present UI elements to the user. iOS manages the display of UI elements and handles the interface with the car. Your app does not need to manage the layout of UI elements for different screen resolutions, or support different input hardware such as touchscreens, knobs, or touch pads. + +It supports **only iOS 14.0+**. For general design guidance, see [Human Interface Guidelines for CarPlay Apps](https://developer.apple.com/design/human-interface-guidelines/carplay/overview/introduction/). + +## 📚 Documentation + +For detailed guides and examples, check out the **[Wiki](https://github.com/oguzhnatly/flutter_carplay/wiki)**: + +- [Getting Started](https://github.com/oguzhnatly/flutter_carplay/wiki/Getting-Started) — Installation and basic setup +- [iOS Setup](https://github.com/oguzhnatly/flutter_carplay/wiki/iOS-Setup) — CarPlay entitlements and configuration +- [Android Auto Setup](https://github.com/oguzhnatly/flutter_carplay/wiki/Android-Auto-Setup) — Android Auto configuration +- [Templates](https://github.com/oguzhnatly/flutter_carplay/wiki/Templates) — All templates with code examples +- [Troubleshooting](https://github.com/oguzhnatly/flutter_carplay/wiki/Troubleshooting) — Common issues and solutions +- [FAQ](https://github.com/oguzhnatly/flutter_carplay/wiki/FAQ) — Frequently asked questions + +# Summary + +- [Overview](#overview) +- [Templates](#templates) +- [Supports](#supports) +- [What's New in latest versions](#whats-new-in-latest-versions) +- [Road Map](#road-map) +- [Contributing](#contributing) +- [Requesting the CarPlay Entitlements](#requesting-the-carplay-entitlements) +- [Disclaimer Before The Installation](#disclaimer-before-the-installation) +- [Get Started](#get-started) +- [Android Auto vs Android Automotive OS](#android-auto-vs-android-automotive-os) +- [Solve problems configuring your project](#solve-problems-configuring-your-project) +- [Usage & Features](#usage--features) +- [Templates](#templates-1) +- [LICENSE](#license) + +# Overview + +![Flutter CarPlay Introduction](https://user-images.githubusercontent.com/54781138/131184549-3cb62678-ad3f-4d67-85fb-1410bd05eaff.gif) + +Before you begin CarPlay integration, you must carefully read this section. + +[_The official App Programming Guidelines from Apple_](https://developer.apple.com/carplay/documentation/CarPlay-App-Programming-Guide.pdf) is the most valuable resource for understanding the needs, limits, and capabilities of CarPlay Apps. This documentation is a 49-page which clearly spells out the some actions required, and you are strongly advised to read it. If you are interested in a CarPlay System, [learn more about the MFi Program](https://mfi.apple.com/). + +# Templates + +## Car Play Templates + +CarPlay apps are built from a fixed set of user interface templates that iOS renders on the CarPlay screen. Each CarPlay app category can only use a restricted number of templates. Your app entitlement determines your access to templates. + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/templates.png) + +## Android Auto Templates + +Android Auto apps built with the Android for Cars App Library are constructed using a fixed set of vehicle-optimized templates that the host renders on the car screen. Each Android Auto app category (e.g., Navigation, Point-of-Interest, IoT, etc.) can only use a restricted number of templates, and access to the Android for Cars App Library and its templates is generally restricted to supported app categories. + +https://developer.android.com/design/ui/cars/guides/templates/overview + +# Supports + +## Car Play Support + +`flutter_carplay` currently supports: + +- [x] Action Sheet Template +- [x] Alert Template +- [x] Grid Template +- [x] List Template +- [x] Tab Bar Template +- [x] Information Template (contribution from [OSch11](https://github.com/OSch11/flutter_carplay)) +- [x] Point of Interest Template (contribution from [OSch11](https://github.com/OSch11/flutter_carplay)) +- [x] Search Template +- [x] Now Playing Template (v1.1.0) + +By evaluating this information, you can request for the relevant entitlement from Apple. + +## Android Auto Support + +- [x] List Template (limited support) +- [x] Grid Template +- [x] Tab Bar Template (requires Car App API level 6+) +- [x] Alert Template +- [x] Message Template +- [x] Long Message Template +- [x] Pane Template +- [x] Now Playing Template (Automatically handled by Android Auto system) + +# What's New in latest versions + +## v1.5.0 + +- **🚘 More Android Auto Templates**: Added alert, grid, and tab bar templates, including tab selection handling +- **⚠️ Android Auto Alerts**: Added modal alert presentation and dismissal APIs for Android Auto flows +- **🧾 Better Android Auto Lists**: Added loading messages and empty view title support for list templates + +## v1.4.0 + +- **🔎 CarPlay Search Template**: Added `CPSearchTemplate` with search text, result selection, and search button callbacks +- **🤖 More Android Auto Templates**: Added `AAMessageTemplate`, `AALongMessageTemplate`, and `AAPaneTemplate`, including update APIs +- **🧾 Better Android Auto Lists**: Added stable IDs, section selection, toggles, browsable rows, and trailing images +- **🖼️ Flutter Asset SVG Support**: Flutter asset SVGs are rasterized before reaching native CarPlay and Android Auto image fields +- **📚 Android Auto Docs**: Clarified that Android Auto template rendering is different from Android Automotive OS apps + +## v1.3.0 + +- **🖼️ CPListImageRowItem**: Added support for image row list items, including element based layouts on newer iOS versions +- **🔄 Information Template Updates**: Added update methods for information items and actions without rebuilding the whole template, thanks to [@sINFdorako](https://github.com/sINFdorako) +- **🧩 Better Tab Bar Configuration**: `tabTitle`, `systemIcon`, and `showsTabBadge` are now exposed consistently on templates +- **🛠️ API Polish**: Added custom ids across models, convenient update helpers, and improved image loading reliability + +## v1.2.0 + +- **🤖 Android Auto Support**: Initial support for Android Auto with limited + features (Thanks to [@EArminjon](https://github.com/EArminjon)) + +## v1.1.0 + +- **🚀 Background Launch Support**: CarPlay apps can now start automatically without requiring the Flutter app to be opened first (Thanks to [@vanlooverenkoen](https://github.com/vanlooverenkoen) and [@EArminjon](https://github.com/EArminjon)) +- **🎵 Now Playing Template**: Navigate to the shared instance of the Now Playing Template with `FlutterCarplay.showSharedNowPlaying()` +- **🌐 Flexible Image Sources**: Load images from assets, local files (`file://`), or URLs (`https://`) (Thanks to [@vanlooverenkoen](https://github.com/vanlooverenkoen)) +- **🔧 Improved Completion Handlers**: Better reliability for list item interactions and template transitions +- **📱 Flutter 3.32.x Compatibility**: Updated for the latest Flutter versions + +Special thanks to [@EArminjon](https://github.com/EArminjon), [@vanlooverenkoen](https://github.com/vanlooverenkoen), [@snipd-mikel](https://github.com/snipd-mikel), [@APIUM](https://github.com/APIUM), and all contributors who made this release possible! + +# Road Map + +Other templates will be supported in the future releases by `flutter_carplay`. + +## Car Play Road Map + +- [ ] Map Template +- [x] Search Template +- [ ] Voice Control & "Hey Siri" for hands-free voice activation +- [ ] Contact Template + +## Android Auto Road Map +- [ ] Action Sheet Template +- [x] Information Template via Pane Template +- [ ] Point of Interest Template +- [ ] Map Template +- [ ] Search Template +- [ ] Voice Control & "Hey Google" for hands-free voice activation +- [ ] Contact Template + +# Contributing + +- Pull Requests are always welcome. +- Pull Request Reviews are even more welcome! I need help in testing. +- If you are interested in contributing more actively, please contact me at info@oguzhanatalay.com Thanks! +- If you want to help in coding, join [Discord Server](https://discord.gg/Xz6WVezFfh), so we can chat over there. + +# Requesting the CarPlay Entitlements + +> All CarPlay apps require a CarPlay app entitlement. + +If you want to build, run and publish your app on Apple with CarPlay compatibility or test or share the app with others through the TestfFlight or AdHoc, you must first request Apple to approve your Developer account for CarPlay access. The process can take from a few days to weeks or even months. It depends on the type of Entitlement you are requesting. + +To request a CarPlay app entitlement from Apple, go to https://developer.apple.com/contact/carplay and provide information about your app, including the CarPlay App Category. You must also agree to the CarPlay Entitlement Addendum. + +With this project, you can start developing and testing through Apple's CarPlay Simulator without waiting for CarPlay Entitlements. Apple will review your request. If your app meets the criteria for a CarPlay app, Apple will assign a CarPlay app entitlement to your Apple Developer Account and will notify you. + +Whether you are running the app through a simulator or developing it for distribution, you must ensure that the relevant entitlement key is added to the `Entitlements.plist` file. You must create an Entitlements.plist file if you do not already have one. + +## After you receive the CarPlay Entitlement + +After you receive the entitlement, you need to configure your Xcode project to use it, which involves several steps. You create and import a provisioning profile, and add an `Entitlements.plist` file. Your project’s code signing settings also require minor changes. + +For more detailed instructions about how to create and import the CarPlay Provisioning Profile and add an Entitlements File to Xcode Project, go to [Configure your CarPlay-enabled app with the entitlements it requires.](https://developer.apple.com/documentation/carplay/requesting_the_carplay_entitlements) + +Choose the CarPlay entitlement that matches the app category Apple approved for your project. The example app keeps `com.apple.developer.carplay-parking` so the Point of Interest demo remains available. If your app uses a different CarPlay category, replace it with the matching entitlement, for example `com.apple.developer.carplay-maps` for maps and navigation, `com.apple.developer.carplay-quick-ordering` for quick ordering, `com.apple.developer.carplay-charging` for EV charging, `com.apple.developer.carplay-fueling` for fuel stations, `com.apple.developer.carplay-driving-task` for driving tasks, `com.apple.developer.carplay-communication` for calling or messaging, or `com.apple.developer.carplay-audio` for audio playback. + +# Disclaimer Before The Installation + +You are about to make some minor changes to your Xcode project after installing this package. This is due to the fact that It requires bitcode compilation which is missing in Flutter. You will procedure that will relocate (we won't remove or edit) some Flutter and its package engines. If you're planning to add this package to a critical project for you, you should proceed cautiously. + +**Please check [THE EXAMPLE PROJECT](https://github.com/oguzhnatly/flutter_carplay/tree/master/example) before you begin to the installation.** + +THE INSTALLATION STEPS MAY BE DIFFICULT OR MAY NOT WORK PROPERLY WITH A FEW PACKAGES IN YOUR CURRENT PROJECT THAT COMMUNICATE WITH THE FLUTTER ENGINE. IF YOU ARE NOT COMPLETELY SURE WHAT YOU ARE DOING, PLEASE CREATE AN ISSUE, SO THAT I CAN HELP YOU TO SOLVE YOUR PROBLEM OR EXPLAIN WHAT YOU NEED TO. + +WHILE THE INSTALLATION PROGRESS, IF YOU TRY TO CHANGE ANYTHING (E.G. ANYTHING WORKS WITH FLUTTER ENGINE, ANYTHING IN GENERATED PLUGIN REGISTRANT SPECIFICALLY ITS LOCATION, ANY FILE NAME, ANY CLASS NAME, OR ANY OTHER FUNCTION THAT WORKS ON APPDELEGATE CLASS, TEMPLATE OR WINDOW APPLICATION DELEGATE SCENE NAMES USED IN INFO.PLIST, INCLUDED STORYBOARD NAMES, BUT NOT LIMITED TO THESE), YOU ARE MOST LIKELY TO ENCOUNTER IRREVERSIBLE ERRORS AND IT MAY DAMAGE TO YOUR PROJECT. I STRONGLY RECOMMEND THAT YOU SHOULD COPY YOUR EXISTING PROJECT BEFORE THE INSTALLATION. + +# Get Started + +## Car Play Get Started + +### Requirement Actions after Installation of the Package + +1. The iOS platform version must be set to 14.0. To make it global, navigate to `ios/Podfile` and copy the first two lines: + +```diff +# Uncomment this line to define a global platform for your project ++ platform :ios, '14.0' +- # platform :ios, '9.0' +``` + +After changing the platform version, execute the following command in your terminal to update your pod files: + +```shell +// For Apple Silicon M1 chips: +$ cd ios && arch -x86_64 pod install --repo-update + +// For Intel chips: +$ cd ios && pod install --repo-update +``` + +2. Open `ios/Runner.xcworkspace` in Xcode. In your project navigator, open `AppDelegate.swift`. + + ![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/step2.png) + + Delete the specified codes below from the application function in `AppDelegate.swift`, and change it with the code below: + +```diff +import UIKit +import Flutter + +let flutterEngine = FlutterEngine(name: "SharedEngine", project: nil, allowHeadlessExecution: true) + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ++ flutterEngine.run() ++ GeneratedPluginRegistrant.register(with: flutterEngine) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} +``` + +3. Create a swift file named `SceneDelegate.swift` in the Runner folder (not in the xcode main project file) and add the code below: + + ```swift + @available(iOS 13.0, *) + class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + + let controller = FlutterViewController.init(engine: flutterEngine, nibName: nil, bundle: nil) + controller.loadDefaultSplashScreenView() + window?.rootViewController = controller + window?.makeKeyAndVisible() + } + } + ``` + + ![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/step3.png) + +4. One more step, open the `Info.plist` file whether in your favorite code editor or in the Xcode. I'm going to share the base code, so if you open in the Xcode, you can fill with the raw keys with the values. + + ```xml + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneConfigurationName + CarPlay Configuration + UISceneDelegateClassName + flutter_carplay.FlutterCarPlaySceneDelegate + + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + ``` + +### That's it, you're ready to build your first CarPlay app! 🚀 😎 + +## Android Auto Get Started + +### Requirement Actions after Installation of the Package + +1. The Android platform version must be set to 21. Update your `android/app/build.gradle.kts` as below: + +```diff +# Update to use at minimum api 21 ++ minSdk = 21 +- minSdk = 19 +``` + +2. To setup Android Auto, you need to add a metadata tag in your `AndroidManifest.xml` file. Open `android/app/src/main/AndroidManifest.xml` and add the following : + + +Inside the `` tag: + +```xml + + + + + + +``` + +Inside the `` tag: + +```xml + + + + + + + + + + + + + +``` + +3. Create a new directory named `xml` inside `android/app/src/main/res/` if it doesn't already exist. Then, create a new XML file named `automotive_app_desc.xml` in the `res/xml/` directory and add the following content: + +```xml + + + + + + + +``` + +For others use, please check official [Android Auto documentation](https://developer.android.com/training/cars/apps/auto). + +4. In your `MainActivity.kt` file, make the necessary to resuse and cache the engine as follow : + +On Android Auto Service, use the same engine as the app if the app is already running, otherwise create a new one and cache using the id `FAAConstants.flutterEngineId`. +To avoid creating multiple engines, you need to override the `provideFlutterEngine` and `configureFlutterEngine` methods as below : + +```kotlin +package com.example.flutter_carplay_example + +import android.content.Context +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor +import com.oguzhnatly.flutter_android_auto.FAAConstants + +class MainActivity : FlutterActivity() { + override fun provideFlutterEngine(context: Context): FlutterEngine? { + // Use engine from cache if it has been started by Android Auto. + return FlutterEngineCache.getInstance().get(FAAConstants.flutterEngineId); + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + // Cache the engine to make it usable by Android Auto. + FlutterEngineCache.getInstance().put(FAAConstants.flutterEngineId, flutterEngine) + super.configureFlutterEngine(flutterEngine) + } +} +``` + +## Android Auto vs Android Automotive OS + +Android Auto and Android Automotive OS (AAOS) are different targets: + +- **Android Auto** is a projected experience. The app runs on a phone, and the vehicle display is rendered by an Android Auto host using templates from the Android for Cars App Library. This is what `flutter_carplay` supports on Android. +- **Android Automotive OS** is Android running directly in the vehicle. A Flutter app can be installed and launched on AAOS like any other Android app, but that opens the app's normal Android activity and shows the regular Flutter UI. + +`flutter_carplay` does not convert a Flutter app into a native AAOS app and does not render Android Auto templates when the app is opened normally on AAOS. The templates are rendered only by a compatible Android Auto host. + +In practice: + +- Use this package for Android Auto template apps. +- Do not expect additional behavior for a normal Flutter app installed on AAOS. +- If you are building a full native AAOS app, build and test the Flutter Android app UI directly for the vehicle environment instead of relying on Android Auto templates. + +For Android Auto testing from a phone emulator or device, use the Android Auto Desktop Head Unit instructions in the official [Android Auto testing documentation](https://developer.android.com/training/cars/testing). + +## Solve problems configuring your project + +Take a look at [this detailed issue reply](https://github.com/oguzhnatly/flutter_carplay/issues/3#issuecomment-926146126) if you got any error. + +## Usage & Features + +To see a complete example for both CarPlay and Android Auto, check the example project. + +[**See Full Example**](https://github.com/oguzhnatly/flutter_carplay/blob/master/example/lib/main.dart) + +### Basic Usage for Android Auto + +Import all the classes you need from a single file: + +```dart +import 'package:flutter_carplay/flutter_carplay.dart'; +``` + +Initialize the Android Auto controller and set a root template: + +```dart +final FlutterAndroidAuto _androidAuto = FlutterAndroidAuto(); + +await FlutterAndroidAuto.setRootTemplate( + template: AATabBarTemplate( + tabs: [ + AAListTemplate( + title: 'Home', + tabTitle: 'Home', + systemIcon: 'house.fill', + sections: [ + AAListSection( + items: [ + AAListItem( + title: 'Item 1', + subtitle: 'Detail Text', + onPress: (complete, self) { + complete(); + }, + ), + ], + ), + ], + ), + ], + ), +); +``` + +> It is recommended to set the root template in the first `initState` of your app, after Android Auto is connected. + +### Listen Connection Changes for Android Auto + +```dart +_androidAuto.addListenerOnConnectionChange(onAndroidAutoConnectionChange); + +void onAndroidAutoConnectionChange(ConnectionStatusTypes status) { + // ConnectionStatusTypes.connected + // ConnectionStatusTypes.disconnected + // ConnectionStatusTypes.unknown +} + +_androidAuto.removeListenerOnConnectionChange(); +``` + +### Android Auto API Methods + +#### **FlutterAndroidAuto.setRootTemplate** + +Sets the root template of the navigation hierarchy. If one already exists, it replaces it entirely. + +The template must be one of: **AATabBarTemplate**, **AAGridTemplate**, **AAListTemplate**, **AAPaneTemplate**, **AAMessageTemplate**, or **AALongMessageTemplate**. + +```dart +await FlutterAndroidAuto.setRootTemplate( + template: /* Android Auto template */, +); +``` + +#### **FlutterAndroidAuto.push** + +Adds a template to the navigation hierarchy and displays it. + +The template must be one of: **AAGridTemplate**, **AAListTemplate**, **AAPaneTemplate**, **AAMessageTemplate**, or **AALongMessageTemplate**. + +```dart +await FlutterAndroidAuto.push( + template: /* Android Auto template */, +); +``` + +#### **FlutterAndroidAuto.showAlert** + +Presents an `AAAlertTemplate` as a full-screen modal. Android Auto does not support true overlay modals, so the alert is pushed onto the navigation stack as a `MessageTemplate`. + +```dart +await FlutterAndroidAuto.showAlert(template: alertTemplate); +``` + +#### **FlutterAndroidAuto.popModal** + +Dismisses the currently presented `AAAlertTemplate`. + +```dart +await FlutterAndroidAuto.popModal(); +``` + +#### **FlutterAndroidAuto.updateTabBarTemplates** + +Updates the tabs of the currently displayed `AATabBarTemplate` without rebuilding the root from Dart. + +```dart +await FlutterAndroidAuto.updateTabBarTemplates(template: updatedTabBarTemplate); +``` + +#### **FlutterAndroidAuto.updatePaneTemplate** + +Updates an existing `AAPaneTemplate` and invalidates its Android Auto screen. + +```dart +await FlutterAndroidAuto.updatePaneTemplate(template: paneTemplate); +``` + +### Android Auto Message Template + +Use `AAMessageTemplate` for simple empty states, errors, or informational screens. + +```dart +final template = AAMessageTemplate( + title: 'No saved places', + message: 'Save places on your phone to access them here.', +); + +await FlutterAndroidAuto.setRootTemplate(template: template); + +await template.update( + title: 'Saved places synced', + message: 'Your saved places are now available in Android Auto.', +); +``` + +Use `AALongMessageTemplate` for longer informational text that needs more room +than a simple message template. + +```dart +final template = AALongMessageTemplate( + title: 'Safety information', + message: 'Keep your attention on the road. This longer Android Auto message ' + 'template is intended for content that needs more space.', +); + +await FlutterAndroidAuto.push(template: template); +``` + +### Android Auto Pane Template + +Use `AAPaneTemplate` for compact informational screens on Android Auto. It maps to Android's native `PaneTemplate` and is the closest Android equivalent for CarPlay-style information screens. + +```dart +await FlutterAndroidAuto.push( + template: AAPaneTemplate( + title: 'Vehicle Info', + items: [ + AAPaneItem(title: 'Battery', detail: '82%'), + AAPaneItem( + title: 'Navigation', + detail: 'Route ready', + imageUrl: 'images/svg_navigation.svg', + imageTint: const AutoImageTint.platform(), + ), + ], + actions: [ + AAPaneAction( + title: 'Refresh', + isPrimary: true, + onPress: () { + // Refresh content. + }, + ), + ], + ), +); +``` + +Pane rows are informational on Android and cannot be tapped. Use pane actions for user interaction. + +### Flutter Asset SVG Images + +Flutter asset SVGs can be used in image fields such as `CPListItem.image`, `CPGridButton.image`, `CPPointOfInterest.image`, `CPListImageRowItem` image collections, `AAListItem.imageUrl`, and `AAPaneTemplate` image fields. The package rasterizes local `.svg` assets to PNG bytes before sending them to the native CarPlay or Android Auto layer. Remote SVG URLs and `file://` SVGs are not rasterized. + +### Basic Usage for Car Play + +- Import the all classes that you need from just one file: + +```dart +import 'package:flutter_carplay/flutter_carplay.dart'; +``` + +- Initialize the CarPlay Controllers, set a root template for the CarPlay view hierarchy and ensure to well update the root template : + +```dart +final FlutterCarplay _flutterCarplay = FlutterCarplay(); + +await FlutterCarplay.setRootTemplate( + rootTemplate: CPTabBarTemplate( + templates: [ + CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + accessoryImage: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + self.setDetailText("You can change the detail text.. 🚀"); + self.setAccessoryImage('images/logo_flutter_1080px_clr.png'); + Future.delayed(const Duration(seconds: 1), () { + self.setDetailText("Customizable Detail Text"); + complete(); + }); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + ), + ], + ), + animated: true, +); +_flutterCarplay.forceUpdateRootTemplate(); +``` + +### CarPlay Search Template + +![Flutter CarPlay Search Template](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/search_template.png) + +Use `CPSearchTemplate` when your CarPlay app needs a native search screen. Your app's CarPlay entitlement controls which templates are available for its approved category. The example app keeps the parking entitlement for Point of Interest support; switch the entitlement in your app target if Apple approved a different category. Return result rows from `onUpdatedSearchText`, handle row selection in `onSelectedResult`, and call the provided completion callback after your app finishes handling the selected result. + +```dart +await FlutterCarplay.push( + template: CPSearchTemplate( + onUpdatedSearchText: (searchText, update) { + update([ + CPListItem( + text: 'Result for $searchText', + detailText: 'Tap to select', + ), + ]); + }, + onSelectedResult: (selectedItem, complete) { + complete(); + }, + ), +); +``` + +> You can set a root template without initializing the CarPlay Controllers, but some callback functions may not work or most likely you will get an error. + +> It's recommended that you should set the root template in the first initState of your app. + +### Basic Usage for Android Auto + +Android Auto row/list affordances follow the AndroidX Car App API names. Selectable lists render radio buttons on every row, `isBrowsable` renders the system navigation affordance, and `toggle` renders a switch in the row. + +```dart +final FlutterAndroidAuto flutterAndroidAuto = FlutterAndroidAuto(); + +await FlutterAndroidAuto.setRootTemplate( + template: AAListTemplate( + title: 'Home', + sections: [ + AAListSection( + selectedIndex: 0, + onSelected: (selectedIndex, selectedItem) { + print('Selected $selectedIndex: ${selectedItem.title}'); + }, + items: [ + AAListItem(title: 'Radio option 1'), + AAListItem(title: 'Radio option 2'), + ], + ), + AAListSection( + title: 'Rows', + items: [ + AAListItem( + title: 'Open details', + isBrowsable: true, + onPress: (complete, item) { + complete(); + }, + ), + AAListItem( + title: 'Toggle item', + toggle: AAToggle( + isChecked: true, + onCheckedChange: (checked, item) { + print('${item.title}: $checked'); + }, + ), + ), + ], + ), + ], + ), +); +flutterAndroidAuto.forceUpdateRootTemplate(); +``` + +### Listen Connection Changes + +You can detect connection changes, such as when CarPlay is connected to iPhone, is in the background, or is completely disconnected. + +```dart +/// Add the listener +_flutterCarplay.addListenerOnConnectionChange(onCarplayConnectionChange); + +void onCarplayConnectionChange(ConnectionStatusTypes status) { + // Do things when carplay connection status is: + // - ConnectionStatusTypes.connected + // - ConnectionStatusTypes.background + // - ConnectionStatusTypes.disconnected + // - ConnectionStatusTypes.unknown +} + +/// Remove the listener +_flutterCarplay.removeListenerOnConnectionChange(); +``` + +### CarPlay API Methods + +#### **CarPlay.setRootTemplate** + +Sets the root template of the navigation hierarchy. If a navigation +hierarchy already exists, CarPlay replaces the entire hierarchy. + +- rootTemplate is a template to use as the root of a new navigation hierarchy. If one exists, + it will replace the current rootTemplate. **Must be one of the type:** + **CPTabBarTemplate**, **CPGridTemplate**, **CPListTemplate**, **CPInformationTemplate**, **CPPointOfInterestTemplate**, or **CPSearchTemplate**. If not, it will throw a **TypeError**. +- If animated is true, CarPlay animates the presentation of the template, but will be ignored + this flag when there isn’t an existing navigation hierarchy to replace. + +> CarPlay cannot have more than 5 templates on one screen. + +```dart +FlutterCarplay.setRootTemplate( + rootTemplate: /* CPTabBarTemplate, CPGridTemplate, CPListTemplate, CPInformationTemplate, CPPointOfInterestTemplate, or CPSearchTemplate */, + animated: true, +); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +#### **CarPlay.push** + +Adds a template to the navigation hierarchy and displays it. + +- template is to add to the navigation hierarchy. **Must be one of the type:** **CPGridTemplate**, **CPListTemplate**, **CPInformationTemplate**, **CPPointOfInterestTemplate**, or **CPSearchTemplate**. If not, it will throw a **TypeError**. +- If animated is true, CarPlay animates the transition between templates. + +> There is a limit to the number of templates that you can push onto the screen. All apps are limited to pushing up to 5 templates in depth, including the root template. + +```dart +FlutterCarplay.push( + template: /* CPGridTemplate, CPListTemplate, CPInformationTemplate, CPPointOfInterestTemplate, or CPSearchTemplate */, + animated: true, +); +``` + +#### **CarPlay.pop** + +Removes the top-most template from the navigation hierarchy. + +- If animated is true, CarPlay animates the transition between templates. +- count represents how many times this function will occur. + +```dart +FlutterCarplay.pop(); +// OR +FlutterCarplay.pop(animated: true, count: 1); +``` + +#### **CarPlay.popToRoot** + +Removes all of the templates from the navigation hierarchy except the root template. + +- If animated is true, CarPlay animates the presentation of the template. + +```dart +FlutterCarplay.popToRoot(animated: true); +``` + +#### **CarPlay.popModal** + +Removes a modal template. Since **CPAlertTemplate** and **CPActionSheetTemplate** are both modals, they can be removed. + +- If animated is true, CarPlay animates the transition between templates. + +```dart +FlutterCarplay.popModal(animated: true); +``` + +#### **CarPlay.showSharedNowPlaying** + +Navigate to the shared instance of the Now Playing Template. This allows users to control media playback directly from CarPlay. + +- If animated is true, CarPlay animates the transition to the Now Playing template. + +```dart +FlutterCarplay.showSharedNowPlaying(animated: true); +``` + +#### **CarPlay.connectionStatus** + +Getter for current CarPlay connection status. It will return one of **ConnectionStatusTypes** as String. + +```dart +FlutterCarplay.connectionStatus +``` + +## Templates + +CarPlay supports general purpose templates such as alerts, lists, and tab bars. They are used to display contents on the CarPlay screen from the app. [The Developer Guide](https://developer.apple.com/carplay/documentation/CarPlay-App-Programming-Guide.pdf) contains more information on the templates that Apple supports. + +> If you attempt to use a template not supported by your entitlement, an exception will occur at runtime. + +### Tab Bar Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/tabbar_template.png) + +The tab bar is a multi-purpose container for other templates, with each template occupying one tab in the tab bar. + +```dart +final CPTabBarTemplate tabBarTemplate = CPTabBarTemplate( + templates: [ + CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + onPress: (complete, self) { + // Returns the self class so that the item + // can be updated within self while loading + self.setDetailText("You can change the detail text.. 🚀"); + // complete function stops the loading + complete(); + }, + // Supports three image formats (v1.1.0+): + // - Asset: 'images/logo_flutter_1080px_clr.png' + // - File: 'file:///path/to/local/image.png' + // - URL: 'https://example.com/image.png' + image: 'images/logo_flutter_1080px_clr.png', + ), + CPListItem( + text: "Item 2", + detailText: "Start progress bar", + isPlaying: false, + playbackProgress: 0, + // asset name defined in pubspec.yaml + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + complete(); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + ), + CPListTemplate( + sections: [], + title: "Settings", + // If there is no section in the list template, + // empty view title and subtitle variants will be shown + emptyViewTitleVariants: ["Settings"], + emptyViewSubtitleVariants: [ + "No settings have been added here yet. You can start adding right away" + ], + showsTabBadge: false, + systemIcon: "gear", + ), + ], +); + +FlutterCarplay.setRootTemplate(rootTemplate: tabBarTemplate, animated: true); +``` + +### Grid Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/grid_template.png) + +Grid Template is a specific style of menu that presents up to 8 items represented by an image and a title. Use the grid template to let people select from a fixed list of categories. + +```dart +final CPGridTemplate gridTemplate = CPGridTemplate( + title: "Grid Template", + buttons: [ + for (var i = 1; i < 9; i++) + CPGridButton( + titleVariants: ["Item $i"], + image: 'images/logo_flutter_1080px_clr.png', + onPress: () { + print("Grid Button $i pressed"); + }, + ), + ], +); + +FlutterCarplay.push(template: gridTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: gridTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Alert Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/alert_template.png) + +Alerts provide important information about your app's status. An alert consists of a title and one or more buttons, depending on the type. + +> If underlying conditions permit, alerts can be dismissed programatically. + +```dart +final CPAlertTemplate alertTemplate = CPAlertTemplate( + titleVariants: ["Alert Title"], + actions: [ + CPAlertAction( + title: "Okay", + style: CPAlertActionStyles.normal, + onPress: () { + print("Okay pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Cancel", + style: CPAlertActionStyles.cancel, + onPress: () { + print("Cancel pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Remove", + style: CPAlertActionStyles.destructive, + onPress: () { + print("Remove pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + ], +), + +FlutterCarplay.showAlert(template: alertTemplate, animated: true); +``` + +### Action Sheet Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/actionsheet_template.png) + +Action Sheet Template is a type of alert that appears when control or action is taken and gives a collection of options based on the current context. + +> Use action sheets to let people initiate tasks, or to request confirmation before performing a potentially destructive operation. + +```dart +final CPActionSheetTemplate actionSheetTemplate = CPActionSheetTemplate( + title: "Action Sheet Template", + message: "This is an example message.", + actions: [ + CPAlertAction( + title: "Cancel", + style: CPAlertActionStyles.cancel, + onPress: () { + print("Cancel pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Dismiss", + style: CPAlertActionStyles.destructive, + onPress: () { + print("Dismiss pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Ok", + style: CPAlertActionStyles.normal, + onPress: () { + print("Ok pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + ], +); + +FlutterCarplay.showActionSheet(template: actionSheetTemplate, animated: true); +``` + +### List Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/list_template.png) + +A list presents data as a scrolling, single-column table of rows that can be divided into sections. Lists are ideal for text-based content, and can be used as a means of navigation for hierarchical information. Each item in a list can include attributes such as an icon, title, subtitle, disclosure indicator, progress indicator, playback status, or read status. + +> Some cars dynamically limit lists to a maximum of 12 items. You always need to be prepared to handle the case where only 12 items can be shown. Items beyond the maximum will not be shown. + +```dart +final CPListTemplate listTemplate = CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + onPress: (complete, self) { + // Returns the self class so that the item + // can be updated within self while loading + self.setDetailText("You can change the detail text.. 🚀"); + // complete function stops the loading + complete(); + }, + image: 'images/logo_flutter_1080px_clr.png', + accessoryImage: 'images/logo_flutter_1080px_clr.png', + ), + CPListItem( + text: "Item 2", + detailText: "Start progress bar", + isPlaying: false, + playbackProgress: 0, + // asset name defined in pubspec.yaml + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + complete(); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + // If there is no section in the list template, + // empty view title and subtitle variants will be shown + emptyViewTitleVariants: ["Home"], + emptyViewSubtitleVariants: [ + "Nothing has added here yet. You can start adding right away" + ], +); + +FlutterCarplay.push(template: listTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: listTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Information Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/information_template.png) + +An Information Template shows a list of items, and actions (max. three)) as array of text buttons. + +> The list is limited to 10 items. Items beyond the maximum will not be shown. Up to three actions are supported. + +```dart +final CPInformationTemplate informationTemplate = CPInformationTemplate( + title: "Title", + layout: CPInformationTemplateLayout.twoColumn, + actions: [ + CPTextButton( + title: "Button Title 1", + onPress: () { + print("Button 1"); + } + ), + CPTextButton( + title: "Button Title 2", + onPress: () { + print("Button 2"); + } + ), + ], + informationItems: [ + CPInformationItem(title: "Title", detail: "Detail"), + ] +); + +FlutterCarplay.push(template: informationTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: informationTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +You can also update an existing `CPInformationTemplate` without rebuilding the full template. + +```dart +await _flutterCarplay.updateInformationTemplateItems( + elementId: informationTemplate.uniqueId, + items: [ + CPInformationItem(title: "Battery", detail: "85%"), + CPInformationItem(title: "Range", detail: "240 km"), + ], +); + +await _flutterCarplay.updateInformationTemplateActions( + elementId: informationTemplate.uniqueId, + actions: [ + CPTextButton( + title: "Refresh", + onPress: () { + print("Refresh tapped"); + }, + ), + ], +); +``` + +### List Image Row Item + +`CPListImageRowItem` lets you show a row of multiple images inside a `CPListTemplate` section. + +```dart +final CPListTemplate listTemplate = CPListTemplate( + title: "Gallery", + sections: [ + CPListSection( + items: [ + CPListImageRowItem( + text: "Recently played", + gridImages: [ + "https://picsum.photos/200/200?1", + "https://picsum.photos/200/200?2", + "https://picsum.photos/200/200?3", + ], + onPress: (complete, item) { + print(item.text); + complete(); + }, + onItemPress: (complete, item, index) { + print("Tapped image index: $index"); + complete(); + }, + ), + ], + ), + ], +); +``` + +Use `CPListImageRowItem.getMaximumNumberOfGridImages()` if you want to respect the host limit before building the row. + +### Point Of Interest Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/point_of_interest_template.png) + +A Point Of Interest template shows multiple points of interest on a Map +The map section is determined by the points of interest. + +> The Template is limited to 12 Points of Interest. + +```dart + final CPPointOfInterestTemplate pointOfInterestTemplate = + CPPointOfInterestTemplate(title: "Title", poi: [ + CPPointOfInterest( + latitude: 51.5052, + longitude: 7.4938, + title: "Title", + subtitle: "Subtitle", + summary: "Summary", + detailTitle: "DetailTitle", + detailSubtitle: "detailSubtitle", + detailSummary: "detailSummary", + image: "images/logo_flutter_1080px_clr.png", + primaryButton: CPTextButton( + title: "Primary", + onPress: () { + print("Primary button pressed"); + } + ), + secondaryButton: CPTextButton( + title: "Secondary", + onPress: () { + print("Secondary button pressed"); + })) + ]); + + FlutterCarplay.push(template: pointOfInterestTemplate, animated: true); + // OR + FlutterCarplay.setRootTemplate(rootTemplate: pointOfInterestTemplate, animated: true); + // You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Now Playing Template + +The Now Playing template provides a standardized interface for media playback controls in CarPlay. It uses the system's shared instance and integrates with your app's media session. + +```dart +// Navigate to the Now Playing template +FlutterCarplay.showSharedNowPlaying(animated: true); +``` + +> **Note**: The Now Playing template displays information from your app's active media session. Make sure your app is properly configured with AVAudioSession and media playback controls for the best experience. + +> **Multiple Calls Safe**: The `showSharedNowPlaying()` method can be called multiple times safely without causing issues. + +## Android Auto Templates + +Android Auto templates are built using the [Android for Cars App Library](https://developer.android.com/training/cars/apps). Each template is vehicle-optimized and rendered by the host application on the car screen. + +### Tab Bar Template (Android Auto) + +The Tab Bar Template is a container that displays multiple child templates as tabs. Rendered as `TabTemplate` from the Car App Library. + +> Requires Car App API level 6+. On older hosts, the first tab's content is shown as a plain list. Supports between 2 and 4 tabs — extra tabs beyond the limit are discarded with a warning in logcat. + +```dart +final AATabBarTemplate tabBarTemplate = AATabBarTemplate( + tabs: [ + AAListTemplate( + title: "Home", + tabTitle: "Home", + systemIcon: "house.fill", + sections: [ + AAListSection( + items: [ + AAListItem( + title: "Item 1", + subtitle: "Detail Text", + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + ], + ), + ], + ), + AAGridTemplate( + title: "Grid", + tabTitle: "Grid", + systemIcon: "square.grid.2x2", + buttons: [ + AAGridButton( + titleVariants: ["Button 1"], + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) async { + complete(); + }, + ), + ], + ), + ], +); + +await FlutterAndroidAuto.setRootTemplate(template: tabBarTemplate); +``` + +To update the tabs dynamically without resetting the root: + +```dart +tabBarTemplate.updateTabs([/* updated list of AATemplate */]); +await FlutterAndroidAuto.updateTabBarTemplates(template: tabBarTemplate); +``` + +### Grid Template (Android Auto) + +The Grid Template displays a grid of tappable cells, each with an image and a title. Use it to let users select from a fixed set of categories. + +> Android Auto recommends a maximum of 8 buttons per grid. + +```dart +final AAGridTemplate gridTemplate = AAGridTemplate( + title: "Grid Template", + buttons: [ + for (var i = 1; i <= 8; i++) + AAGridButton( + titleVariants: ["Item $i"], + image: 'images/logo_flutter_1080px_clr.png', + loadingMessage: "Loading...", + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + ], + emptyViewTitleVariants: ["No items available"], +); + +await FlutterAndroidAuto.push(template: gridTemplate); +// OR +await FlutterAndroidAuto.setRootTemplate(template: gridTemplate); +``` + +### Alert Template (Android Auto) + +Alerts present important information as a full-screen message with one or more action buttons. Because Android Auto does not support true overlay modals, the alert is pushed onto the navigation stack as a `MessageTemplate`. + +> Only one alert can be presented at a time. Use `FlutterAndroidAuto.popModal()` to dismiss it programmatically. + +```dart +final AAAlertTemplate alertTemplate = AAAlertTemplate( + title: "Alert Title", + message: "This is an example message.", + actions: [ + AAAlertAction( + title: "Confirm", + style: AAAlertActionStyle.normal, + onPress: () { + print("Confirm pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + AAAlertAction( + title: "Cancel", + style: AAAlertActionStyle.cancel, + onPress: () { + print("Cancel pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + AAAlertAction( + title: "Delete", + style: AAAlertActionStyle.destructive, + onPress: () { + print("Delete pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + ], + onPresent: (bool completed) { + print("Alert presented: $completed"); + }, +); + +await FlutterAndroidAuto.showAlert(template: alertTemplate); +``` + +### List Template (Android Auto) + +A list presents data as a scrollable, single-column table divided into sections. Each item can include a title, subtitle, and an image. + +```dart +final AAListTemplate listTemplate = AAListTemplate( + title: "Home", + sections: [ + AAListSection( + title: "First Section", + items: [ + AAListItem( + title: "Item 1", + subtitle: "Detail Text", + // Supports three image formats: + // - Asset: 'images/logo.png' + // - File: 'file:///path/to/image.png' + // - URL: 'https://example.com/image.png' + image: 'images/logo_flutter_1080px_clr.png', + loadingMessage: "Loading...", + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + AAListItem( + title: "Item 2", + subtitle: "No image example", + onPress: (complete, self) async { + complete(); + }, + ), + ], + ), + ], + emptyViewTitleVariants: ["Nothing here yet"], +); + +await FlutterAndroidAuto.push(template: listTemplate); +// OR +await FlutterAndroidAuto.setRootTemplate(template: listTemplate); +``` + +# Support + +If this package has been helpful, consider supporting its development: + +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=github)](https://github.com/sponsors/oguzhnatly) + +Your support helps maintain and improve this package! ❤️ + +# Star History + +[![Star History Chart](https://star-history.dera.page/svg?repos=oguzhnatly/flutter_carplay&type=Date)](https://star-history.dera.page/#oguzhnatly/flutter_carplay&Date) + +# LICENSE + +[**MIT License**](https://github.com/oguzhnatly/flutter_carplay/blob/master/LICENSE) + +Copyright (c) 2021 Oğuzhan Atalay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/flutter_carplay/analysis_options.yaml b/third_party/flutter_carplay/analysis_options.yaml new file mode 100644 index 00000000..e0b53705 --- /dev/null +++ b/third_party/flutter_carplay/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +formatter: + page_width: 80 +linter: + rules: + avoid_redundant_argument_values: true + prefer_final_locals: true + prefer_const_constructors: true + avoid_print: true + prefer_single_quotes: true diff --git a/third_party/flutter_carplay/android/build.gradle b/third_party/flutter_carplay/android/build.gradle new file mode 100644 index 00000000..fbdf051d --- /dev/null +++ b/third_party/flutter_carplay/android/build.gradle @@ -0,0 +1,54 @@ +group = "com.example.flutter_carplay" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.oguzhnatly.flutter_android_auto" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + implementation("androidx.car.app:app:1.7.0") + testImplementation("junit:junit:4.13.2") + } +} diff --git a/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties b/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b82aa23a --- /dev/null +++ b/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/flutter_carplay/android/settings.gradle b/third_party/flutter_carplay/android/settings.gradle new file mode 100644 index 00000000..de8e5222 --- /dev/null +++ b/third_party/flutter_carplay/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_carplay' diff --git a/third_party/flutter_carplay/android/src/main/AndroidManifest.xml b/third_party/flutter_carplay/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3ea6bff5 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt new file mode 100644 index 00000000..7d8fe044 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt @@ -0,0 +1,103 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Context +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.validation.HostValidator +import androidx.car.app.Session +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.FlutterEngineCache; + +/// DELTA B from upstream 1.6.5. +/// +/// Upstream unconditionally builds its own bare `FlutterEngine(this)` when the +/// cache is empty. In an app whose engine owns platform channels — MeshMapper's +/// USB serial and MapLibre tile cache both live on the engine, not on an +/// Activity — that headless engine is the wrong one: its isolate has none of +/// those channels, and if the Activity later builds the real engine the app ends +/// up with two, each with its own copy of every plugin, fighting over this +/// plugin's own static template state. +/// +/// [FAAEngineProvider] lets the host app supply the engine instead. The default +/// path below is unchanged, so apps that do not set a factory behave exactly as +/// they did before. +object FAAEngineProvider { + /// Set this before the car host can start the service — an Application + /// subclass's onCreate is the only reliably early enough place, since + /// Application.onCreate always precedes any Service.onCreate in the process. + /// + /// The factory must return a fully configured engine with Dart already + /// running; this class will not call executeDartEntrypoint on it. + var factory: ((Context) -> FlutterEngine)? = null +} + +/// DELTA D from upstream 1.6.5. +/// +/// A map template draws nothing by itself: the host hands the app a Surface and +/// the app renders onto it. That rendering is entirely app-specific — MeshMapper +/// puts a MapLibre map there — so rather than teach this plugin about any one +/// map SDK, let the host app supply the SurfaceCallback. +/// +/// Leave [factory] null and nothing changes: no surface callback is registered +/// and the plugin behaves exactly as upstream. +object FAASurfaceProvider { + /// Set from the host app before a car session starts. Registering the + /// callback requires the androidx.car.app.ACCESS_SURFACE permission, which + /// is why this is opt-in rather than always-on. + var factory: ((CarContext) -> SurfaceCallback)? = null +} + +class AndroidAutoService : CarAppService() { + companion object { + /// The Android Auto session that this service is handling. + var session: AndroidAutoSession? = null + } + + override fun onCreate() { + super.onCreate() + val engineCache = FlutterEngineCache.getInstance() + val flutterEngineId = FAAConstants.flutterEngineId + + if (engineCache.get(flutterEngineId) != null) return; + + // DELTA B: give the host app first refusal on engine creation. It is + // responsible for having started Dart; we only cache the result under + // the id this plugin looks up. + FAAEngineProvider.factory?.let { create -> + engineCache.put(flutterEngineId, create(this)) + return + } + + // Create new engine in headless mode + val flutterEngine = FlutterEngine(this) + flutterEngine.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint.createDefault() + ) + // Cache the engine + engineCache.put(flutterEngineId, flutterEngine) + } + + + /// DELTA C from upstream 1.6.5: upstream returns ALLOW_ALL_HOSTS_VALIDATOR + /// unconditionally. Android's own documentation restricts that to debug + /// builds — it lets *any* app on the device bind this service and drive the + /// car surface — and shipping it is a Play review risk. Validate against the + /// car-app library's bundled allowlist of known hosts in release builds, and + /// keep the permissive behaviour only where the debug flag is set, which is + /// what the Desktop Head Unit needs. + override fun createHostValidator(): HostValidator = + if ((applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.Builder(applicationContext) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build() + } + + override fun onCreateSession(): Session { + session = AndroidAutoSession() + return session!! + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt new file mode 100644 index 00000000..cfa64cef --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt @@ -0,0 +1,5 @@ +package com.oguzhnatly.flutter_android_auto + +object FAAConstants { + val flutterEngineId = "android_auto_id" +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt new file mode 100644 index 00000000..9c4eab65 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt @@ -0,0 +1,35 @@ +package com.oguzhnatly.flutter_android_auto + +enum class FAAConnectionTypes { + connected, + background, + disconnected, +} + +enum class FAAChannelTypes { + onAndroidAutoConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + pushTemplate, + popTemplate, + popToRootTemplate, + updateListTemplateSections, + updatePaneTemplate, + onListItemSelected, + onListItemSelectedComplete, + onListSectionSelected, + onToggleCheckedChange, + onPaneActionPressed, + onMapActionPressed, + onScreenBackButtonPressed, + setAlert, + closePresent, + onAlertActionPressed, + onPresentStateChanged, + updateTabBarTemplates, + onTabBarItemSelected, + onGridButtonPressed, + onGridButtonSelectedComplete, + updateMessageTemplate, + updateLongMessageTemplate, +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt new file mode 100644 index 00000000..dc31f1ad --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt @@ -0,0 +1,118 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.car.app.model.CarIcon +import androidx.core.graphics.drawable.IconCompat +import io.flutter.FlutterInjector +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object FAAHelpers { + fun makeFCPChannelId(event: String): String { + return "com.oguzhnatly.flutter_android_auto" + event + } +} + +fun makeCarIconFromBytes(bytes: ByteArray?): CarIcon? { + return makeCarIconFromBytes(bytes, null) +} + +fun makeCarIconFromBytes(bytes: ByteArray?, imageTint: FAAImageTint?): CarIcon? { + if (bytes == null || bytes.isEmpty()) return null + return try { + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +suspend fun loadCarImageFromAsset( + context: Context, + assetPath: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val key = FlutterInjector.instance().flutterLoader() + .getLookupKeyForAsset(assetPath) + context.assets.open(key).use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) ?: return@use null + bitmap.toCarIcon(imageTint) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun loadCarImageFromFile( + path: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val filePath = path.removePrefix("file://") + val file = File(filePath) + if (!file.exists()) return@withContext null + val bitmap = BitmapFactory.decodeFile(filePath) ?: return@withContext null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun resolveCarIcon( + context: Context, + bytes: ByteArray?, + imageUrl: String?, + imageTint: FAAImageTint? = null, +): CarIcon? { + makeCarIconFromBytes(bytes, imageTint)?.let { return it } + + val source = imageUrl?.trim() + if (source.isNullOrEmpty()) return null + + return when { + source.startsWith("http") -> loadCarImageAsync(source, imageTint) + source.startsWith("file://") -> loadCarImageFromFile(source, imageTint) + else -> loadCarImageFromAsset(context, source, imageTint) + } +} + +suspend fun loadCarImageAsync( + imageUrl: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val url = URL(imageUrl) + val connection = url.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + val inputStream = connection.inputStream + val bitmap = BitmapFactory.decodeStream(inputStream) ?: return@withContext null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +private fun android.graphics.Bitmap.toCarIcon(imageTint: FAAImageTint? = null): CarIcon { + val iconCompat = IconCompat.createWithBitmap(this) + val builder = CarIcon.Builder(iconCompat) + if (imageTint != null) { + builder.setTint(imageTint.toCarColor()) + } + return builder.build() +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt new file mode 100644 index 00000000..3c101e71 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt @@ -0,0 +1,1058 @@ +package com.oguzhnatly.flutter_android_auto + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.ScreenManager +import androidx.car.app.model.Action +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarText +import androidx.car.app.model.GridItem +import androidx.car.app.model.GridTemplate +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.LongMessageTemplate +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.SectionedItemList +import androidx.car.app.model.Tab +import androidx.car.app.model.TabContents +import androidx.car.app.model.TabTemplate +import androidx.car.app.model.Template +import androidx.car.app.model.ActionStrip +import androidx.car.app.navigation.model.MapController +import androidx.car.app.navigation.model.MapWithContentTemplate +import androidx.car.app.model.Toggle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class FlutterAndroidAutoPlugin : FlutterPlugin, EventChannel.StreamHandler { + private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + lateinit var channel: MethodChannel + lateinit var eventChannel: EventChannel + + companion object { + var events: EventChannel.EventSink? = null + var currentTemplate: Template? = null + var currentScreen: Screen? = null + var currentAlertScreen: Screen? = null + + private var currentRootTemplateElementId: String? = null + private var currentTabBarData: FAATabBarTemplate? = null + private var activeTabContentId: String? = null + private var pendingTemplateElementId: String? = null + + private val templateDataByElementId = mutableMapOf>() + private val templateRuntimeTypes = mutableMapOf() + private val templateBackButtons = mutableMapOf() + private val templatesByElementId = mutableMapOf() + private val screensByElementId = mutableMapOf() + + fun sendEvent(type: String, data: Map) { + events?.success(mapOf("type" to type, "data" to data)) + } + + fun onAndroidAutoConnectionChange(status: FAAConnectionTypes) { + sendEvent( + type = FAAChannelTypes.onAndroidAutoConnectionChange.name, + data = mapOf("status" to status.name) + ) + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel( + flutterPluginBinding.binaryMessenger, + FAAHelpers.makeFCPChannelId("") + ) + eventChannel = EventChannel( + flutterPluginBinding.binaryMessenger, + FAAHelpers.makeFCPChannelId("/event") + ) + setUpHandlers() + } + + private fun setUpHandlers() { + channel.setMethodCallHandler { call, result -> + try { + when (call.method) { + FAAChannelTypes.forceUpdateRootTemplate.name -> forceUpdateRootTemplate(call, result) + FAAChannelTypes.setRootTemplate.name -> setRootTemplate(call, result) + FAAChannelTypes.pushTemplate.name -> pushTemplate(call, result) + FAAChannelTypes.popTemplate.name -> popTemplate(call, result) + FAAChannelTypes.popToRootTemplate.name -> popToRootTemplate(call, result) + FAAChannelTypes.updateListTemplateSections.name -> updateListTemplateSections(call, result) + FAAChannelTypes.updatePaneTemplate.name -> updatePaneTemplate(call, result) + FAAChannelTypes.updateMessageTemplate.name -> updateMessageTemplate(call, result) + FAAChannelTypes.updateLongMessageTemplate.name -> updateLongMessageTemplate(call, result) + FAAChannelTypes.onListItemSelectedComplete.name -> onListItemSelectedComplete(call, result) + FAAChannelTypes.onGridButtonSelectedComplete.name -> onGridButtonSelectedComplete(call, result) + FAAChannelTypes.setAlert.name -> setAlert(call, result) + FAAChannelTypes.closePresent.name -> closePresent(call, result) + FAAChannelTypes.updateTabBarTemplates.name -> updateTabBarTemplates(call, result) + else -> result.notImplemented() + } + } catch (e: Exception) { + e.printStackTrace() + result.completeWithError(e) + } + } + eventChannel.setStreamHandler(this) + } + + private fun forceUpdateRootTemplate(call: MethodCall, result: MethodChannel.Result) { + currentScreen?.invalidate() + result.success(true) + } + + private fun popTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val screenManager = carContext.getCarService(ScreenManager::class.java) + if (screenManager.stackSize > 1) { + screenManager.pop() + result.success(true) + } else { + result.error("No screens to pop", "You are at root screen", null) + } + } + + private fun popToRootTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val screenManager = carContext.getCarService(ScreenManager::class.java) + if (screenManager.stackSize > 1) { + screenManager.popToRoot() + result.success(true) + } else { + result.error("No screens to pop", "You are at root screen", null) + } + } + + private fun onListItemSelectedComplete(call: MethodCall, result: MethodChannel.Result) { + rebuildPendingTemplate(result) + } + + private fun onGridButtonSelectedComplete(call: MethodCall, result: MethodChannel.Result) { + rebuildPendingTemplate(result) + } + + private fun rebuildPendingTemplate(result: MethodChannel.Result) { + val elementId = pendingTemplateElementId + pendingTemplateElementId = null + if (elementId == null) { + result.success(true) + return + } + rebuildElementTemplate(elementId, result) + } + + private fun setAlert(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + + pluginScope.launchMethodCall(result) { + val alertTemplate = FAAAlertTemplate.fromJson(data) + val messageTemplate = buildAlertMessageTemplate(alertTemplate) + + val alertScreen = object : Screen(carContext) { + override fun onGetTemplate(): Template = messageTemplate + + init { + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + currentAlertScreen = null + sendEvent( + type = FAAChannelTypes.onPresentStateChanged.name, + data = mapOf( + "elementId" to alertTemplate.elementId, + "completed" to false, + ) + ) + } + } + }) + } + } + + currentAlertScreen = alertScreen + carContext.getCarService(ScreenManager::class.java).push(alertScreen) + true + } + } + + private fun buildAlertMessageTemplate(alert: FAAAlertTemplate): Template { + val body = alert.message?.takeIf { it.isNotBlank() } ?: " " + val title = alert.title.takeIf { it.isNotBlank() } ?: " " + val builder = MessageTemplate.Builder(body).setTitle(title) + + for (action in alert.actions) { + val actionBuilder = Action.Builder() + .setTitle(action.title) + .setOnClickListener { + sendEvent( + type = FAAChannelTypes.onAlertActionPressed.name, + data = mapOf("elementId" to action.elementId) + ) + } + + if (action.style == "destructive") { + actionBuilder.setBackgroundColor(CarColor.RED) + } + + builder.addAction(actionBuilder.build()) + } + return builder.build() + } + + private fun closePresent(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + val alertScreen = currentAlertScreen + if (alertScreen == null) { + result.error("No modal", "No modal template is currently presented", null) + return + } + carContext.getCarService(ScreenManager::class.java).pop() + currentAlertScreen = null + result.success(true) + } + + private fun updateTabBarTemplates(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val tabBarTemplate = try { + FAATabBarTemplate.fromJson(data) + } catch (e: IllegalArgumentException) { + result.error("Invalid template", e.message, null) + return + } + + pluginScope.launchMethodCall(result) { + currentTabBarData = tabBarTemplate + storeTemplateData(tabBarTemplate.elementId, "FAATabBarTemplate", data, false, currentScreen) + storeTabData(tabBarTemplate) + if (tabBarTemplate.tabs.none { it.elementId == activeTabContentId }) { + activeTabContentId = tabBarTemplate.tabs.firstOrNull()?.elementId + } + currentTemplate = buildNativeTabTemplate(tabBarTemplate) + currentScreen?.invalidate() + true + } + } + + private fun updateListTemplateSections(call: MethodCall, result: MethodChannel.Result) { + val elementId = call.argument("elementId") ?: "" + val sections = call.argument>>("sections") ?: emptyList() + val data = templateDataByElementId[elementId] + if (data == null) { + result.error("No template found", "AAListTemplate not found with elementId: $elementId", null) + return + } + val parsedSections = sections.map { FAAListSection.fromJson(it) } + val hasSelectableList = parsedSections.any { it.isSelectable } + val isSingleUntitledList = parsedSections.size == 1 && parsedSections.first().title.isEmpty() + if (hasSelectableList && !isSingleUntitledList) { + result.error( + "Invalid template", + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title.", + null, + ) + return + } + + data["sections"] = sections + rebuildElementTemplate(elementId, result) + } + + private fun updatePaneTemplate(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "A pane template payload is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + if (elementId.isEmpty()) { + result.error("Missing elementId", "The pane template must have an element id", null) + return + } + + storeTemplateData(elementId, "FAAPaneTemplate", data, templateBackButtons[elementId] ?: true, screensByElementId[elementId]) + rebuildElementTemplate(elementId, result) + } + + private fun updateMessageTemplate(call: MethodCall, result: MethodChannel.Result) { + updateMessageTemplate(call, result, "message", "FAAMessageTemplate") + } + + private fun updateLongMessageTemplate(call: MethodCall, result: MethodChannel.Result) { + updateMessageTemplate(call, result, "long message", "FAALongMessageTemplate") + } + + private fun updateMessageTemplate( + call: MethodCall, + result: MethodChannel.Result, + templateType: String, + runtimeType: String, + ) { + val elementId = call.argument("elementId") ?: "" + if (elementId.isEmpty()) { + result.error("Missing elementId", "elementId is required to update a $templateType template", null) + return + } + + val data = mutableMapOf( + "_elementId" to elementId, + "title" to (call.argument("title") ?: ""), + "message" to (call.argument("message") ?: ""), + ) + storeTemplateData(elementId, runtimeType, data, templateBackButtons[elementId] ?: true, screensByElementId[elementId]) + rebuildElementTemplate(elementId, result) + } + + private fun pushTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val runtimeType = call.argument("runtimeType") ?: "" + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + + pluginScope.launchMethodCall(result) { + val newScreen = object : Screen(carContext) { + override fun onGetTemplate(): Template = templatesByElementId[elementId] + ?: getTemplateBlocking(runtimeType, data, true, this) + + init { + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + removeTemplateData(elementId) + sendEvent( + type = FAAChannelTypes.onScreenBackButtonPressed.name, + data = mapOf("elementId" to elementId) + ) + } + } + }) + } + } + + val template = buildTemplateForType(runtimeType, data, true, newScreen) + + storeTemplateData(elementId, runtimeType, data, true, newScreen) + templatesByElementId[elementId] = template + carContext.getCarService(ScreenManager::class.java).push(newScreen) + true + } + } + + private fun setRootTemplate(call: MethodCall, result: MethodChannel.Result) { + val runtimeType = call.argument("runtimeType") ?: "" + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + + pluginScope.launchMethodCall(result) { + val template = buildTemplateForType(runtimeType, data, false, currentScreen) + + currentRootTemplateElementId = elementId + currentTemplate = template + storeTemplateData(elementId, runtimeType, data, false, currentScreen) + templatesByElementId[elementId] = template + currentScreen?.invalidate() + true + } + } + + private fun rebuildElementTemplate(elementId: String, result: MethodChannel.Result) { + val runtimeType = templateRuntimeTypes[elementId] + val data = templateDataByElementId[elementId] + if (runtimeType == null || data == null) { + result.error("No template found", "No Android Auto template found with elementId: $elementId", null) + return + } + + pluginScope.launchMethodCall(result) { + val template = if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == elementId }) { + buildNativeTabTemplate(currentTabBarData!!) + } else { + buildTemplateForType( + runtimeType, + data, + templateBackButtons[elementId] ?: true, + screensByElementId[elementId], + ) + } + + if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == elementId }) { + currentTemplate = template + currentScreen?.invalidate() + } else { + templatesByElementId[elementId] = template + if (currentRootTemplateElementId == elementId) { + currentTemplate = template + currentScreen?.invalidate() + } else { + screensByElementId[elementId]?.invalidate() + } + } + true + } + } + + private fun storeTemplateData( + elementId: String, + runtimeType: String, + data: Map, + addBackButton: Boolean, + screen: Screen?, + ) { + if (elementId.isEmpty()) return + templateDataByElementId[elementId] = data.toMutableMap() + templateRuntimeTypes[elementId] = runtimeType + templateBackButtons[elementId] = addBackButton + if (screen != null) screensByElementId[elementId] = screen + } + + private fun removeTemplateData(elementId: String) { + templateDataByElementId.remove(elementId) + templateRuntimeTypes.remove(elementId) + templateBackButtons.remove(elementId) + templatesByElementId.remove(elementId) + screensByElementId.remove(elementId) + } + + private fun storeTabData(tabBar: FAATabBarTemplate) { + for (tab in tabBar.tabs) { + storeTemplateData(tab.elementId, tab.runtimeType, tab.templateData, false, currentScreen) + } + } + + private fun getTemplateBlocking( + runtimeType: String, + data: Map, + addBackButton: Boolean, + owningScreen: Screen?, + ): Template = kotlinx.coroutines.runBlocking { + buildTemplateForType(runtimeType, data, addBackButton, owningScreen) + } + + private suspend fun buildTemplateForType( + runtimeType: String, + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template = when (runtimeType) { + "FAAListTemplate" -> getListTemplate(data, addBackButton, owningScreen) + "FAAGridTemplate" -> getGridTemplate(data, addBackButton, owningScreen) + "FAATabBarTemplate" -> { + val tabBarTemplate = FAATabBarTemplate.fromJson(data) + currentTabBarData = tabBarTemplate + storeTabData(tabBarTemplate) + if (activeTabContentId == null || tabBarTemplate.tabs.none { it.elementId == activeTabContentId }) { + activeTabContentId = tabBarTemplate.tabs.firstOrNull()?.elementId + } + buildNativeTabTemplate(tabBarTemplate) + } + "FAAPaneTemplate" -> getPaneTemplate(data, addBackButton) + // DELTA D: see FAASurfaceProvider in AndroidAutoService.kt. + "FAAMapWithContentTemplate" -> + getMapWithContentTemplate(data, addBackButton, owningScreen) + "FAAMessageTemplate" -> getMessageTemplate(data, addBackButton) + "FAALongMessageTemplate" -> getLongMessageTemplate(data, addBackButton) + else -> throw IllegalArgumentException("Template type $runtimeType is not supported") + } + + /// DELTA D from upstream 1.6.5: MapWithContentTemplate support. + /// + /// The host draws nothing here — the app does, onto a Surface it gets from + /// AppManager.setSurfaceCallback. All this builds is the frame: a map region + /// the app renders into, plus whatever content template Dart nested inside + /// it, which goes through the same dispatch so any supported template works + /// as the content half. + /// + /// Requires car API level 7 and the androidx.car.app.MAP_TEMPLATES and + /// androidx.car.app.ACCESS_SURFACE permissions, and an app category that is + /// permitted to draw maps (navigation, POI, or weather — not IOT). + private suspend fun getMapWithContentTemplate( + data: Map, + addBackButton: Boolean, + owningScreen: Screen?, + ): Template { + val contentType = data["contentRuntimeType"] as? String + ?: throw IllegalArgumentException("contentRuntimeType is required") + val contentData = data["contentTemplate"] as? Map + ?: throw IllegalArgumentException("contentTemplate is required") + + val content = buildTemplateForType(contentType, contentData, addBackButton, owningScreen) + val builder = MapWithContentTemplate.Builder().setContentTemplate(content) + + @Suppress("UNCHECKED_CAST") + val actions = (data["mapActions"] as? List>).orEmpty() + if (actions.isNotEmpty()) { + val strip = ActionStrip.Builder() + for (action in actions) { + strip.addAction(createMapAction(currentScreen?.carContext, action)) + } + builder.setMapController( + MapController.Builder().setMapActionStrip(strip.build()).build(), + ) + } + return builder.build() + } + + /// An action for the vertical strip the host draws down the right edge of + /// the map. + /// + /// Deliberately icon-only: ACTIONS_CONSTRAINTS_MAP leaves maxCustomTitles at + /// zero, so setting a title here gets the whole template rejected. The title + /// still travels from Dart and is used only as the content description. + private suspend fun createMapAction( + carContext: CarContext?, + action: Map, + ): Action { + val elementId = action["_elementId"] as? String ?: "" + val imageUrl = action["imageUrl"] as? String + val builder = Action.Builder() + + val icon = if (carContext != null && imageUrl != null) { + resolveCarIcon(carContext, null, imageUrl, null) + } else null + if (icon != null) builder.setIcon(icon) + + if (action["isPrimary"] == true) builder.setFlags(Action.FLAG_PRIMARY) + if (action["onPress"] == true) { + builder.setOnClickListener { + sendEvent( + type = FAAChannelTypes.onMapActionPressed.name, + data = mapOf("elementId" to elementId), + ) + } + } + return builder.build() + } + + private suspend fun buildNativeTabTemplate(tabBar: FAATabBarTemplate): Template { + val activeId = activeTabContentId ?: tabBar.tabs.firstOrNull()?.elementId + val activeTab = tabBar.tabs.find { it.elementId == activeId } + ?: tabBar.tabs.firstOrNull() + ?: return ListTemplate.Builder().setLoading(true).build() + + val carContext = AndroidAutoService.session?.carContext + val supportsTabTemplate = carContext != null && carContext.getCarAppApiLevel() >= 6 + + if (tabBar.tabs.size < 2 || !supportsTabTemplate) { + return buildInnerTemplateForTab(activeTab, false) + } + + val cappedTabs = tabBar.tabs.take(4) + val resolvedActiveTab = if (cappedTabs.contains(activeTab)) activeTab else cappedTabs.first() + + val tabCallback = object : TabTemplate.TabCallback { + override fun onTabSelected(tabContentId: String) { + pluginScope.launch { + activeTabContentId = tabContentId + currentTabBarData?.let { + currentTemplate = buildNativeTabTemplate(it) + currentScreen?.invalidate() + } + sendEvent( + type = FAAChannelTypes.onTabBarItemSelected.name, + data = mapOf("elementId" to tabContentId) + ) + } + } + } + + val builder = TabTemplate.Builder(tabCallback) + builder.setHeaderAction(Action.APP_ICON) + builder.setActiveTabContentId(resolvedActiveTab.elementId) + + for (tab in cappedTabs) { + builder.addTab( + Tab.Builder() + .setTitle(resolveTabTitle(tab)) + .setIcon(resolveTabIcon(carContext, tab)) + .setContentId(tab.elementId) + .build() + ) + } + + val innerTemplate = buildInnerTemplateForTab(resolvedActiveTab, false) + builder.setTabContents(TabContents.Builder(innerTemplate).build()) + return builder.build() + } + + private suspend fun buildInnerTemplateForTab( + tab: FAATabBarItem, + addBackButton: Boolean, + ): Template { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + val runtimeType = templateRuntimeTypes[tab.elementId] ?: tab.runtimeType + return buildTemplateForType(runtimeType, data, addBackButton, currentScreen) + } + + private fun resolveTabTitle(tab: FAATabBarItem): String { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + return data["tabTitle"] as? String ?: data["title"] as? String ?: tab.tabTitle + } + + private suspend fun resolveTabIcon(carContext: CarContext?, tab: FAATabBarItem): CarIcon { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + val iconUrl = data["iconUrl"] as? String ?: tab.iconUrl + if (carContext != null && !iconUrl.isNullOrBlank()) { + resolveCarIcon(carContext, null, iconUrl)?.let { return it } + } + + val systemIcon = data["systemIcon"] as? String ?: tab.systemIcon + if (carContext != null && !systemIcon.isNullOrBlank()) { + val value = systemIcon.trim() + if (value.startsWith("http") || value.startsWith("file://") || value.contains("/") || value.contains(".")) { + resolveCarIcon(carContext, null, value)?.let { return it } + } + } + + return when (systemIcon?.lowercase()) { + "map", "map.fill", "location", "location.fill", "navigation", "navigation.fill", + "location.north", "location.north.fill" -> CarIcon.PAN + "exclamationmark", "exclamationmark.triangle", "exclamationmark.triangle.fill", + "alert", "bell", "bell.fill" -> CarIcon.ALERT + "pencil", "pencil.circle", "compose", "square.and.pencil", "message", + "message.fill", "bubble.left", "bubble.right" -> CarIcon.COMPOSE_MESSAGE + "chevron.backward", "chevron.left", "arrow.backward", "back", "arrow.left", + "arrowshape.backward", "arrowshape.backward.fill" -> CarIcon.BACK + "xmark.circle", "xmark", "multiply", "error", "exclamationmark.circle" -> CarIcon.ERROR + "hand.draw", "hand.point.up", "pan" -> CarIcon.PAN + else -> CarIcon.COMPOSE_MESSAGE + } + } + + private fun getMessageTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template = buildMessageTemplate( + data, + addBackButton, + createBuilder = { MessageTemplate.Builder(it) }, + setTitle = { title -> setTitle(title) }, + setHeaderAction = { action -> setHeaderAction(action) }, + build = { build() }, + ) + + private fun getLongMessageTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template = buildMessageTemplate( + data, + addBackButton, + createBuilder = { LongMessageTemplate.Builder(it) }, + setTitle = { title -> setTitle(title) }, + setHeaderAction = { action -> setHeaderAction(action) }, + build = { build() }, + ) + + private fun buildMessageTemplate( + data: Map, + addBackButton: Boolean, + createBuilder: (String) -> Builder, + setTitle: Builder.(String) -> Unit, + setHeaderAction: Builder.(Action) -> Unit, + build: Builder.() -> Template, + ): Template { + val template = FAAMessageTemplate.fromJson(data) + val builder = createBuilder(template.message) + builder.setTitle(template.title) + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun getPaneTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAPaneTemplate.fromJson(data) + val paneBuilder = Pane.Builder() + + val isLoading = template.isLoading || template.items.isEmpty() + paneBuilder.setLoading(isLoading) + if (!isLoading) { + for (item in template.items) { + paneBuilder.addRow(createPaneRowFromItem(carContext, item)) + } + + val imageIcon = makeCarIconFromBytes(template.imageData, template.imageTint) + ?: if (carContext != null && template.imageUrl != null) { + resolveCarIcon(carContext, null, template.imageUrl, template.imageTint) + } else null + if (imageIcon != null) paneBuilder.setImage(imageIcon) + + for (action in template.actions) { + paneBuilder.addAction(createPaneAction(carContext, action)) + } + } + + // DELTA D: an empty title means no header. build() does not require one, + // and setting an empty CarText would draw an empty header bar rather + // than none at all. + val paneTemplateBuilder = PaneTemplate.Builder(paneBuilder.build()) + if (template.title.isNotEmpty()) paneTemplateBuilder.setTitle(template.title) + if (addBackButton) paneTemplateBuilder.setHeaderAction(Action.BACK) + return paneTemplateBuilder.build() + } + + private suspend fun createPaneRowFromItem( + carContext: CarContext?, + item: FAAPaneItem, + ): Row { + val rowBuilder = Row.Builder().setTitle(CarText.create(item.title)) + item.detail?.let { rowBuilder.addText(CarText.create(it)) } + + val imageIcon = makeCarIconFromBytes(item.imageData, item.imageTint) + ?: if (carContext != null && item.imageUrl != null) { + resolveCarIcon(carContext, null, item.imageUrl, item.imageTint) + } else null + if (imageIcon != null) { + rowBuilder.setImage( + imageIcon, + if (item.imageTint != null) Row.IMAGE_TYPE_ICON else Row.IMAGE_TYPE_SMALL, + ) + } + return rowBuilder.build() + } + + private suspend fun createPaneAction( + carContext: CarContext?, + action: FAAPaneAction, + ): Action { + val actionBuilder = Action.Builder().setTitle(action.title) + val imageIcon = makeCarIconFromBytes(action.imageData, action.imageTint) + ?: if (carContext != null && action.imageUrl != null) { + resolveCarIcon(carContext, null, action.imageUrl, action.imageTint) + } else null + if (imageIcon != null) actionBuilder.setIcon(imageIcon) + if (action.isPrimary) actionBuilder.setFlags(Action.FLAG_PRIMARY) + if (action.isOnPressListenerActive) { + actionBuilder.setOnClickListener { + sendEvent( + type = FAAChannelTypes.onPaneActionPressed.name, + data = mapOf("elementId" to action.elementId), + ) + } + } + return actionBuilder.build() + } + + private suspend fun getListTemplate( + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAListTemplate.fromJson(data) + val builder = ListTemplate.Builder().setTitle(template.title) + val emptyMessage = template.emptyViewTitleVariants.firstOrNull() + val isEmpty = template.sections.isEmpty() || template.sections.all { it.items.isEmpty() } + + if (isEmpty) { + if (emptyMessage != null) { + builder.setLoading(false) + builder.setSingleList(ItemList.Builder().setNoItemsMessage(emptyMessage).build()) + } else { + builder.setLoading(true) + } + } else { + builder.setLoading(false) + val isSingleList = template.sections.size == 1 && template.sections.first().title.isEmpty() + if (isSingleList) { + builder.setSingleList( + createItemListFromSection(carContext, template.sections.first(), template.elementId, "FAAListTemplate", owningScreen) + ) + } else { + for (section in template.sections) { + builder.addSectionedList( + SectionedItemList.create( + createItemListFromSection(carContext, section, template.elementId, "FAAListTemplate", owningScreen), + section.title, + ) + ) + } + } + } + + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun createItemListFromSection( + carContext: CarContext?, + section: FAAListSection, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + ): ItemList { + val itemListBuilder = ItemList.Builder() + val useSelectionListener = section.isOnSelectedListenerActive || section.selectedIndex != null + + for (item in section.items) { + itemListBuilder.addItem( + createRowFromItem( + carContext, + item, + templateElementId, + runtimeType, + owningScreen, + enableOnClick = !useSelectionListener, + ) + ) + } + + if (useSelectionListener) { + itemListBuilder.setOnSelectedListener { selectedIndex -> + if (section.isOnSelectedListenerActive) { + sendEvent( + type = FAAChannelTypes.onListSectionSelected.name, + data = mapOf( + "elementId" to section.elementId, + "selectedIndex" to selectedIndex, + ) + ) + } + } + } + + section.selectedIndex?.let { selectedIndex -> + if (selectedIndex >= 0 && selectedIndex < section.items.size) { + itemListBuilder.setSelectedIndex(selectedIndex) + } + } + + return itemListBuilder.build() + } + + private suspend fun createRowFromItem( + carContext: CarContext?, + item: FAAListItem, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + enableOnClick: Boolean = true, + ): Row { + val rowBuilder = Row.Builder().setTitle(CarText.create(item.title)) + item.subtitle?.let { rowBuilder.addText(CarText.create(it)) } + + val imageIcon = makeCarIconFromBytes(item.imageData, item.imageTint) + ?: if (carContext != null && item.imageUrl != null) { + resolveCarIcon(carContext, null, item.imageUrl, item.imageTint) + } else null + if (imageIcon != null) { + rowBuilder.setImage( + imageIcon, + if (item.imageTint != null) Row.IMAGE_TYPE_ICON else Row.IMAGE_TYPE_SMALL, + ) + } + + val trailingIcon = makeCarIconFromBytes(item.trailingImageData, item.trailingImageTint) + ?: if (carContext != null && item.trailingImage != null) { + resolveCarIcon(carContext, null, item.trailingImage, item.trailingImageTint) + } else null + if (trailingIcon != null) { + rowBuilder.addAction(Action.Builder().setIcon(trailingIcon).build()) + } + + item.isBrowsable?.let { rowBuilder.setBrowsable(it) } + + item.toggle?.let { toggle -> + val toggleBuilder = Toggle.Builder { checked -> + if (toggle.isOnCheckedChangeListenerActive) { + sendEvent( + type = FAAChannelTypes.onToggleCheckedChange.name, + data = mapOf( + "elementId" to item.elementId, + "checked" to checked, + ) + ) + } + }.setChecked(toggle.isChecked) + toggle.isEnabled?.let { toggleBuilder.setEnabled(it) } + rowBuilder.setToggle(toggleBuilder.build()) + } + + if (enableOnClick && item.isOnPressListenerActive) { + rowBuilder.setOnClickListener { + showLoadingForTemplate(templateElementId, runtimeType, item.loadingMessage) + sendEvent( + type = FAAChannelTypes.onListItemSelected.name, + data = mapOf("elementId" to item.elementId) + ) + } + } + return rowBuilder.build() + } + + private suspend fun getGridTemplate( + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAGridTemplate.fromJson(data) + val builder = GridTemplate.Builder().setTitle(template.title) + val emptyMessage = template.emptyViewTitleVariants.firstOrNull() + + if (template.buttons.isEmpty()) { + if (emptyMessage != null) { + builder.setLoading(false) + builder.setSingleList(ItemList.Builder().setNoItemsMessage(emptyMessage).build()) + } else { + builder.setLoading(true) + } + } else { + builder.setLoading(false) + val itemListBuilder = ItemList.Builder() + for (button in template.buttons) { + itemListBuilder.addItem( + createGridItemFromButton(carContext, button, template.elementId, "FAAGridTemplate", owningScreen) + ) + } + builder.setSingleList(itemListBuilder.build()) + } + + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun createGridItemFromButton( + carContext: CarContext?, + button: FAAGridButton, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + ): GridItem { + val itemBuilder = GridItem.Builder().setTitle(button.title) + val carIcon = makeCarIconFromBytes(button.imageData) + ?: if (carContext != null && button.image != null) { + resolveCarIcon(carContext, null, button.image) + } else null + + itemBuilder.setImage(carIcon ?: CarIcon.COMPOSE_MESSAGE) + + if (button.isOnPressListenerActive) { + itemBuilder.setOnClickListener { + showLoadingForTemplate(templateElementId, runtimeType, button.loadingMessage) + sendEvent( + type = FAAChannelTypes.onGridButtonPressed.name, + data = mapOf("elementId" to button.elementId) + ) + } + } + return itemBuilder.build() + } + + private fun showLoadingForTemplate( + templateElementId: String, + runtimeType: String, + loadingMessage: String? = null, + ) { + pendingTemplateElementId = templateElementId + val loading = buildLoadingTemplate(runtimeType, loadingMessage, templateBackButtons[templateElementId] ?: false) + + if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == templateElementId }) { + currentTemplate = loading + currentScreen?.invalidate() + return + } + + templatesByElementId[templateElementId] = loading + if (currentRootTemplateElementId == templateElementId) { + currentTemplate = loading + currentScreen?.invalidate() + } else { + screensByElementId[templateElementId]?.invalidate() + } + } + + private fun buildLoadingTemplate( + runtimeType: String, + loadingMessage: String?, + addBackButton: Boolean, + ): Template { + return if (runtimeType == "FAAGridTemplate") { + GridTemplate.Builder() + .setLoading(true) + .apply { + if (!loadingMessage.isNullOrBlank()) setTitle(loadingMessage) + if (addBackButton) setHeaderAction(Action.BACK) + } + .build() + } else { + ListTemplate.Builder() + .setLoading(true) + .apply { + if (!loadingMessage.isNullOrBlank()) setTitle(loadingMessage) + if (addBackButton) setHeaderAction(Action.BACK) + } + .build() + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + FlutterAndroidAutoPlugin.events = events + } + + override fun onCancel(arguments: Any?) { + events?.endOfStream() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt new file mode 100644 index 00000000..8c6e6da6 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt @@ -0,0 +1,35 @@ +package com.oguzhnatly.flutter_android_auto + +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +private const val METHOD_CALL_ERROR_CODE = "android_auto_error" +private const val METHOD_CALL_CANCELLED_CODE = "operation_cancelled" + +internal fun CoroutineScope.launchMethodCall( + result: MethodChannel.Result, + block: suspend () -> T, +): Job = launch { + val value = try { + block() + } catch (exception: CancellationException) { + result.error(METHOD_CALL_CANCELLED_CODE, exception.message, null) + throw exception + } catch (exception: Exception) { + result.completeWithError(exception) + return@launch + } + + result.success(value) +} + +internal fun MethodChannel.Result.completeWithError(exception: Exception) { + error( + METHOD_CALL_ERROR_CODE, + exception.message ?: exception.javaClass.simpleName, + exception.stackTraceToString(), + ) +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt new file mode 100644 index 00000000..f2058b6c --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt @@ -0,0 +1,36 @@ +package com.oguzhnatly.flutter_android_auto + + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.service.credentials.Action +import android.text.Spannable +import android.text.SpannableString +import androidx.annotation.OptIn +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.car.app.annotations.ExperimentalCarApi +import androidx.car.app.model.CarLocation +import androidx.car.app.model.ItemList +import androidx.car.app.model.Metadata +import androidx.car.app.model.PlaceListMapTemplate +import androidx.car.app.model.PlaceMarker +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.model.ListTemplate + +class MainScreen(carContext: CarContext) : Screen(carContext) { + init {} + + override fun onGetTemplate(): Template { + val appName = + carContext.applicationInfo.loadLabel(carContext.packageManager) + .toString() ?: "" + + return FlutterAndroidAutoPlugin.currentTemplate + ?: ListTemplate.Builder().setTitle(appName).setLoading(true).build() + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt new file mode 100644 index 00000000..f73a91d0 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt @@ -0,0 +1,49 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Intent +import androidx.car.app.AppManager +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +class AndroidAutoSession : Session() { + override fun onCreateScreen(intent: Intent): Screen { + val screen = MainScreen(carContext) + FlutterAndroidAutoPlugin.currentScreen = screen + + // DELTA D: hand the host app the CarContext so it can render onto the + // car's Surface. Opt-in — apps that set no factory never touch + // AppManager, and so never need the ACCESS_SURFACE permission. + FAASurfaceProvider.factory?.let { create -> + carContext.getCarService(AppManager::class.java) + .setSurfaceCallback(create(carContext)) + } + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.connected + ) + super.onStart(owner) + } + + + override fun onResume(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.connected + ) + super.onResume(owner) + } + + override fun onStop(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.disconnected + ) + super.onStop(owner) + } + }) + + return screen + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt new file mode 100644 index 00000000..5a3167cf --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt @@ -0,0 +1,16 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAAlertAction( + val elementId: String, + val title: String, + val style: String, +) { + companion object { + fun fromJson(map: Map): FAAAlertAction { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val style = map["style"] as? String ?: "normal" + return FAAAlertAction(elementId, title, style) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt new file mode 100644 index 00000000..7f713b03 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt @@ -0,0 +1,21 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAAlertTemplate( + val elementId: String, + val title: String, + val message: String?, + val actions: List, +) { + companion object { + fun fromJson(map: Map): FAAAlertTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val message = map["message"] as? String + val actions = (map["actions"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAAlertAction.fromJson(it) } + } ?: emptyList() + return FAAAlertTemplate(elementId, title, message, actions) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt new file mode 100644 index 00000000..986a6df9 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt @@ -0,0 +1,78 @@ +package com.oguzhnatly.flutter_android_auto + +import android.graphics.Color +import androidx.car.app.model.CarColor +import kotlin.math.roundToInt + +data class FAAImageTint( + val type: String, + val color: FAAColor? = null, + val darkColor: FAAColor? = null, + val selectedSafe: Boolean = true, +) { + fun toCarColor(): CarColor { + return when (type) { + "platform" -> CarColor.DEFAULT + "primary" -> CarColor.PRIMARY + "secondary" -> CarColor.SECONDARY + "red" -> CarColor.RED + "green" -> CarColor.GREEN + "blue" -> CarColor.BLUE + "yellow" -> CarColor.YELLOW + "custom" -> { + val lightColor = color?.toColorInt() ?: Color.WHITE + val darkColor = darkColor?.toColorInt() ?: lightColor + CarColor.createCustom(lightColor, darkColor) + } + else -> CarColor.DEFAULT + } + } + + companion object { + fun fromJson(map: Map?): FAAImageTint? { + if (map == null) return null + val type = map["type"] as? String ?: return null + return FAAImageTint( + type = type, + color = FAAColor.fromJson(map["color"] as? Map), + darkColor = FAAColor.fromJson(map["darkColor"] as? Map), + selectedSafe = map["selectedSafe"] as? Boolean ?: true, + ) + } + } +} + +data class FAAColor( + val red: Int, + val green: Int, + val blue: Int, + val alpha: Double = 1.0, +) { + fun toColorInt(): Int { + val alphaInt = if (alpha > 1) alpha.roundToInt() else (alpha * 255).roundToInt() + return Color.argb( + alphaInt.coerceIn(0, 255), + red.coerceIn(0, 255), + green.coerceIn(0, 255), + blue.coerceIn(0, 255), + ) + } + + companion object { + fun fromJson(map: Map?): FAAColor? { + if (map == null) return null + val red = numberValue(map["red"])?.roundToInt() ?: return null + val green = numberValue(map["green"])?.roundToInt() ?: return null + val blue = numberValue(map["blue"])?.roundToInt() ?: return null + val alpha = numberValue(map["alpha"]) ?: 1.0 + return FAAColor(red, green, blue, alpha) + } + + private fun numberValue(value: Any?): Double? { + return when (value) { + is Number -> value.toDouble() + else -> null + } + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt new file mode 100644 index 00000000..c9996a84 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt @@ -0,0 +1,33 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAGridButton( + val elementId: String, + val titleVariants: List, + val image: String?, + val imageData: ByteArray?, + val loadingMessage: String?, + val isOnPressListenerActive: Boolean, +) { + val title: String get() = titleVariants.firstOrNull() ?: "" + + companion object { + fun fromJson(map: Map): FAAGridButton { + val elementId = map["_elementId"] as? String ?: "" + val titleVariants = (map["titleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + val image = map["image"] as? String + val imageData = map["imageData"] as? ByteArray + val loadingMessage = map["loadingMessage"] as? String + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAGridButton( + elementId, + titleVariants, + image, + imageData, + loadingMessage, + isOnPressListenerActive, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt new file mode 100644 index 00000000..6f6f3926 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt @@ -0,0 +1,24 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAGridTemplate( + val elementId: String, + val title: String, + val buttons: List, + val emptyViewTitleVariants: List, +) { + companion object { + fun fromJson(map: Map): FAAGridTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val buttons = (map["buttons"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>) + ?.mapKeys { entry -> entry.key.toString() } + ?.let { btn -> FAAGridButton.fromJson(btn) } + } ?: emptyList() + val emptyViewTitleVariants = (map["emptyViewTitleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + + return FAAGridTemplate(elementId, title, buttons, emptyViewTitleVariants) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt new file mode 100644 index 00000000..4ac2fd10 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt @@ -0,0 +1,73 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListItem( + val elementId: String, + val title: String, + val subtitle: String? = null, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val trailingImage: String? = null, + val trailingImageData: ByteArray? = null, + val trailingImageTint: FAAImageTint? = null, + val loadingMessage: String? = null, + val isBrowsable: Boolean? = null, + val toggle: FAAToggle? = null, + val isOnPressListenerActive: Boolean, +) { + companion object { + fun fromJson(map: Map): FAAListItem { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val subtitle = map["subtitle"] as? String + val imageUrl = map["imageUrl"] as? String ?: map["image"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val trailingImage = map["trailingImage"] as? String + val trailingImageData = map["trailingImageData"] as? ByteArray + val trailingImageTint = + FAAImageTint.fromJson(map["trailingImageTint"] as? Map) + val loadingMessage = map["loadingMessage"] as? String + val isBrowsable = map["isBrowsable"] as? Boolean + val toggle = (map["toggle"] as? Map<*, *>)?.mapKeys { entry -> + entry.key.toString() + }?.let { FAAToggle.fromJson(it) } + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAListItem( + elementId, + title, + subtitle, + imageUrl, + imageData, + imageTint, + trailingImage, + trailingImageData, + trailingImageTint, + loadingMessage, + isBrowsable, + toggle, + isOnPressListenerActive, + ) + } + } +} + +data class FAAToggle( + val isChecked: Boolean = false, + val isEnabled: Boolean? = null, + val isOnCheckedChangeListenerActive: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAToggle { + val isChecked = map["isChecked"] as? Boolean ?: false + val isEnabled = map["isEnabled"] as? Boolean + val isOnCheckedChangeListenerActive = + map["onCheckedChange"] as? Boolean ?: false + + return FAAToggle( + isChecked, isEnabled, isOnCheckedChangeListenerActive + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt new file mode 100644 index 00000000..ff3bedc7 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt @@ -0,0 +1,30 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListSection( + val elementId: String, + val title: String, + val items: List, + val selectedIndex: Int? = null, + val isOnSelectedListenerActive: Boolean = false, +) { + val isSelectable: Boolean + get() = selectedIndex != null || isOnSelectedListenerActive + + companion object { + fun fromJson(map: Map): FAAListSection { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val items = (map["items"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAListItem.fromJson(it) } + } ?: emptyList() + val selectedIndex = map["selectedIndex"] as? Int + val isOnSelectedListenerActive = + map["onSelected"] as? Boolean ?: false + + return FAAListSection( + elementId, title, items, selectedIndex, isOnSelectedListenerActive + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt new file mode 100644 index 00000000..8b7b3dde --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt @@ -0,0 +1,31 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListTemplate( + val elementId: String, + val title: String, + val sections: List, + val emptyViewTitleVariants: List, +) { + init { + val hasSelectableList = sections.any { it.isSelectable } + val isSingleUntitledList = sections.size == 1 && sections.first().title.isEmpty() + require(!hasSelectableList || isSingleUntitledList) { + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title." + } + } + + companion object { + fun fromJson(map: Map): FAAListTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val sections = (map["sections"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAListSection.fromJson(it) } + } ?: emptyList() + val emptyViewTitleVariants = (map["emptyViewTitleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + + return FAAListTemplate(elementId, title, sections, emptyViewTitleVariants) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt new file mode 100644 index 00000000..00aec88f --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt @@ -0,0 +1,17 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAMessageTemplate( + val elementId: String, + val title: String, + val message: String, +) { + companion object { + fun fromJson(map: Map): FAAMessageTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val message = map["message"] as? String ?: "" + + return FAAMessageTemplate(elementId, title, message) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt new file mode 100644 index 00000000..5be23003 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt @@ -0,0 +1,33 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneAction( + val elementId: String, + val title: String, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val isPrimary: Boolean = false, + val isOnPressListenerActive: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAPaneAction { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val isPrimary = map["isPrimary"] as? Boolean ?: false + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAPaneAction( + elementId, + title, + imageUrl, + imageData, + imageTint, + isPrimary, + isOnPressListenerActive, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt new file mode 100644 index 00000000..03e6adaf --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt @@ -0,0 +1,23 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneItem( + val elementId: String, + val title: String, + val detail: String? = null, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, +) { + companion object { + fun fromJson(map: Map): FAAPaneItem { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val detail = map["detail"] as? String + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + + return FAAPaneItem(elementId, title, detail, imageUrl, imageData, imageTint) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt new file mode 100644 index 00000000..395fac6e --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt @@ -0,0 +1,42 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneTemplate( + val elementId: String, + val title: String, + val items: List, + val actions: List, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val isLoading: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAPaneTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val items = (map["items"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAPaneItem.fromJson(it) } + } ?: emptyList() + val actions = (map["actions"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAPaneAction.fromJson(it) } + } ?: emptyList() + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val isLoading = map["isLoading"] as? Boolean ?: false + + return FAAPaneTemplate( + elementId, + title, + items, + actions, + imageUrl, + imageData, + imageTint, + isLoading, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt new file mode 100644 index 00000000..a94e6a59 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt @@ -0,0 +1,26 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAATabBarItem( + val elementId: String, + val runtimeType: String, + val templateData: Map, + val tabTitle: String, + val systemIcon: String?, + val iconUrl: String?, +) { + companion object { + fun fromJson(map: Map): FAATabBarItem { + val elementId = map["elementId"] as? String ?: "" + val runtimeType = map["runtimeType"] as? String ?: "" + val templateData = (map["template"] as? Map<*, *>) + ?.mapKeys { it.key.toString() } ?: emptyMap() + val tabTitle = (templateData["tabTitle"] as? String) + ?: (templateData["title"] as? String) + ?: "" + val systemIcon = templateData["systemIcon"] as? String + val iconUrl = templateData["iconUrl"] as? String + + return FAATabBarItem(elementId, runtimeType, templateData, tabTitle, systemIcon, iconUrl) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt new file mode 100644 index 00000000..9c43702c --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt @@ -0,0 +1,22 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAATabBarTemplate( + val elementId: String, + val tabs: List, +) { + companion object { + fun fromJson(map: Map): FAATabBarTemplate { + val elementId = map["_elementId"] as? String ?: "" + val tabs = (map["tabs"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAATabBarItem.fromJson(it) } + } ?: emptyList() + tabs.forEach { tab -> + if (tab.runtimeType == "FAAListTemplate") { + FAAListTemplate.fromJson(tab.templateData) + } + } + return FAATabBarTemplate(elementId, tabs) + } + } +} diff --git a/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt new file mode 100644 index 00000000..a1c6456f --- /dev/null +++ b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt @@ -0,0 +1,60 @@ +package com.oguzhnatly.flutter_android_auto + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class FAATabBarTemplateTest { + @Test + fun `rejects an invalid selectable list in a non-active tab`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + FAATabBarTemplate.fromJson( + mapOf( + "_elementId" to "tabs", + "tabs" to listOf( + listTab("first", listOf(listSection())), + listTab( + "invalid", + listOf( + listSection( + title = "Options", + selectedIndex = 0, + ), + ), + ), + ), + ), + ) + } + + assertEquals( + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title.", + exception.message, + ) + } + + private fun listTab(id: String, sections: List>) = mapOf( + "elementId" to id, + "runtimeType" to "FAAListTemplate", + "template" to mapOf( + "_elementId" to id, + "title" to id, + "sections" to sections, + ), + ) + + private fun listSection( + title: String = "", + selectedIndex: Int? = null, + ) = mapOf( + "_elementId" to "section-$title", + "title" to title, + "selectedIndex" to selectedIndex, + "items" to listOf( + mapOf( + "_elementId" to "item-$title", + "title" to "Item", + ), + ), + ) +} diff --git a/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt new file mode 100644 index 00000000..03a0912e --- /dev/null +++ b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt @@ -0,0 +1,79 @@ +package com.oguzhnatly.flutter_android_auto + +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MethodCallResultTest { + @Test + fun `successful call completes the result exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { true }.join() + + assertEquals(listOf(true), result.successes) + assertTrue(result.errors.isEmpty()) + assertEquals(1, result.completionCount) + } + + @Test + fun `failed call reports the exception exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { + throw IllegalStateException("Template creation failed") + }.join() + + assertTrue(result.successes.isEmpty()) + assertEquals(1, result.errors.size) + assertEquals("android_auto_error", result.errors.single().code) + assertEquals("Template creation failed", result.errors.single().message) + assertEquals(1, result.completionCount) + } + + @Test + fun `cancelled call reports cancellation exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { + throw CancellationException("Plugin detached") + }.join() + + assertTrue(result.successes.isEmpty()) + assertEquals(1, result.errors.size) + assertEquals("operation_cancelled", result.errors.single().code) + assertEquals("Plugin detached", result.errors.single().message) + assertEquals(1, result.completionCount) + } +} + +private class RecordingResult : MethodChannel.Result { + data class Error( + val code: String, + val message: String?, + val details: Any?, + ) + + val successes = mutableListOf() + val errors = mutableListOf() + var notImplementedCount = 0 + + val completionCount: Int + get() = successes.size + errors.size + notImplementedCount + + override fun success(result: Any?) { + successes.add(result) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + errors.add(Error(errorCode, errorMessage, errorDetails)) + } + + override fun notImplemented() { + notImplementedCount++ + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart b/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart new file mode 100644 index 00000000..b050ac9a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart @@ -0,0 +1,37 @@ +import 'package:uuid/uuid.dart'; + +/// Display styles for an alert action button on Android Auto. +/// +/// - [normal] — default appearance, no background color applied. +/// - [cancel] — no native equivalent in the Car App Library; renders the same as [normal]. +/// - [destructive] — renders with a red background (`CarColor.RED`). +enum AAAlertActionStyle { normal, cancel, destructive } + +/// An action that can be performed from an [AAAlertTemplate] on Android Auto. +class AAAlertAction { + final String _elementId; + + /// The button label. + final String title; + + /// Visual style hint for the button. + final AAAlertActionStyle style; + + /// Called when the user taps this action. + final Function() onPress; + + AAAlertAction({ + required this.title, + this.style = AAAlertActionStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'style': style.name, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart b/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart new file mode 100644 index 00000000..40d9daf4 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart @@ -0,0 +1,49 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'alert_action.dart'; + +/// A template that presents a modal alert on Android Auto. +/// +/// Rendered as a full-screen [MessageTemplate] from the Car App Library. +/// Unlike CarPlay, Android Auto does not support true overlay modals, so the +/// alert occupies the entire screen and is pushed onto the navigation stack. +/// +/// Use [FlutterAndroidAuto.showAlert] to present it and +/// [FlutterAndroidAuto.popModal] to dismiss it programmatically. +class AAAlertTemplate implements AATemplate { + final String _elementId; + + /// Primary title shown at the top of the alert. + final String title; + + /// Optional body message displayed below the title. + final String? message; + + /// The action buttons available on the alert. + final List actions; + + /// Called when the alert finishes presenting. + /// [completed] is true when the alert was shown successfully. + final Function(bool completed)? onPresent; + + AAAlertTemplate({ + required this.title, + this.message, + required this.actions, + this.onPresent, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + 'actions': actions.map((e) => e.toJson()).toList(), + 'onPresent': onPresent != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/all.dart b/third_party/flutter_carplay/lib/aa_models/alert/all.dart new file mode 100644 index 00000000..b09017dd --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/all.dart @@ -0,0 +1,2 @@ +export 'alert_action.dart'; +export 'alert_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/all.dart b/third_party/flutter_carplay/lib/aa_models/all.dart new file mode 100644 index 00000000..b30e8d41 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/all.dart @@ -0,0 +1,8 @@ +export 'alert/all.dart'; +export 'grid/all.dart'; +export 'list/all.dart'; +export 'map/all.dart'; +export 'message/all.dart'; +export 'pane/all.dart'; +export 'tabbar/all.dart'; +export 'template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/grid/all.dart b/third_party/flutter_carplay/lib/aa_models/grid/all.dart new file mode 100644 index 00000000..2a5672a9 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/all.dart @@ -0,0 +1,2 @@ +export 'grid_button.dart'; +export 'grid_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart b/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart new file mode 100644 index 00000000..b47a3743 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart @@ -0,0 +1,52 @@ +import 'package:uuid/uuid.dart'; + +/// A single cell in an [AAGridTemplate]. +/// +/// Each button shows a [titleVariants] label and an optional [image]. +/// The first element of [titleVariants] is used as the primary title on +/// Android Auto (Car App Library shows the most appropriate variant +/// according to available space). +class AAGridButton { + /// Unique id of the object. + final String _elementId; + + /// Label variants displayed beneath the cell image. Must not be empty. + final List titleVariants; + + /// Image displayed inside the grid cell. Supports three formats: + /// - **Asset** (pubspec.yaml): `'images/logo.png'` + /// - **Local file**: `'file:///path/to/image.png'` + /// - **Network URL**: `'https://example.com/image.png'` + /// + /// Falls back to a default icon when null or when the load fails. + final String? image; + + /// Text displayed as the loading screen title while [onPress] is executing + /// (until [complete] is called). When null, no title is shown. + final String? loadingMessage; + + /// Callback fired when the user taps this button. + /// + /// - `complete` must be called after processing to dismiss the loading screen + /// and rebuild the template — identical to [AAListItem] behaviour. + /// - `self` is a reference to the tapped button itself. + final Future Function(Function() complete, AAGridButton self)? onPress; + + AAGridButton({ + required this.titleVariants, + this.image, + this.loadingMessage, + this.onPress, + }) : assert(titleVariants.isNotEmpty, 'titleVariants must not be empty'), + _elementId = const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'image': image, + 'loadingMessage': loadingMessage, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart b/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart new file mode 100644 index 00000000..b00abdb5 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart @@ -0,0 +1,66 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'grid_button.dart'; + +/// A template that displays a grid of tappable cells on Android Auto. +/// +/// Rendered as [GridTemplate] from the Car App Library. Android Auto +/// recommends a maximum of 8 buttons per grid. +/// +/// When used as a tab inside an [AATabBarTemplate], the tab label is taken +/// from [tabTitle] (falling back to [title]). An optional tab icon can be +/// provided via [systemIcon] or [iconUrl]. +class AAGridTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + /// Title displayed in the header of the template. + final String title; + + /// The grid cells. Android Auto recommends a maximum of 8 items. + final List buttons; + + /// Messages displayed when [buttons] is empty. + /// + /// Android Auto uses the first element as the text passed to + /// `ItemList.Builder.setNoItemsMessage()`. When [buttons] is empty and + /// this list is null or empty, the template shows a loading indicator. + final List? emptyViewTitleVariants; + + /// Label displayed on the tab bar item when this template is used as a tab + /// inside an [AATabBarTemplate]. Falls back to [title] when not set. + final String? tabTitle; + + /// SF Symbol / icon name used to resolve a [CarIcon] for the tab bar item. + /// Common names such as "map", "house", "star" are mapped to Car App + /// Library built-in icons. Unknown names fall back to a default. + final String? systemIcon; + + /// URL of an image to use as the tab bar icon. Loaded asynchronously. + /// Takes precedence over [systemIcon] when both are set. + final String? iconUrl; + + AAGridTemplate({ + required this.title, + required this.buttons, + this.emptyViewTitleVariants, + this.tabTitle, + this.systemIcon, + this.iconUrl, + }) : _elementId = const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttons': buttons.map((b) => b.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'tabTitle': tabTitle, + 'systemIcon': systemIcon, + 'iconUrl': iconUrl, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/all.dart b/third_party/flutter_carplay/lib/aa_models/list/all.dart new file mode 100644 index 00000000..1e9bac51 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/all.dart @@ -0,0 +1,3 @@ +export 'list_item.dart'; +export 'list_section.dart'; +export 'list_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_item.dart b/third_party/flutter_carplay/lib/aa_models/list/list_item.dart new file mode 100644 index 00000000..1647b987 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_item.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAToggle { + bool isChecked; + final bool? isEnabled; + final Function(bool checked, AAListItem self)? onCheckedChange; + + AAToggle({this.isChecked = false, this.isEnabled, this.onCheckedChange}); + + Map toJson() => { + 'isChecked': isChecked, + 'isEnabled': isEnabled, + 'onCheckedChange': onCheckedChange != null ? true : false, + }; +} + +class AAListItem { + /// Unique id of the object. + final String _elementId; + + final String title; + final String? subtitle; + + /// The image displayed for this row on Android Auto. + /// + /// Supports asset paths, SVG assets, file paths, and network URLs. + final String? imageUrl; + final AutoImageTint? imageTint; + + /// The image displayed on the trailing side of this row when supported by the + /// Android Auto host. + final String? trailingImage; + + /// Optional tint applied to [trailingImage]. + final AutoImageTint? trailingImageTint; + + /// Text displayed as the loading screen title while [onPress] is executing + /// until [complete] is called. When null, no title is shown. + final String? loadingMessage; + + final bool? isBrowsable; + final AAToggle? toggle; + final FutureOr Function(Function() complete, AAListItem self)? onPress; + + AAListItem({ + required this.title, + this.subtitle, + String? image, + String? imageUrl, + this.imageTint, + this.trailingImage, + this.trailingImageTint, + this.loadingMessage, + this.isBrowsable, + this.toggle, + this.onPress, + String? id, + }) : imageUrl = imageUrl ?? image, + assert( + isBrowsable != true || toggle == null, + 'A browsable row must not have a toggle set.', + ), + assert( + isBrowsable != true || onPress != null, + 'A browsable row must have an onClickListener set.', + ), + assert( + toggle == null || onPress == null, + 'If a row contains a toggle, it must not have an onClickListener set.', + ), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'subtitle': subtitle, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'trailingImage': trailingImage, + 'trailingImageTint': trailingImageTint?.toJson(), + 'loadingMessage': loadingMessage, + 'isBrowsable': isBrowsable, + 'toggle': toggle?.toJson(), + 'onPress': onPress != null ? true : false, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_section.dart b/third_party/flutter_carplay/lib/aa_models/list/list_section.dart new file mode 100644 index 00000000..d622566f --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_section.dart @@ -0,0 +1,54 @@ +import 'package:uuid/uuid.dart'; + +import 'list_item.dart'; + +class AAListSection { + /// Unique id of the object. + final String _elementId; + + /// Required only when multiple [AAListSection] are used in the same [AAListTemplate]. + final String? title; + + final List items; + int? selectedIndex; + final Function(int selectedIndex, AAListItem selectedItem)? onSelected; + + AAListSection({ + this.title, + required this.items, + this.selectedIndex, + this.onSelected, + String? id, + }) : assert( + selectedIndex == null || + (selectedIndex >= 0 && selectedIndex < items.length), + 'selectedIndex must be within the list item range.', + ), + assert( + (selectedIndex == null && onSelected == null) || items.isNotEmpty, + 'A selectable list must have at least one item.', + ), + assert( + selectedIndex == null && onSelected == null || + items.every((AAListItem item) => item.onPress == null), + 'Selectable list items must not have an onClickListener set.', + ), + assert( + selectedIndex == null && onSelected == null || + items.every((AAListItem item) => item.toggle == null), + 'Selectable list items must not have a toggle set.', + ), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + bool get isSelectable => selectedIndex != null || onSelected != null; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'items': items.map((AAListItem item) => item.toJson()).toList(), + 'selectedIndex': selectedIndex, + 'onSelected': onSelected != null ? true : false, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_template.dart b/third_party/flutter_carplay/lib/aa_models/list/list_template.dart new file mode 100644 index 00000000..9a0c06a7 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_template.dart @@ -0,0 +1,91 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'list_section.dart'; + +class AAListTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + final String title; + final List sections; + + /// An array of title variants displayed when the list is empty. + /// + /// Android Auto uses the first element as the message passed to + /// `ItemList.Builder.setNoItemsMessage()`. When [sections] is empty and + /// this list is null, the template falls back to a loading indicator. + final List? emptyViewTitleVariants; + + /// Label displayed on the tab bar item when this template is used as a tab + /// inside an [AATabBarTemplate]. Falls back to [title] when not set. + final String? tabTitle; + + /// Icon displayed in the tab when this template is used inside an + /// [AATabBarTemplate]. Supports mapped system icon names, Flutter assets, + /// local files, and network URLs. + /// + /// When [iconUrl] is also set, [iconUrl] takes precedence. + final String? systemIcon; + + /// URL of an image to use as the tab bar icon. Loaded asynchronously. + /// Takes precedence over [systemIcon] when set. + final String? iconUrl; + + AAListTemplate({ + required this.title, + required this.sections, + this.emptyViewTitleVariants, + this.tabTitle, + this.systemIcon, + this.iconUrl, + String? id, + }) : assert( + _hasValidSelectableList(sections), + 'A selectable AAListSection must be the only section in an ' + 'AAListTemplate and must not have a title.', + ), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + static bool _hasValidSelectableList(List sections) { + final selectableSections = sections.where( + (AAListSection section) => section.isSelectable, + ); + if (selectableSections.isEmpty) return true; + + return sections.length == 1 && + (selectableSections.single.title == null || + selectableSections.single.title!.isEmpty); + } + + static void _validateSelectableList(List sections) { + assert( + _hasValidSelectableList(sections), + 'A selectable AAListSection must be the only section in an ' + 'AAListTemplate and must not have a title.', + ); + } + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'sections': + sections.map((AAListSection section) => section.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'tabTitle': tabTitle, + 'systemIcon': systemIcon, + 'iconUrl': iconUrl, + }; + + void updateSections(List newSections) { + _validateSelectableList(newSections); + final copy = List.from(newSections); + sections + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/map/all.dart b/third_party/flutter_carplay/lib/aa_models/map/all.dart new file mode 100644 index 00000000..6f5d219e --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/all.dart @@ -0,0 +1,2 @@ +export 'map_action.dart'; +export 'map_with_content_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/map/map_action.dart b/third_party/flutter_carplay/lib/aa_models/map/map_action.dart new file mode 100644 index 00000000..35312740 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/map_action.dart @@ -0,0 +1,42 @@ +import 'package:uuid/uuid.dart'; + +/// DELTA D from upstream 1.6.5: an action in a map action strip. +/// +/// The strip is the vertical bar the host draws down the right edge of the map +/// region. Its constraints are stricter than a pane's — `ACTIONS_CONSTRAINTS_MAP` +/// allows at most 4 actions and 1 primary, and leaves `maxCustomTitles` at 0, so +/// **an action without an icon is rejected**. [imageUrl] is therefore required, +/// not decorative; [title] is carried only for accessibility and for the +/// host to use if it ever renders one. +class AAMapAction { + AAMapAction({ + required this.title, + required this.imageUrl, + this.isPrimary = false, + this.onPress, + String? id, + }) : assert(title.isNotEmpty, 'AAMapAction.title cannot be empty'), + assert(imageUrl.isNotEmpty, + 'AAMapAction.imageUrl is required — the map strip is icon-only'), + _elementId = id ?? const Uuid().v4(); + + final String _elementId; + final String title; + + /// Flutter asset path, file:// path or network URL — resolved to a CarIcon + /// natively by the same loader pane and grid images use. + final String imageUrl; + + final bool isPrimary; + final Function()? onPress; + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'imageUrl': imageUrl, + 'isPrimary': isPrimary, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart b/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart new file mode 100644 index 00000000..db338e7a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart @@ -0,0 +1,56 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'map_action.dart'; + +/// DELTA D from upstream 1.6.5: Android Auto's MapWithContentTemplate. +/// +/// The host renders no map here. It reserves a map region and hands the app a +/// Surface through `AppManager.setSurfaceCallback`; whatever the app draws onto +/// that Surface is the map. See `FAASurfaceProvider` on the native side. +/// +/// [contentTemplate] is any supported template — it is dispatched through the +/// same builder as a root template — and occupies the content half beside the +/// map. +/// +/// Requires car API level 7, the `androidx.car.app.MAP_TEMPLATES` and +/// `androidx.car.app.ACCESS_SURFACE` permissions, and an app category permitted +/// to draw maps (navigation, POI, or weather — **not** IOT). +class AAMapWithContentTemplate implements AATemplate { + AAMapWithContentTemplate({ + required this.contentTemplate, + this.mapActions = const [], + String? id, + }) : assert(mapActions.length <= 4, + 'A map action strip cannot hold more than 4 actions'), + assert( + mapActions.where((AAMapAction a) => a.isPrimary).length <= 1, + 'A map action strip cannot hold more than 1 primary action', + ), + _elementId = id ?? const Uuid().v4(); + + final String _elementId; + final AATemplate contentTemplate; + + /// The vertical strip down the right edge of the map. Empty means no strip. + final List mapActions; + + @override + String get uniqueId => _elementId; + + /// The native side needs the content's runtime type to dispatch it, exactly + /// as the root template does. + static String runtimeTypeOf(AATemplate template) { + final name = template.runtimeType.toString(); + return name.startsWith('AA') ? 'FAA${name.substring(2)}' : 'FAA$name'; + } + + @override + Map toJson() => { + '_elementId': _elementId, + 'contentRuntimeType': runtimeTypeOf(contentTemplate), + 'contentTemplate': contentTemplate.toJson(), + 'mapActions': + mapActions.map((AAMapAction action) => action.toJson()).toList(), + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/all.dart b/third_party/flutter_carplay/lib/aa_models/message/all.dart new file mode 100644 index 00000000..96ab4f18 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/all.dart @@ -0,0 +1,2 @@ +export 'long_message_template.dart'; +export 'message_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart b/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart new file mode 100644 index 00000000..a68c3264 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart @@ -0,0 +1,18 @@ +import '../../constants/private_constants.dart'; +import 'message_template_base.dart'; + +class AALongMessageTemplate extends AAMessageTemplateBase { + /// Creates a long message template for Android Auto. + /// + /// [message] must not be empty because Android Auto requires a non-empty + /// message when building the native template. + AALongMessageTemplate({ + required super.title, + required super.message, + super.id, + }); + + @override + FAAChannelTypes get updateChannelType => + FAAChannelTypes.updateLongMessageTemplate; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/message_template.dart b/third_party/flutter_carplay/lib/aa_models/message/message_template.dart new file mode 100644 index 00000000..470a78d7 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/message_template.dart @@ -0,0 +1,14 @@ +import '../../constants/private_constants.dart'; +import 'message_template_base.dart'; + +class AAMessageTemplate extends AAMessageTemplateBase { + /// Creates a message template for Android Auto. + /// + /// [message] must not be empty because Android Auto requires a non-empty + /// message when building the native template. + AAMessageTemplate({required super.title, required super.message, super.id}); + + @override + FAAChannelTypes get updateChannelType => + FAAChannelTypes.updateMessageTemplate; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart b/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart new file mode 100644 index 00000000..cc807e75 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart @@ -0,0 +1,76 @@ +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:uuid/uuid.dart'; + +import '../../constants/private_constants.dart'; +import '../template.dart'; + +abstract class AAMessageTemplateBase implements AATemplate { + /// Unique id of the object. + final String _elementId; + + String title; + String message; + + AAMessageTemplateBase({ + required this.title, + required this.message, + String? id, + }) : _elementId = id ?? const Uuid().v4() { + _validateMessage(message); + } + + FAAChannelTypes get updateChannelType; + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + }; + + /// Updates the template content on Android Auto. + /// + /// Android Auto templates are immutable. This method asks the native side to + /// rebuild the message template and invalidate the current screen. Changing + /// the title or message is treated by Android Auto as a new template step, + /// not as a refresh of the existing template, and can count toward host + /// template limits. + /// + /// [message] must not be empty. Native update errors are surfaced as + /// [PlatformException]s from the MethodChannel call. + Future update({String? title, String? message}) async { + final nextTitle = title ?? this.title; + final nextMessage = message ?? this.message; + _validateMessage(nextMessage); + + await FlutterAndroidAutoController.flutterToNativeModuleStatic( + updateChannelType, + {'elementId': _elementId, 'title': nextTitle, 'message': nextMessage}, + ); + + updateTemplate(title: nextTitle, message: nextMessage); + } + + Future setTitle(String title) { + return update(title: title); + } + + Future setMessage(String message) { + return update(message: message); + } + + void updateTemplate({required String title, required String message}) { + _validateMessage(message); + this.title = title; + this.message = message; + } + + static void _validateMessage(String message) { + if (message.isEmpty) { + throw ArgumentError.value(message, 'message', 'Message cannot be empty'); + } + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/all.dart b/third_party/flutter_carplay/lib/aa_models/pane/all.dart new file mode 100644 index 00000000..862d910f --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/all.dart @@ -0,0 +1,3 @@ +export 'pane_action.dart'; +export 'pane_item.dart'; +export 'pane_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart new file mode 100644 index 00000000..103a4b8a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart @@ -0,0 +1,39 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAPaneAction { + /// Unique id of the object. + final String _elementId; + + final String title; + + /// Optional icon displayed with this pane action on Android Auto. + /// + /// Supports the same formats as [AAPaneItem.imageUrl], including Flutter + /// asset SVGs rasterized before the payload is sent to native Android. + final String? imageUrl; + final AutoImageTint? imageTint; + final bool isPrimary; + final Function()? onPress; + + AAPaneAction({ + required this.title, + this.imageUrl, + this.imageTint, + this.isPrimary = false, + this.onPress, + String? id, + }) : assert(title.isNotEmpty, 'AAPaneAction.title cannot be empty'), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'isPrimary': isPrimary, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart new file mode 100644 index 00000000..3a9d9b2d --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAPaneItem { + /// Unique id of the object. + final String _elementId; + + final String title; + final String? detail; + + /// The image displayed for this informational row on Android Auto. + /// + /// Supports these formats: + /// - **Asset path**: `images/flutter_logo.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/icon.svg` (rasterized to PNG before being sent to + /// the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + final String? imageUrl; + final AutoImageTint? imageTint; + + AAPaneItem({ + required this.title, + this.detail, + this.imageUrl, + this.imageTint, + String? id, + }) : assert(title.isNotEmpty, 'AAPaneItem.title cannot be empty'), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'detail': detail, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart new file mode 100644 index 00000000..6825732e --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart @@ -0,0 +1,80 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'pane_action.dart'; +import 'pane_item.dart'; + +class AAPaneTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + /// DELTA D from upstream 1.6.5: optional, and empty by default. + /// + /// Upstream requires a non-empty title, but `PaneTemplate.Builder.build()` + /// does not — it validates only the pane's rows and actions. The title is what + /// draws the header, so an empty one means no header, which matters when the + /// pane is the content half of a MapWithContentTemplate and every row of + /// chrome is taken from the map. + final String title; + final List items; + final List actions; + + /// Optional image displayed alongside the pane content. + /// + /// Supports the same formats as [AAPaneItem.imageUrl], including Flutter + /// asset SVGs rasterized before the payload is sent to native Android. + final String? imageUrl; + final AutoImageTint? imageTint; + + /// Shows Android Auto's loading state instead of rows. + /// + /// Android requires pane content to be either loading with no rows, or + /// non-loading with at least one row. + /// + /// Android treats a loading pane followed by loaded content as a template + /// refresh. Switching an already-loaded pane back to loading changes the row + /// count, so it may count as a new template in Android Auto's template quota. + final bool isLoading; + + AAPaneTemplate({ + this.title = '', + required this.items, + this.actions = const [], + this.imageUrl, + this.imageTint, + this.isLoading = false, + String? id, + }) : assert( + isLoading || items.isNotEmpty, + 'AAPaneTemplate.items cannot be empty unless isLoading is true', + ), + assert( + !isLoading || items.isEmpty, + 'AAPaneTemplate.items must be empty when isLoading is true', + ), + assert( + actions.length <= 2, + 'AAPaneTemplate.actions cannot contain more than 2 actions', + ), + assert( + actions.where((AAPaneAction action) => action.isPrimary).length <= 1, + 'AAPaneTemplate.actions cannot contain more than 1 primary action', + ), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'items': items.map((AAPaneItem item) => item.toJson()).toList(), + 'actions': + actions.map((AAPaneAction action) => action.toJson()).toList(), + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'isLoading': isLoading, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart b/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart new file mode 100644 index 00000000..b9ce2e6a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart @@ -0,0 +1 @@ +export 'tabbar_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart b/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart new file mode 100644 index 00000000..5a248392 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart @@ -0,0 +1,59 @@ +import 'package:uuid/uuid.dart'; + +import '../grid/grid_template.dart'; +import '../list/list_template.dart'; +import '../template.dart'; + +/// A container template that displays multiple child templates as tabs on +/// Android Auto. Rendered as [TabTemplate] from the Car App Library (API 6+). +/// +/// Each child [AATemplate] is shown in its own tab. The tab's label is taken +/// from [AAListTemplate.tabTitle] (falling back to [AAListTemplate.title]). +/// An optional tab icon can be provided via [AAListTemplate.systemIcon] +/// (mapped to a CarIcon built-in) or [AAListTemplate.iconUrl] (loaded async). +/// +/// Currently supported child template types: [AAListTemplate]. +/// +/// Devices that do not support API level 6 will fall back to showing the first +/// tab's content as a plain [ListTemplate]. +class AATabBarTemplate implements AATemplate { + final String _elementId; + + /// The templates shown in each tab (max 5, per Android Auto restrictions). + final List tabs; + + AATabBarTemplate({ + required List tabs, + String? id, + }) : tabs = List.from(tabs), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'tabs': tabs + .map((t) => { + 'elementId': t.uniqueId, + 'runtimeType': _runtimeTypeOf(t), + 'template': t.toJson(), + }) + .toList(), + }; + + /// Updates the tabs list in-place (mirrors CPTabBarTemplate.updateTemplates). + void updateTabs(List newTabs) { + final copy = List.from(newTabs); + tabs + ..clear() + ..addAll(copy); + } + + static String _runtimeTypeOf(AATemplate t) { + if (t is AAListTemplate) return 'FAAListTemplate'; + if (t is AAGridTemplate) return 'FAAGridTemplate'; + return 'FAAUnknown'; + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/template.dart b/third_party/flutter_carplay/lib/aa_models/template.dart new file mode 100644 index 00000000..51cd407f --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/template.dart @@ -0,0 +1,7 @@ +abstract interface class AATemplate { + const AATemplate(); + + String get uniqueId; + + Map toJson(); +} diff --git a/third_party/flutter_carplay/lib/android_auto_worker.dart b/third_party/flutter_carplay/lib/android_auto_worker.dart new file mode 100644 index 00000000..12888ed7 --- /dev/null +++ b/third_party/flutter_carplay/lib/android_auto_worker.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +import 'controllers/android_auto_controller.dart'; + +/// An object used to integrate Android Auto navigation and manage user +/// interface elements displayed on the Android Auto screen. +class FlutterAndroidAuto { + static final FlutterAndroidAutoController _androidAutoController = + FlutterAndroidAutoController(); + + late final StreamSubscription? _eventBroadcast; + + static String _connectionStatus = ConnectionStatusTypes.unknown.name; + + /// The size used when rasterizing Flutter asset SVGs referenced by image + /// fields before they are sent to the native side. + static int svgRasterSize = defaultSvgRasterSize; + + Function(ConnectionStatusTypes status)? _onAndroidAutoConnectionChange; + + FlutterAndroidAuto() { + if (defaultTargetPlatform != TargetPlatform.android) return; + + _eventBroadcast = _androidAutoController.eventChannel + .receiveBroadcastStream() + .listen((event) async { + final FAAChannelTypes receivedChannelType = EnumUtils.enumFromString( + FAAChannelTypes.values, + event['type'], + ); + + switch (receivedChannelType) { + case FAAChannelTypes.onAndroidAutoConnectionChange: + final ConnectionStatusTypes connectionStatus = + EnumUtils.enumFromString( + ConnectionStatusTypes.values, + event['data']['status'], + ); + _connectionStatus = connectionStatus.name; + _onAndroidAutoConnectionChange?.call(connectionStatus); + break; + + case FAAChannelTypes.onListItemSelected: + await _androidAutoController.processFAAListItemSelectedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onListSectionSelected: + _androidAutoController.processFAAListSectionSelectedChannel( + event['data']['elementId'], + event['data']['selectedIndex'], + ); + break; + + case FAAChannelTypes.onToggleCheckedChange: + _androidAutoController.processFAAToggleCheckedChangeChannel( + event['data']['elementId'], + event['data']['checked'], + ); + break; + + case FAAChannelTypes.onPaneActionPressed: + _androidAutoController.processFAAPaneActionPressedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onMapActionPressed: + _androidAutoController.processFAAMapActionPressedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onScreenBackButtonPressed: + FlutterAndroidAutoController.templateHistory.removeWhere( + (AATemplate item) => item.uniqueId == event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onAlertActionPressed: + _androidAutoController.processFAAAlertActionPressed( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onPresentStateChanged: + final bool completed = event['data']['completed'] as bool? ?? false; + _androidAutoController.processFAAPresentStateChanged( + event['data']['elementId'], + completed, + ); + break; + + case FAAChannelTypes.onTabBarItemSelected: + break; + + case FAAChannelTypes.onGridButtonPressed: + await _androidAutoController.processFAAGridButtonPressed( + event['data']['elementId'], + ); + break; + + default: + break; + } + }); + } + + void closeConnection() { + _eventBroadcast!.cancel(); + } + + void resumeConnection() { + _eventBroadcast!.resume(); + } + + void pauseConnection() { + _eventBroadcast!.pause(); + } + + void addListenerOnConnectionChange( + Function(ConnectionStatusTypes status) onAndroidAutoConnectionChange, + ) { + _onAndroidAutoConnectionChange = onAndroidAutoConnectionChange; + } + + void removeListenerOnConnectionChange() { + _onAndroidAutoConnectionChange = null; + } + + static String get connectionStatus => _connectionStatus; + + static Future setRootTemplate({required AATemplate template}) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.setRootTemplate, { + 'template': template.toJson(), + 'runtimeType': _getAARuntimeTypeString(template), + }); + + if (isCompleted == true) { + if (FlutterAndroidAutoController.templateHistory.isEmpty) { + FlutterAndroidAutoController.templateHistory.add(template); + } else { + FlutterAndroidAutoController.templateHistory[0] = template; + } + } + } + + Future updateListTemplateSections({ + required String elementId, + required List sections, + }) { + return FlutterAndroidAutoController.updateAAListTemplateSections( + elementId: elementId, + sections: sections, + ); + } + + static Future updatePaneTemplate({ + required AAPaneTemplate template, + }) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.updatePaneTemplate, { + 'template': template.toJson(), + }); + + if (isCompleted == true) { + final int index = FlutterAndroidAutoController.templateHistory.indexWhere( + (AATemplate item) => item.uniqueId == template.uniqueId, + ); + if (index != -1) { + FlutterAndroidAutoController.templateHistory[index] = template; + } + } + + return isCompleted ?? false; + } + + Future forceUpdateRootTemplate() { + return _androidAutoController.flutterToNativeModule( + FAAChannelTypes.forceUpdateRootTemplate, + ); + } + + static dynamic get rootTemplate => + FlutterAndroidAutoController.currentRootTemplate; + + static Future showAlert({ + required AAAlertTemplate template, + }) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.setAlert, { + 'template': template.toJson(), + }); + + if (isCompleted == true) { + FlutterAndroidAutoController.currentPresentTemplate = template; + } + template.onPresent?.call(isCompleted ?? false); + } + + static Future popModal() async { + FlutterAndroidAutoController.currentPresentTemplate = null; + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.closePresent); + return isCompleted ?? false; + } + + static Future updateTabBarTemplates({ + required AATabBarTemplate template, + }) async { + await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.updateTabBarTemplates, { + 'template': template.toJson(), + }); + + final index = FlutterAndroidAutoController.templateHistory + .indexWhere((item) => item.uniqueId == template.uniqueId); + if (index >= 0) { + FlutterAndroidAutoController.templateHistory[index] = template; + } + } + + static Future pop() async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.popTemplate); + return isCompleted ?? false; + } + + static Future popToRoot() async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.popToRootTemplate); + return isCompleted ?? false; + } + + static Future push({required AATemplate template}) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.pushTemplate, { + 'template': template.toJson(), + 'runtimeType': _getAARuntimeTypeString(template), + }); + if (isCompleted == true) { + FlutterAndroidAutoController.templateHistory.add(template); + } + return isCompleted ?? false; + } + + static Future showSharedNowPlaying() async => false; + + static String _getAARuntimeTypeString(AATemplate template) { + if (template is AAListTemplate) return 'FAAListTemplate'; + if (template is AAGridTemplate) return 'FAAGridTemplate'; + if (template is AATabBarTemplate) return 'FAATabBarTemplate'; + if (template is AAPaneTemplate) return 'FAAPaneTemplate'; + // DELTA D + if (template is AAMapWithContentTemplate) { + return 'FAAMapWithContentTemplate'; + } + if (template is AAMessageTemplate) return 'FAAMessageTemplate'; + if (template is AALongMessageTemplate) return 'FAALongMessageTemplate'; + if (template is AAAlertTemplate) return 'FAAAlertTemplate'; + return 'FAA${template.runtimeType}'; + } +} diff --git a/third_party/flutter_carplay/lib/carplay_worker.dart b/third_party/flutter_carplay/lib/carplay_worker.dart new file mode 100644 index 00000000..7186d67e --- /dev/null +++ b/third_party/flutter_carplay/lib/carplay_worker.dart @@ -0,0 +1,455 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/controllers/carplay_controller.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +/// An object in order to integrate Apple CarPlay in navigation and +/// manage all user interface elements appearing on your screens displayed on +/// the CarPlay screen. +/// +/// Using CarPlay, you can display content from your app on a customized user interface +/// that is generated and hosted by the system itself. Control over UI elements, such as +/// touch target size, font size and color, highlights, and so on. +/// +/// **Useful Links:** +/// - [What is CarPlay?](https://developer.apple.com/carplay/) +/// - [Request CarPlay Framework](https://developer.apple.com/contact/carplay/) +/// - [Learn more about MFi Program](https://mfi.apple.com) +class FlutterCarplay { + /// A main Flutter CarPlay Controller to manage the system. + static final FlutterCarPlayController _carPlayController = + FlutterCarPlayController(); + + /// CarPlay main bridge as a listener from CarPlay and native side. + StreamSubscription? _eventBroadcast; + + /// Current CarPlay and mobile app connection status. + static String _connectionStatus = ConnectionStatusTypes.unknown.name; + + /// The size (in logical pixels, square) used when rasterizing Flutter asset + /// SVGs referenced by image fields (e.g. `CPListItem.image`, + /// `CPGridButton.image`, `CPPoi.image`) before they are sent to the native + /// side. Defaults to [defaultSvgRasterSize] (120). + static int svgRasterSize = defaultSvgRasterSize; + + /// A listener function, which will be triggered when CarPlay connection changes + /// and will be transmitted to the main code, allowing the user to access + /// the current connection status. + Function(ConnectionStatusTypes status)? _onCarplayConnectionChange; + + /// Creates an [FlutterCarplay] and starts the connection. + FlutterCarplay() { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + + _eventBroadcast = _carPlayController.eventChannel + .receiveBroadcastStream() + .listen((event) async { + final FCPChannelTypes receivedChannelType = + EnumUtils.enumFromString(FCPChannelTypes.values, event['type']); + switch (receivedChannelType) { + case FCPChannelTypes.onCarplayConnectionChange: + final ConnectionStatusTypes connectionStatus = + EnumUtils.enumFromString( + ConnectionStatusTypes.values, + event['data']['status'], + ); + _connectionStatus = connectionStatus.name; + if (_onCarplayConnectionChange != null) { + _onCarplayConnectionChange!(connectionStatus); + } + break; + case FCPChannelTypes.onFCPListItemSelected: + await _carPlayController.processFCPListItemSelectedChannel( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onFCPListImageRowItemSelected: + await _carPlayController.processFCPListImageRowItemSelectedChannel( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onFCPListImageRowItemElementSelected: + _carPlayController.processFCPListImageRowItemElementSelectedChannel( + event['data']['elementId'], + event['data']['index'], + ); + break; + case FCPChannelTypes.onFCPAlertActionPressed: + _carPlayController.processFCPAlertActionPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onPresentStateChanged: + _carPlayController.processFCPAlertTemplateCompleted( + event['data']['completed'], + ); + break; + case FCPChannelTypes.onGridButtonPressed: + _carPlayController.processFCPGridButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onBarButtonPressed: + _carPlayController.processFCPBarButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onTextButtonPressed: + _carPlayController.processFCPTextButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onSearchTextUpdated: + _carPlayController.processFCPSearchTextUpdated( + event['data']['elementId'], + event['data']['searchText'], + ); + break; + case FCPChannelTypes.onSearchResultSelected: + _carPlayController.processFCPSearchResultSelected( + event['data']['elementId'], + event['data']['itemElementId'], + ); + break; + case FCPChannelTypes.onSearchButtonPressed: + _carPlayController.processFCPSearchButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onScreenBackButtonPressed: + final String elementId = event['data']['elementId']; + final CPTemplate? poppedTemplate = FlutterCarPlayController + .templateHistory + .where((item) => item.uniqueId == elementId) + .firstOrNull; + poppedTemplate?.onPop?.call(); + FlutterCarPlayController.templateHistory + .removeWhere((item) => item.uniqueId == elementId); + break; + default: + break; + } + }); + } + + /// A function that will disconnect all event listeners from CarPlay. The action + /// will be irrevocable, and a new [FlutterCarplay] controller must be created after this, + /// otherwise CarPlay will be unusable. + /// + /// [!] It is not recommended to use this function if you do not know what you are doing. + void closeConnection() { + _eventBroadcast?.cancel(); + } + + /// A function that will resume the paused all event listeners from CarPlay. + void resumeConnection() { + _eventBroadcast?.resume(); + } + + /// A function that will pause the all active event listeners from CarPlay. + void pauseConnection() { + _eventBroadcast?.pause(); + } + + /// Callback function will be fired when CarPlay connection status is changed. + /// For example, when CarPlay is connected to the device, in the background state, + /// or completely disconnected. + /// + /// See also: [ConnectionStatusTypes] + void addListenerOnConnectionChange( + Function(ConnectionStatusTypes status) onCarplayConnectionChange, + ) { + _onCarplayConnectionChange = onCarplayConnectionChange; + } + + /// Removes the callback function that has been set before in order to listen + /// on CarPlay connection status changed. + void removeListenerOnConnectionChange() { + _onCarplayConnectionChange = null; + } + + /// Current CarPlay connection status. It will return one of [ConnectionStatusTypes] as String. + static String get connectionStatus { + return _connectionStatus; + } + + /// Sets the root template of the navigation hierarchy. If a navigation + /// hierarchy already exists, CarPlay replaces the entire hierarchy. + /// + /// - rootTemplate is a template to use as the root of a new navigation hierarchy. If one exists, + /// it will replace the current rootTemplate. **Must be one of the type:** + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], + /// [CPInformationTemplate], [CPPointOfInterestTemplate], [CPSearchTemplate] + /// If not, it will throw an [TypeError] + /// + /// - If animated is true, CarPlay animates the presentation of the template, but will be ignored + /// this flag when there isn’t an existing navigation hierarchy to replace. + /// + /// [!] CarPlay cannot have more than 5 templates on one screen. + static Future setRootTemplate({ + required CPTemplate rootTemplate, + bool animated = true, + }) async { + if (rootTemplate is CPTabBarTemplate || + rootTemplate is CPGridTemplate || + rootTemplate is CPListTemplate || + rootTemplate is CPInformationTemplate || + rootTemplate is CPPointOfInterestTemplate || + rootTemplate is CPSearchTemplate) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setRootTemplate, { + 'rootTemplate': rootTemplate.toJson(), + 'animated': animated, + }).then((value) { + if (value == true) { + if (FlutterCarPlayController.templateHistory.isEmpty) { + FlutterCarPlayController.templateHistory.add(rootTemplate); + } else { + FlutterCarPlayController.templateHistory[0] = rootTemplate; + } + } + }); + } else { + throw TypeError(); + } + } + + /// It will set the current root template again. + Future forceUpdateRootTemplate() { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.forceUpdateRootTemplate, + ); + } + + /// It will update the sections of the [CPListTemplate] which has the given [elementId]. + Future updateListTemplateSections({ + required String elementId, + required List sections, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateListTemplateSections, + { + 'elementId': elementId, + 'sections': + sections.map((CPListSection section) => section.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = + FlutterCarPlayController.getTemplateFromHistory( + elementId); + template?.updateSections(sections); + } + return; + } + + /// It will update the information items of the [CPInformationTemplate] which has the given [elementId]. + Future updateInformationTemplateItems({ + required String elementId, + required List items, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateInformationTemplateItems, + { + 'elementId': elementId, + 'items': items.map((CPInformationItem item) => item.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = FlutterCarPlayController.getTemplateFromHistory< + CPInformationTemplate>(elementId); + template?.updateInformationItems(items); + } + return; + } + + /// It will update the actions of the [CPInformationTemplate] which has the given [elementId]. + Future updateInformationTemplateActions({ + required String elementId, + required List actions, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateInformationTemplateActions, + { + 'elementId': elementId, + 'actions': + actions.map((CPTextButton action) => action.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = FlutterCarPlayController.getTemplateFromHistory< + CPInformationTemplate>(elementId); + template?.updateActions(actions); + } + return; + } + + /// It will update the templates of the [CPTabBarTemplate] which has the given [elementId]. + /// Supported template types: [CPListTemplate], [CPPointOfInterestTemplate], + /// [CPGridTemplate], [CPInformationTemplate] + Future updateTabBarTemplates({ + required String elementId, + required List templates, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateTabBarTemplates, + { + 'elementId': elementId, + 'templates': + templates.map((CPTemplate template) => template.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = + FlutterCarPlayController.getTemplateFromHistory( + elementId); + template?.updateTemplates(templates); + } + return; + } + + /// Getter for current root template. + /// Return one of type [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate] + static dynamic get rootTemplate { + return FlutterCarPlayController.currentRootTemplate; + } + + /// It will present [CPAlertTemplate] modally. + /// + /// - template is to present modally. + /// - If animated is true, CarPlay animates the presentation of the template. + /// + /// [!] CarPlay can only present one modal template at a time. + static Future showAlert({ + required CPAlertTemplate template, + bool animated = true, + }) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setAlert, + { + 'rootTemplate': template.toJson(), + 'animated': animated, + 'onPresent': template.onPresent != null ? true : false, + }, + ).then((value) { + if (value == true) { + FlutterCarPlayController.currentPresentTemplate = template; + } + }); + } + + /// It will present [CPActionSheetTemplate] modally. + /// + /// - template is to present modally. + /// - If animated is true, CarPlay animates the presentation of the template. + /// + /// [!] CarPlay can only present one modal template at a time. + static Future showActionSheet({ + required CPActionSheetTemplate template, + bool animated = true, + }) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setActionSheet, + { + 'rootTemplate': template.toJson(), + 'animated': animated, + }, + ).then((value) { + if (value == true) { + FlutterCarPlayController.currentPresentTemplate = template; + } + }); + } + + /// Removes the top-most template from the navigation hierarchy. + /// + /// - If animated is true, CarPlay animates the transition between templates. + /// - count represents how many times this function will occur. + static Future pop({bool animated = true, int count = 1}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.popTemplate, + {'count': count, 'animated': animated}, + ); + + return isCompleted ?? false; + } + + /// Removes all of the templates from the navigation hierarchy except the root template. + /// If animated is true, CarPlay animates the presentation of the template. + static Future popToRoot({bool animated = true}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.popToRootTemplate, + animated, + ); + + return isCompleted ?? false; + } + + /// Removes a modal template. Since [CPAlertTemplate] and [CPActionSheetTemplate] are both + /// modals, they can be removed. If animated is true, CarPlay animates the transition between templates. + static Future popModal({bool animated = true}) async { + FlutterCarPlayController.currentPresentTemplate = null; + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.closePresent, + animated, + ); + + return isCompleted ?? false; + } + + /// Adds a template to the navigation hierarchy and displays it. + /// + /// - template is to add to the navigation hierarchy. **Must be one of the type:** + /// [CPGridTemplate] or [CPListTemplate] [CPInformationTemplate] [CPPointOfInterestTemplate] If not, it will throw an [TypeError] + /// + /// - If animated is true, CarPlay animates the transition between templates. + static Future push({ + required CPTemplate template, + bool animated = true, + }) async { + if (template is CPGridTemplate || + template is CPListTemplate || + template is CPInformationTemplate || + template is CPPointOfInterestTemplate || + template is CPSearchTemplate) { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.pushTemplate, + { + 'template': template.toJson(), + 'animated': animated, + }, + ); + if (isCompleted == true) { + _carPlayController.addTemplateToHistory(template); + } + return isCompleted ?? false; + } else { + throw TypeError(); + } + } + + /// Navigate to the shared instance of the NowPlaying Template + /// + /// - If animated is true, CarPlay animates the transition between templates. + static Future showSharedNowPlaying({bool animated = true}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.showNowPlaying, + animated, + ); + return isCompleted ?? false; + } +} diff --git a/third_party/flutter_carplay/lib/constants/all.dart b/third_party/flutter_carplay/lib/constants/all.dart new file mode 100644 index 00000000..50da5bad --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/all.dart @@ -0,0 +1,2 @@ +export 'constants.dart'; +export 'private_constants.dart'; diff --git a/third_party/flutter_carplay/lib/constants/constants.dart b/third_party/flutter_carplay/lib/constants/constants.dart new file mode 100644 index 00000000..621507e7 --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/constants.dart @@ -0,0 +1 @@ +enum ConnectionStatusTypes { connected, background, disconnected, unknown } diff --git a/third_party/flutter_carplay/lib/constants/private_constants.dart b/third_party/flutter_carplay/lib/constants/private_constants.dart new file mode 100644 index 00000000..e9ac87f0 --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/private_constants.dart @@ -0,0 +1,67 @@ +enum FCPChannelTypes { + onCarplayConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + updateListItem, + updateListImageRowItem, + updateListImageRowItemElement, + onFCPListItemSelected, + onFCPListItemSelectedComplete, + onFCPListImageRowItemSelected, + onFCPListImageRowItemSelectedComplete, + onFCPListImageRowItemElementSelected, + onFCPListImageRowItemElementSelectedComplete, + onFCPAlertActionPressed, + setAlert, + onPresentStateChanged, + popTemplate, + closePresent, + pushTemplate, + showNowPlaying, + onGridButtonPressed, + setActionSheet, + onBarButtonPressed, + onTextButtonPressed, + popToRootTemplate, + onScreenBackButtonPressed, + updateTabBarTemplates, + updateListTemplateSections, + updateInformationTemplateItems, + updateInformationTemplateActions, + getMaximumNumberOfGridImages, + getMaximumSectionCount, + getMaximumItemCount, + onSearchTextUpdated, + onSearchResultSelected, + onSearchButtonPressed, + updateSearchResults, + onSearchResultSelectedComplete, +} + +enum FAAChannelTypes { + onAndroidAutoConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + pushTemplate, + popTemplate, + popToRootTemplate, + updateListTemplateSections, + updatePaneTemplate, + onListItemSelected, + onListItemSelectedComplete, + onListSectionSelected, + onToggleCheckedChange, + onPaneActionPressed, + onMapActionPressed, + onScreenBackButtonPressed, + setAlert, + closePresent, + onAlertActionPressed, + onPresentStateChanged, + updateTabBarTemplates, + onTabBarItemSelected, + onGridButtonPressed, + onGridButtonSelectedComplete, + updateMessageTemplate, + updateLongMessageTemplate, +} diff --git a/third_party/flutter_carplay/lib/controllers/all.dart b/third_party/flutter_carplay/lib/controllers/all.dart new file mode 100644 index 00000000..f2c8998b --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/all.dart @@ -0,0 +1,2 @@ +export 'android_auto_controller.dart'; +export 'carplay_controller.dart'; diff --git a/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart b/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart new file mode 100644 index 00000000..f00ddad9 --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/constants/private_constants.dart'; + +import '../aa_models/alert/alert_action.dart'; +import '../aa_models/alert/alert_template.dart'; +import '../aa_models/grid/grid_button.dart'; +import '../aa_models/list/list_item.dart'; +import '../aa_models/list/list_section.dart'; +import '../aa_models/list/list_template.dart'; +import '../aa_models/map/map_action.dart'; +import '../aa_models/pane/pane_action.dart'; +import '../aa_models/template.dart'; +import '../android_auto_worker.dart'; +import '../helpers/auto_android_helper.dart'; +import '../helpers/svg_rasterizer.dart'; + +/// [FlutterAndroidAutoController] is a root object used to control and +/// communicate with Android Auto native functions. +class FlutterAndroidAutoController { + static final FlutterAutoAndroidHelper _androidAutoHelper = + const FlutterAutoAndroidHelper(); + static final MethodChannel _methodChannel = MethodChannel( + _androidAutoHelper.makeFAAChannelId(), + ); + static final EventChannel _eventChannel = EventChannel( + _androidAutoHelper.makeFAAChannelId(event: '/event'), + ); + + /// [AATabBarTemplate], [AAGridTemplate], [AAListTemplate], [AAPaneTemplate], + /// [AAMessageTemplate], and [AALongMessageTemplate] in a list. + static List templateHistory = []; + + static AATemplate? get currentRootTemplate => templateHistory.firstOrNull; + + /// The currently presented modal, i.e. [AAAlertTemplate]. + static AATemplate? currentPresentTemplate; + + MethodChannel get methodChannel => _methodChannel; + + EventChannel get eventChannel => _eventChannel; + + Future flutterToNativeModule( + FAAChannelTypes type, [ + dynamic data, + ]) async { + return FlutterAndroidAutoController.flutterToNativeModuleStatic(type, data); + } + + static Future flutterToNativeModuleStatic( + FAAChannelTypes type, [ + dynamic data, + ]) async { + await resolveSvgInPayload(data, size: FlutterAndroidAuto.svgRasterSize); + final bool? value = await _methodChannel.invokeMethod( + type.name, + data, + ); + return value; + } + + static Future updateAAListTemplateSections({ + required String elementId, + required List sections, + }) async { + final payload = { + 'elementId': elementId, + 'sections': + sections.map((AAListSection section) => section.toJson()).toList(), + }; + + final bool? isCompleted = await flutterToNativeModuleStatic( + FAAChannelTypes.updateListTemplateSections, + payload, + ); + + if (isCompleted == true) { + for (final template in templateHistory) { + if (template is AAListTemplate && template.uniqueId == elementId) { + template.updateSections(sections); + return; + } + } + } + } + + Future processFAAListItemSelectedChannel(String elementId) async { + final AAListItem? item = _androidAutoHelper.findAAListItem( + templates: templateHistory, + elementId: elementId, + ); + if (item == null) return; + + Future complete() async { + await flutterToNativeModule( + FAAChannelTypes.onListItemSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFAAGridButtonPressed(String elementId) async { + final AAGridButton? item = _androidAutoHelper.findAAGridButton( + templates: templateHistory, + elementId: elementId, + ); + if (item == null) return; + + Future complete() async { + await flutterToNativeModule( + FAAChannelTypes.onGridButtonSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + void processFAAListSectionSelectedChannel( + String elementId, + int selectedIndex, + ) { + final AAListSection? listSection = _androidAutoHelper.findAAListSection( + templates: templateHistory, + elementId: elementId, + ); + final selectedItem = listSection?.items.elementAtOrNull(selectedIndex); + + if (listSection != null && selectedItem != null) { + listSection.selectedIndex = selectedIndex; + listSection.onSelected?.call(selectedIndex, selectedItem); + } + } + + void processFAAToggleCheckedChangeChannel(String elementId, bool checked) { + final AAListItem? listItem = _androidAutoHelper.findAAListItem( + templates: templateHistory, + elementId: elementId, + ); + + final toggle = listItem?.toggle; + if (toggle != null) { + toggle.isChecked = checked; + toggle.onCheckedChange?.call(checked, listItem!); + } + } + + void processFAAPaneActionPressedChannel(String elementId) { + final AAPaneAction? paneAction = _androidAutoHelper.findAAPaneAction( + templates: templateHistory, + elementId: elementId, + ); + paneAction?.onPress?.call(); + } + + void processFAAMapActionPressedChannel(String elementId) { + final AAMapAction? action = _androidAutoHelper.findAAMapAction( + templates: templateHistory, + elementId: elementId, + ); + action?.onPress?.call(); + } + + void processFAAAlertActionPressed(String elementId) { + final template = currentPresentTemplate; + if (template is! AAAlertTemplate) return; + + final AAAlertAction? action = + template.actions.cast().firstWhere( + (action) => action?.uniqueId == elementId, + orElse: () => null, + ); + action?.onPress(); + } + + void processFAAPresentStateChanged(String elementId, bool completed) { + final template = currentPresentTemplate; + if (template is AAAlertTemplate && template.onPresent != null) { + template.onPresent!(completed); + } + if (!completed) currentPresentTemplate = null; + } +} diff --git a/third_party/flutter_carplay/lib/controllers/carplay_controller.dart b/third_party/flutter_carplay/lib/controllers/carplay_controller.dart new file mode 100644 index 00000000..feefbd9a --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/carplay_controller.dart @@ -0,0 +1,443 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +/// [FlutterCarPlayController] is an root object in order to control and communication +/// system with the Apple CarPlay and native functions. +class FlutterCarPlayController { + static final FlutterCarplayHelper _carplayHelper = + const FlutterCarplayHelper(); + static final MethodChannel _methodChannel = MethodChannel( + _carplayHelper.makeFCPChannelId(), + ); + static final EventChannel _eventChannel = EventChannel( + _carplayHelper.makeFCPChannelId(event: '/event'), + ); + + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], [CPIInformationTemplate], [CPPointOfInterestTemplate] in a List + static List templateHistory = []; + + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], [CPIInformationTemplate], [CPPointOfInterestTemplate] + static CPTemplate? get currentRootTemplate => templateHistory.firstOrNull; + + /// [CPAlertTemplate], [CPActionSheetTemplate] + static CPTemplate? currentPresentTemplate; + + MethodChannel get methodChannel { + return _methodChannel; + } + + EventChannel get eventChannel { + return _eventChannel; + } + + static Future flutterToNativeModule( + FCPChannelTypes type, [ + dynamic data, + ]) async { + // Rasterize any Flutter asset SVGs referenced by image fields into PNG + // bytes before sending the payload to the native side, which cannot render + // SVG directly. Non-collection payloads pass through unchanged. + await resolveSvgInPayload(data, size: FlutterCarplay.svgRasterSize); + + final value = await _methodChannel.invokeMethod( + type.name, + data, + ); + return value; + } + + static void updateCPListItem( + CPListItem updatedListItem, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListItem, + updatedListItem.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListItem.uniqueId && + i is CPListItem) { + s.items[s.items.indexOf(i)] = updatedListItem; + return; + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListItem.uniqueId && + i is CPListItem) { + s.items[s.items.indexOf(i)] = updatedListItem; + return; + } + } + } + break; + default: + } + } + }, + ); + } + + static void updateCPListImageRowItemElement( + CPListImageRowItemElement updatedListImageRowItemElement, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListImageRowItemElement, + updatedListImageRowItemElement.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i is CPListImageRowItem) { + for (var e in i.elements ?? []) { + if (e.uniqueId == + updatedListImageRowItemElement.uniqueId) { + i.elements![i.elements!.indexOf(e)] = + updatedListImageRowItemElement; + return; + } + } + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i is CPListImageRowItem) { + for (var e in i.elements ?? []) { + if (e.uniqueId == + updatedListImageRowItemElement.uniqueId) { + i.elements![i.elements!.indexOf(e)] = + updatedListImageRowItemElement; + return; + } + } + } + } + } + break; + default: + } + } + }, + ); + } + + static void updateCPListImageRowItem( + CPListImageRowItem updatedListImageItem, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListImageRowItem, + updatedListImageItem.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListImageItem.uniqueId && + i is CPListImageRowItem) { + s.items[s.items.indexOf(i)] = updatedListImageItem; + return; + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListImageItem.uniqueId && + i is CPListImageRowItem) { + s.items[s.items.indexOf(i)] = updatedListImageItem; + return; + } + } + } + break; + default: + } + } + }, + ); + } + + static Future getMaximumNumberOfGridImages() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumNumberOfGridImages.name, + ); + return value; + } + + static Future getMaximumSectionCount() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumSectionCount.name, + ); + return value; + } + + static Future getMaximumItemCount() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumItemCount.name, + ); + return value; + } + + void addTemplateToHistory(CPTemplate template) { + if (template is CPTabBarTemplate || + template is CPGridTemplate || + template is CPInformationTemplate || + template is CPPointOfInterestTemplate || + template is CPListTemplate || + template is CPSearchTemplate) { + templateHistory.add(template); + } else { + throw TypeError(); + } + } + + Future processFCPListItemSelectedChannel(String elementId) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + if (item is! CPListItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListItemSelectedComplete, item.uniqueId); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFCPListImageRowItemSelectedChannel( + String elementId) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + + if (item is! CPListImageRowItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListImageRowItemSelectedComplete, item.uniqueId); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFCPListImageRowItemElementSelectedChannel( + String elementId, + int index, + ) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + + if (item is! CPListImageRowItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListImageRowItemElementSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onItemPress?.call(complete, item, index)); + } catch (_) { + await complete(); + } + } + + void processFCPAlertActionPressed(String elementId) { + if (currentPresentTemplate is! CPActionsTemplate) return; + + final actions = (currentPresentTemplate as CPActionsTemplate).actions; + for (var action in actions) { + if (action.uniqueId == elementId) { + action.onPress(); + return; + } + } + } + + void processFCPAlertTemplateCompleted(bool completed) { + if (currentPresentTemplate is CPAlertTemplate) { + (currentPresentTemplate as CPAlertTemplate).onPresent?.call(completed); + } + } + + void processFCPGridButtonPressed(String elementId) { + CPGridButton? gridButton; + l1: + for (var t in templateHistory) { + if (t is CPGridTemplate) { + for (var b in t.buttons) { + if (b.uniqueId == elementId) { + gridButton = b; + break l1; + } + } + } + } + gridButton?.onPress?.call(); + } + + void processFCPBarButtonPressed(String elementId) { + for (var t in templateHistory) { + final List listTemplates = []; + if (t is CPTabBarTemplate) { + for (var template in t.templates) { + if (template is CPListTemplate) listTemplates.add(template); + } + } else if (t is CPListTemplate) { + listTemplates.add(t); + } + for (var list in listTemplates) { + if (list.backButton?.uniqueId == elementId) { + list.backButton?.onPress(); + return; + } + } + } + } + + void processFCPTextButtonPressed(String elementId) { + for (var t in templateHistory) { + if (t is CPPointOfInterestTemplate) { + for (CPPointOfInterest p in t.poi) { + if (p.primaryButton != null && + p.primaryButton!.uniqueId == elementId) { + p.primaryButton!.onPress(); + return; + } + if (p.secondaryButton != null && + p.secondaryButton!.uniqueId == elementId) { + p.secondaryButton!.onPress(); + return; + } + } + } else { + if (t is CPInformationTemplate) { + for (CPTextButton b in t.actions) { + if (b.uniqueId == elementId) { + b.onPress(); + return; + } + } + } + } + } + } + + void processFCPSearchTextUpdated(String elementId, String searchText) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + t.onUpdatedSearchText?.call( + searchText, + (List results) { + t.updateResults(results); + final items = results.map((e) => e.toJson()).toList(); + FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateSearchResults, + { + 'elementId': elementId, + 'searchResults': items, + }, + ); + }, + ); + return; + } + } + } + + void processFCPSearchResultSelected(String elementId, String itemElementId) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + CPListItem? selectedItem; + for (var item in t.currentResults) { + if (item.uniqueId == itemElementId) { + selectedItem = item; + break; + } + } + if (selectedItem != null) { + t.onSelectedResult?.call( + selectedItem, + () { + FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.onSearchResultSelectedComplete, + {'elementId': elementId}, + ); + }, + ); + } + return; + } + } + } + + void processFCPSearchButtonPressed(String elementId) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + t.onSearchTemplateSearchButtonPressed?.call(); + return; + } + } + } + + static T? getTemplateFromHistory(String elementId) { + for (final template in templateHistory) { + if (template is T && template.uniqueId == elementId) return template; + + if (template is CPTabBarTemplate) { + for (final t in template.templates) { + if (t is T && t.uniqueId == elementId) return t; + } + } + } + return null; + } +} diff --git a/third_party/flutter_carplay/lib/flutter_carplay.dart b/third_party/flutter_carplay/lib/flutter_carplay.dart new file mode 100644 index 00000000..aea62785 --- /dev/null +++ b/third_party/flutter_carplay/lib/flutter_carplay.dart @@ -0,0 +1,16 @@ +// Android Auto & CarPlay Workers +export 'package:flutter_carplay/android_auto_worker.dart' + show FlutterAndroidAuto; +export 'package:flutter_carplay/carplay_worker.dart' show FlutterCarplay; + +// Constants +export 'package:flutter_carplay/constants/all.dart'; + +// Helpers +export 'package:flutter_carplay/helpers/all.dart'; + +// Models +export 'package:flutter_carplay/models/all.dart'; + +// Android Auto Models +export 'package:flutter_carplay/aa_models/all.dart'; diff --git a/third_party/flutter_carplay/lib/helpers/all.dart b/third_party/flutter_carplay/lib/helpers/all.dart new file mode 100644 index 00000000..ba2f0ba9 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/all.dart @@ -0,0 +1,4 @@ +export 'auto_android_helper.dart'; +export 'carplay_helper.dart'; +export 'enum_utils.dart'; +export 'svg_rasterizer.dart'; diff --git a/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart b/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart new file mode 100644 index 00000000..bb1ee07d --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart @@ -0,0 +1,103 @@ +import 'package:flutter_carplay/flutter_carplay.dart'; + +class FlutterAutoAndroidHelper { + const FlutterAutoAndroidHelper(); + + AAListItem? findAAListItem({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final listTemplate in _listTemplates(template)) { + for (final section in listTemplate.sections) { + for (final item in section.items) { + if (item.uniqueId == elementId) return item; + } + } + } + } + return null; + } + + AAListSection? findAAListSection({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final listTemplate in _listTemplates(template)) { + for (final section in listTemplate.sections) { + if (section.uniqueId == elementId) return section; + } + } + } + return null; + } + + AAGridButton? findAAGridButton({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final gridTemplate in _gridTemplates(template)) { + for (final button in gridTemplate.buttons) { + if (button.uniqueId == elementId) return button; + } + } + } + return null; + } + + AAPaneAction? findAAPaneAction({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + if (template is AAPaneTemplate) { + for (final action in template.actions) { + if (action.uniqueId == elementId) return action; + } + } + } + return null; + } + + /// DELTA D: the map action strip's actions live on the map template itself, + /// not on the content template nested inside it, so the pane lookup above + /// cannot find them. + AAMapAction? findAAMapAction({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + if (template is AAMapWithContentTemplate) { + for (final action in template.mapActions) { + if (action.uniqueId == elementId) return action; + } + } + } + return null; + } + + Iterable _listTemplates(AATemplate template) sync* { + if (template is AAListTemplate) { + yield template; + } else if (template is AATabBarTemplate) { + for (final tab in template.tabs) { + if (tab is AAListTemplate) yield tab; + } + } + } + + Iterable _gridTemplates(AATemplate template) sync* { + if (template is AAGridTemplate) { + yield template; + } else if (template is AATabBarTemplate) { + for (final tab in template.tabs) { + if (tab is AAGridTemplate) yield tab; + } + } + } + + String makeFAAChannelId({String event = ''}) => + 'com.oguzhnatly.flutter_android_auto$event'; +} diff --git a/third_party/flutter_carplay/lib/helpers/carplay_helper.dart b/third_party/flutter_carplay/lib/helpers/carplay_helper.dart new file mode 100644 index 00000000..8bee09e9 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/carplay_helper.dart @@ -0,0 +1,41 @@ +import 'package:flutter_carplay/flutter_carplay.dart'; + +class FlutterCarplayHelper { + const FlutterCarplayHelper(); + + CPListTemplateItem? findCPListTemplateItem({ + required List templates, + required String elementId, + }) { + CPListTemplateItem? listItem; + l1: + for (var t in templates) { + final List listTemplates = []; + if (t is CPTabBarTemplate) { + for (var template in t.templates) { + if (template is CPListTemplate) { + listTemplates.add(template); + } + } + } else if (t is CPListTemplate) { + listTemplates.add(t); + } + if (listTemplates.isNotEmpty) { + for (var list in listTemplates) { + for (var section in list.sections) { + for (var item in section.items) { + if (item.uniqueId == elementId) { + listItem = item; + break l1; + } + } + } + } + } + } + return listItem; + } + + String makeFCPChannelId({String? event = ''}) => + 'com.oguzhnatly.flutter_carplay${event!}'; +} diff --git a/third_party/flutter_carplay/lib/helpers/enum_utils.dart b/third_party/flutter_carplay/lib/helpers/enum_utils.dart new file mode 100644 index 00000000..70572073 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/enum_utils.dart @@ -0,0 +1,9 @@ +class EnumUtils { + const EnumUtils._(); + + static T enumFromString(Iterable values, String string) { + return values.firstWhere( + (T f) => f.name.toUpperCase() == string.toUpperCase(), + ); + } +} diff --git a/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart b/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart new file mode 100644 index 00000000..6091747b --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Default raster size (in logical pixels, square) used when an SVG asset is +/// rasterized to PNG bytes for native consumption. +/// +/// This can be overridden globally via [FlutterCarplay.svgRasterSize] / +/// [FlutterAndroidAuto.svgRasterSize], which are forwarded to +/// [resolveSvgInPayload]. +const defaultSvgRasterSize = 120; + +/// In-memory cache of rasterized SVG assets keyed by `assetPath|size`. +final _svgRasterCache = {}; + +/// In-flight rasterization operations keyed by `assetPath|size`. Concurrent +/// requests for the same asset/size share a single operation instead of +/// rasterizing the same SVG multiple times. +final _svgRasterInflight = >{}; + +/// Clears the in-memory rasterized SVG cache. +/// +/// Primarily intended for tests, but safe to call at any time. +@visibleForTesting +void clearSvgRasterCache() => _svgRasterCache.clear(); + +/// Returns `true` when [value] points to a Flutter asset SVG. +/// +/// Two conditions must hold: the value ends with `.svg` (case-insensitive) and +/// it is not a remote (`http`/`https`) URL. +bool isSvgAsset(String? value) { + final lower = value?.trim().toLowerCase(); + if (lower == null) return false; + return lower.endsWith('.svg') && !lower.startsWith('http'); +} + +/// Rasterizes the Flutter asset SVG at [assetPath] into PNG bytes. +/// +/// The output is a square image of [size] x [size] logical pixels. Results are +/// cached in-memory keyed by `assetPath|size`, so repeated calls return the +/// same [Uint8List] instance. Concurrent calls for the same asset/size share a +/// single in-flight operation. +/// +/// Returns `null` when the asset cannot be loaded or rasterized (e.g. invalid +/// SVG, missing asset). +Future rasterizeSvgAsset( + String assetPath, { + int size = defaultSvgRasterSize, +}) { + final cacheKey = '$assetPath|$size'; + + final cached = _svgRasterCache[cacheKey]; + if (cached != null) return Future.value(cached); + + final inflight = _svgRasterInflight[cacheKey]; + if (inflight != null) return inflight; + + final operation = _rasterize(assetPath, size, cacheKey); + _svgRasterInflight[cacheKey] = operation; + return operation.whenComplete(() => _svgRasterInflight.remove(cacheKey)); +} + +/// Performs the actual rasterization for [rasterizeSvgAsset]. +Future _rasterize( + String assetPath, + int size, + String cacheKey, +) async { + try { + // Load the asset bytes ourselves (rather than via [SvgAssetLoader]) so that + // a missing asset surfaces here as a catchable error instead of going + // through flutter_svg's asset cache. + final assetData = await rootBundle.load(assetPath); + final svgString = utf8.decode(assetData.buffer.asUint8List()); + + final loader = SvgStringLoader(svgString); + final pictureInfo = await vg.loadPicture(loader, null); + + try { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + final pictureSize = pictureInfo.size; + final sourceWidth = pictureSize.width.isFinite && pictureSize.width > 0 + ? pictureSize.width + : size.toDouble(); + final sourceHeight = pictureSize.height.isFinite && pictureSize.height > 0 + ? pictureSize.height + : size.toDouble(); + + // Scale uniformly to fit within the target square while preserving the + // aspect ratio, then center within the [size] x [size] canvas. + final scale = (size / sourceWidth) < (size / sourceHeight) + ? size / sourceWidth + : size / sourceHeight; + final dx = (size - sourceWidth * scale) / 2; + final dy = (size - sourceHeight * scale) / 2; + + canvas.translate(dx, dy); + canvas.scale(scale); + canvas.drawPicture(pictureInfo.picture); + + final rendered = recorder.endRecording(); + final image = await rendered.toImage(size, size); + try { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return null; + final bytes = byteData.buffer.asUint8List(); + _svgRasterCache[cacheKey] = bytes; + return bytes; + } finally { + image.dispose(); + rendered.dispose(); + } + } finally { + pictureInfo.picture.dispose(); + } + } catch (error, stackTrace) { + debugPrint('flutter_carplay: failed to rasterize SVG "$assetPath": $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } +} + +/// Every payload key that may reference a Flutter asset SVG, paired with how the +/// rasterized bytes are attached. +/// +/// This is the single source of truth for the walker. When a model gains a new +/// image-bearing `toJson()` key, add it here. The coverage test +/// (`test/helpers/svg_rasterizer_coverage_test.dart`) asserts that every model +/// image key is represented here so new keys cannot silently slip through. +/// +/// Models emitting these keys: +/// - `image` -> CPListItem, CPGridButton, CPPointOfInterest, and all +/// CPListImageRowItem*Element subtypes. Bytes are attached +/// under `imageData`. +/// - `imageUrl` -> AAListItem (Android Auto). The native contract expects the +/// bytes under `imageData`. +/// - `accessoryImage` / `trailingImage` -> CPListItem trailing/accessory image. +/// Bytes are attached under `trailingImageData`. +/// - `gridImages`-> CPListImageRowItem (legacy iOS grid images); the native +/// contract expects the bytes under `gridImageData`. +@visibleForTesting +const svgImageDataKeys = { + 'image': 'imageData', + 'imageUrl': 'imageData', + 'accessoryImage': 'trailingImageData', + 'trailingImage': 'trailingImageData', + 'gridImages': 'gridImageData', +}; + +/// Image keys whose values are lists rather than a single asset string. +@visibleForTesting +const svgListImageKeys = {'gridImages'}; + +/// Keys that look image-related but must never be rasterized. +/// +/// - `systemIcon` -> CPTemplate tab image (resolved natively as an SF Symbol / +/// tab image, not an asset SVG we rasterize). +/// - `imageTitles` -> CPListImageRowItem labels (text, not images). +/// - `imageTint`/`trailingImageTint`/`gridImageTints` -> tint metadata, not +/// image references. +@visibleForTesting +const svgIgnoredKeys = { + 'systemIcon', + 'imageTitles', + 'imageTint', + 'trailingImageTint', + 'gridImageTints', +}; + +/// The sibling keys under which the walker attaches rasterized bytes (e.g. +/// `imageData`, `gridImageData`). These hold raw byte payloads, so the walker +/// must never recurse into them. +final _svgDataKeys = svgImageDataKeys.values.toSet(); + +/// Recursively walks a method-channel [node] (maps/lists), rasterizing any +/// Flutter asset SVG referenced by an image-bearing key (see [svgImageDataKeys]) +/// and attaching the PNG bytes to a sibling `Data` key. +/// +/// Behavior: +/// - A single-image key (e.g. `image`, `imageUrl`) whose value is an SVG asset +/// -> sibling `Data` ([Uint8List]). +/// - A list-image key (e.g. `gridImages`) -> sibling `Data` +/// (`List`, `null` for non-SVG entries). +/// - Keys in [svgIgnoredKeys] are skipped entirely (never rasterized, never +/// recursed into). +/// - Original image strings are preserved for native fallback / back-compat. +/// +/// The [node] is mutated in place and also returned for convenience. +Future resolveSvgInPayload( + dynamic node, { + int size = defaultSvgRasterSize, +}) async { + if (node is Map) { + for (final entry in svgImageDataKeys.entries) { + final value = node[entry.key]; + if (svgListImageKeys.contains(entry.key)) { + if (value is! List) continue; + var hasSvg = false; + final data = []; + for (final item in value) { + final bytes = await _rasterizeIfSvg(item, size); + data.add(bytes); + if (bytes != null) hasSvg = true; + } + if (hasSvg) node[entry.value] = data; + } else { + final bytes = await _rasterizeIfSvg(value, size); + if (bytes != null) node[entry.value] = bytes; + } + } + + // Recurse into all values, skipping ignored keys and the byte payloads we + // just attached. The latter are raster bytes ([Uint8List], which is itself + // a `List`) or lists of them; descending into those would needlessly + // walk every individual byte. + for (final key in node.keys.toList()) { + if (svgIgnoredKeys.contains(key)) continue; + if (_svgDataKeys.contains(key)) continue; + final value = node[key]; + if (value is Uint8List) continue; + await resolveSvgInPayload(value, size: size); + } + } else if (node is List) { + for (final item in node) { + if (item is Uint8List) continue; + await resolveSvgInPayload(item, size: size); + } + } + + return node; +} + +/// Returns rasterized PNG bytes when [value] is a Flutter asset SVG string, +/// otherwise `null`. +Future _rasterizeIfSvg(dynamic value, int size) { + if (value is String && isSvgAsset(value)) { + return rasterizeSvgAsset(value, size: size); + } + return Future.value(); +} diff --git a/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart b/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart new file mode 100644 index 00000000..d7d4ec06 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart @@ -0,0 +1,53 @@ +import 'package:flutter_carplay/models/alert/alert_action.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A template that displays a modal action sheet. +/// https://developer.apple.com/documentation/carplay/cpactionsheettemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPActionSheetTemplate extends CPTemplate implements CPActionsTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title of the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? title; + + /// The descriptive message providing details about the reason for displaying the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? message; + + /// The list of actions available on the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + @override + final List actions; + + /// Creates [CPActionSheetTemplate] + CPActionSheetTemplate({ + this.title, + this.message, + required this.actions, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + 'actions': actions.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPActionSheetTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/action_sheet/all.dart b/third_party/flutter_carplay/lib/models/action_sheet/all.dart new file mode 100644 index 00000000..5236d18e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/action_sheet/all.dart @@ -0,0 +1 @@ +export 'action_sheet_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/alert/alert_action.dart b/third_party/flutter_carplay/lib/models/alert/alert_action.dart new file mode 100644 index 00000000..a30df547 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_action.dart @@ -0,0 +1,42 @@ +import 'package:flutter_carplay/models/alert/alert_constants.dart'; +import 'package:uuid/uuid.dart'; + +/// An object that encapsulates an action the user can perform on an action sheet or alert. +/// https://developer.apple.com/documentation/carplay/cpalertaction +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPAlertAction { + /// Unique id of the object. + final String _elementId; + + /// The action button’s title. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The display style for the action button. + /// Default is [CPAlertActionStyle.normal] + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final CPAlertActionStyle style; + + /// The closure that CarPlay invokes after the user taps the action button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function() onPress; + + /// Creates [CPAlertAction] + CPAlertAction({ + required this.title, + this.style = CPAlertActionStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'style': style.name, + 'runtimeType': 'FCPAlertAction', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/alert/alert_constants.dart b/third_party/flutter_carplay/lib/models/alert/alert_constants.dart new file mode 100644 index 00000000..ea50a304 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_constants.dart @@ -0,0 +1,3 @@ +/// Display styles for an alert’s action button. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +enum CPAlertActionStyle { normal, cancel, destructive } diff --git a/third_party/flutter_carplay/lib/models/alert/alert_template.dart b/third_party/flutter_carplay/lib/models/alert/alert_template.dart new file mode 100644 index 00000000..2401ca7d --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_template.dart @@ -0,0 +1,55 @@ +import 'package:flutter_carplay/models/alert/alert_action.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A template that displays a modal alert. +/// https://developer.apple.com/documentation/carplay/cpalerttemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPAlertTemplate extends CPTemplate implements CPActionsTemplate { + /// Unique id of the object. + final String _elementId; + + /// The array of title variants. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List titleVariants; + + /// The array of actions available on the alert. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + @override + final List actions; + + /// The closure that CarPlay invokes after the user taps the action button. + /// Notes: + /// - If completed is true, the alert successfully presented. If not, you may want to show an error to the user. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(bool completed)? onPresent; + + /// Creates [CPAlertTemplate] + CPAlertTemplate({ + required this.titleVariants, + required this.actions, + this.onPresent, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'actions': actions.map((e) => e.toJson()).toList(), + 'onPresent': onPresent != null ? true : false, + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPAlertTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/alert/all.dart b/third_party/flutter_carplay/lib/models/alert/all.dart new file mode 100644 index 00000000..5f9cd4eb --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/all.dart @@ -0,0 +1,3 @@ +export 'alert_action.dart'; +export 'alert_constants.dart'; +export 'alert_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/all.dart b/third_party/flutter_carplay/lib/models/all.dart new file mode 100644 index 00000000..2ad9241f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/all.dart @@ -0,0 +1,11 @@ +export 'action_sheet/all.dart'; +export 'alert/all.dart'; +export 'button/all.dart'; +export 'common/all.dart'; +export 'grid/all.dart'; +export 'information/all.dart'; +export 'list/all.dart'; +export 'poi/all.dart'; +export 'search/all.dart'; +export 'tabbar/all.dart'; +export 'template.dart'; diff --git a/third_party/flutter_carplay/lib/models/button/all.dart b/third_party/flutter_carplay/lib/models/button/all.dart new file mode 100644 index 00000000..2081529b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/all.dart @@ -0,0 +1,3 @@ +export 'bar_button.dart'; +export 'button_constants.dart'; +export 'text_button.dart'; diff --git a/third_party/flutter_carplay/lib/models/button/bar_button.dart b/third_party/flutter_carplay/lib/models/button/bar_button.dart new file mode 100644 index 00000000..f3397645 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/bar_button.dart @@ -0,0 +1,43 @@ +import 'package:uuid/uuid.dart'; + +import 'button_constants.dart'; + +/// A button for placement in a navigation bar. +/// https://developer.apple.com/documentation/carplay/cpbarbutton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPBarButton { + /// Unique id of the object. + final String _elementId; + + /// The title displayed on the bar button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The style to use when displaying the button. + /// Default is [CPBarButtonStyle.rounded] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPBarButtonStyle buttonStyle; + + /// A block that CarPlay calls when the user taps a bar button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final Function() onPress; + + /// Creates [CPBarButton] + CPBarButton({ + required this.title, + this.buttonStyle = CPBarButtonStyle.rounded, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttonStyle': buttonStyle.name, + 'runtimeType': 'FCPBarButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/button/button_constants.dart b/third_party/flutter_carplay/lib/models/button/button_constants.dart new file mode 100644 index 00000000..794068d4 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/button_constants.dart @@ -0,0 +1,5 @@ +/// The display style of a bar button. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPBarButtonStyle { none, rounded } + +enum CPTextButtonStyle { normal, cancel, confirm } diff --git a/third_party/flutter_carplay/lib/models/button/text_button.dart b/third_party/flutter_carplay/lib/models/button/text_button.dart new file mode 100644 index 00000000..6b3fa979 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/text_button.dart @@ -0,0 +1,43 @@ +import 'package:uuid/uuid.dart'; + +import 'button_constants.dart'; + +/// A button that displays a stylized title. +/// https://developer.apple.com/documentation/carplay/CPTextButton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPTextButton { + /// Unique id of the object. + final String _elementId; + + /// The text the button displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The text style the button applies to its title. + /// Default is [CPTextButtonStyle.normal] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPTextButtonStyle textstyle; + + /// A closure that CarPlay invokes when the user taps the button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final Function() onPress; + + /// Creates [CPTextButton] + CPTextButton({ + required this.title, + this.textstyle = CPTextButtonStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'textstyle': textstyle.name, + 'runtimeType': 'FCPTextButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/common/all.dart b/third_party/flutter_carplay/lib/models/common/all.dart new file mode 100644 index 00000000..e463f94f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/all.dart @@ -0,0 +1,2 @@ +export 'image_tint.dart'; +export 'ui_color.dart'; diff --git a/third_party/flutter_carplay/lib/models/common/image_tint.dart b/third_party/flutter_carplay/lib/models/common/image_tint.dart new file mode 100644 index 00000000..8236248f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/image_tint.dart @@ -0,0 +1,130 @@ +import 'ui_color.dart'; + +/// Host-aware tint options for image glyphs shown in CarPlay and Android Auto. +/// +/// Prefer [AutoImageTint.platform] for icons that must remain visible in focused +/// or selected rows. It lets the host choose an appropriate color on Android +/// Auto and uses a high-contrast system color on CarPlay. +/// +/// Custom colors use [UIColor] RGB channels authored as byte values from `0` to +/// `255`. Native platforms convert those byte values internally, so callers do +/// not need to account for UIKit's normalized color component range. +class AutoImageTint { + final AutoImageTintType type; + + /// Light-mode custom color. Only used by [AutoImageTint.custom]. + final UIColor? color; + + /// Dark-mode custom color. Falls back to [color] when omitted. + final UIColor? darkColor; + + /// Adds contrast protection where the platform does not manage selected-state + /// icon contrast for us. Currently this is applied by the CarPlay renderer. + final bool selectedSafe; + + const AutoImageTint._({ + required this.type, + this.color, + this.darkColor, + this.selectedSafe = true, + }); + + /// Uses the host platform's default icon tint. + const AutoImageTint.platform({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.platform, + selectedSafe: selectedSafe, + ); + + /// Uses the host platform's primary tint color. + const AutoImageTint.primary({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.primary, + selectedSafe: selectedSafe, + ); + + /// Uses the host platform's secondary tint color. + const AutoImageTint.secondary({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.secondary, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard red tint. + const AutoImageTint.red({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.red, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard green tint. + const AutoImageTint.green({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.green, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard blue tint. + const AutoImageTint.blue({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.blue, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard yellow tint. + const AutoImageTint.yellow({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.yellow, + selectedSafe: selectedSafe, + ); + + /// Uses custom RGB byte colors for light and optional dark mode. + /// + /// Pass [color] and [darkColor] as [UIColor] values with RGB channels from + /// `0` to `255`. If [darkColor] is omitted, [color] is reused in dark mode. + const AutoImageTint.custom({ + required UIColor color, + UIColor? darkColor, + bool selectedSafe = true, + }) : this._( + type: AutoImageTintType.custom, + color: color, + darkColor: darkColor, + selectedSafe: selectedSafe, + ); + + Map toJson() => { + 'type': type.name, + 'color': color?.toJson(), + 'darkColor': darkColor?.toJson(), + 'selectedSafe': selectedSafe, + }; +} + +/// Convenience helpers for configuring [AutoImageTint]. +extension AutoImageTintDarkColor on AutoImageTint { + /// Returns a copy of this tint with [darkColor] as its dark-mode custom color. + /// + /// [darkColor] is authored with RGB byte channels from `0` to `255`. Native + /// platforms convert those values internally. For non-custom tint types this + /// method creates a custom tint, using the provided [darkColor] for both light + /// and dark mode unless the original tint already had a custom light [color]. + AutoImageTint withDarkColor(UIColor darkColor) { + return AutoImageTint.custom( + color: color ?? darkColor, + darkColor: darkColor, + selectedSafe: selectedSafe, + ); + } +} + +enum AutoImageTintType { + platform, + primary, + secondary, + red, + green, + blue, + yellow, + custom, +} diff --git a/third_party/flutter_carplay/lib/models/common/ui_color.dart b/third_party/flutter_carplay/lib/models/common/ui_color.dart new file mode 100644 index 00000000..4d0214e5 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/ui_color.dart @@ -0,0 +1,43 @@ +/// An object that stores color data and sometimes opacity. +/// +/// Pass RGB channels as byte values from `0` to `255`. Native platforms convert +/// those byte channels internally to the format they need, such as UIKit's +/// normalized `0.0` to `1.0` components. +/// +/// [alpha] accepts the existing normalized `0.0` to `1.0` range, and native +/// platforms also tolerate byte-style alpha values from `0` to `255`. +/// iOS 2.0+ | iPadOS 2.0+ | Mac Catalyst 13.1+ +class UIColor { + /// Red channel, authored as a byte value from `0` to `255`. + final int red; + + /// Green channel, authored as a byte value from `0` to `255`. + final int green; + + /// Blue channel, authored as a byte value from `0` to `255`. + final int blue; + + /// Opacity. Prefer `0.0` to `1.0`; native platforms also accept `0` to `255`. + final double alpha; + + /// Creates [UIColor]. + /// + /// RGB values outside `0` to `255` are clamped when serialized. + const UIColor({ + required this.red, + required this.green, + required this.blue, + this.alpha = 1.0, + }); + + Map toJson() { + return { + 'red': _clampByte(red), + 'green': _clampByte(green), + 'blue': _clampByte(blue), + 'alpha': alpha, + }; + } + + static int _clampByte(int value) => value.clamp(0, 255).toInt(); +} diff --git a/third_party/flutter_carplay/lib/models/grid/all.dart b/third_party/flutter_carplay/lib/models/grid/all.dart new file mode 100644 index 00000000..2a5672a9 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/all.dart @@ -0,0 +1,2 @@ +export 'grid_button.dart'; +export 'grid_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/grid/grid_button.dart b/third_party/flutter_carplay/lib/models/grid/grid_button.dart new file mode 100644 index 00000000..1bfaab60 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/grid_button.dart @@ -0,0 +1,62 @@ +import 'package:uuid/uuid.dart'; + +import '../common/image_tint.dart'; + +/// A menu item button displayed on a grid template. +/// https://developer.apple.com/documentation/carplay/cpgridbutton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPGridButton { + /// Unique id of the object. + final String _elementId; + + /// An array of title variants for the button. + /// When the system displays the button, it selects the title that best fits the available + /// screen space, so arrange the titles from most to least preferred when creating a grid button. + /// Also, localize each title for display to the user, and **be sure to include at least + /// one title in the array.** + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List titleVariants; + + /// The image displayed on the button. + /// + /// Supports these formats: + /// - **Asset path**: `images/flutter_logo.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/icon.svg` (rasterized to PNG before being sent to + /// the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + /// + /// **[!] When creating a grid button, do NOT provide an animated image. If you do, the button + /// uses the first image in the animation sequence.** + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String image; + + /// Optional tint applied to [image]. + final AutoImageTint? imageTint; + + /// The block invoked after the user taps the button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function()? onPress; + + /// Creates [CPGridButton] + CPGridButton({ + required this.titleVariants, + required this.image, + this.imageTint, + this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'onPress': onPress != null ? true : false, + 'runtimeType': 'FCPGridButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/grid/grid_template.dart b/third_party/flutter_carplay/lib/models/grid/grid_template.dart new file mode 100644 index 00000000..fd04838e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/grid_template.dart @@ -0,0 +1,50 @@ +import 'package:flutter_carplay/models/grid/grid_button.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// Creates a grid template with a title and a set of buttons. +/// https://developer.apple.com/documentation/carplay/cpgridtemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPGridTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title shown in the grid template’s navigation bar. + /// [systemIcon] must be set in order for the title to be displayed in a tab bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The array of grid buttons displayed on the template. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List buttons; + + /// Creates [CPGridTemplate] in order to display a grid of items as buttons. + /// When creating the grid template, provide an array of [CPGridButton] objects. + /// Each button must contain a title that is shown in the grid template's navigation bar. + CPGridTemplate({ + required this.title, + required this.buttons, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttons': buttons.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPGridTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/information/all.dart b/third_party/flutter_carplay/lib/models/information/all.dart new file mode 100644 index 00000000..525bcfab --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/all.dart @@ -0,0 +1,3 @@ +export 'information_constants.dart'; +export 'information_item.dart'; +export 'information_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/information/information_constants.dart b/third_party/flutter_carplay/lib/models/information/information_constants.dart new file mode 100644 index 00000000..aa5e4f27 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_constants.dart @@ -0,0 +1,3 @@ +/// The layout that an information template uses to arrange its items. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPInformationTemplateLayout { leading, twoColumn } diff --git a/third_party/flutter_carplay/lib/models/information/information_item.dart b/third_party/flutter_carplay/lib/models/information/information_item.dart new file mode 100644 index 00000000..da13fcaf --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_item.dart @@ -0,0 +1,35 @@ +import 'package:uuid/uuid.dart'; + +/// A data object that provides content for an information template. +/// https://developer.apple.com/documentation/carplay/cpinformationitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPInformationItem { + /// Unique id of the object. + final String _elementId; + + /// The text that the template displays as the item’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? title; + + /// The text that the template displays below or beside the item’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? detail; + + /// Creates [CPInformationItem] + CPInformationItem({ + this.title, + this.detail, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'detail': detail, + 'runtimeType': 'FCPInformationItem', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/information/information_template.dart b/third_party/flutter_carplay/lib/models/information/information_template.dart new file mode 100644 index 00000000..ebcc3830 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_template.dart @@ -0,0 +1,75 @@ +import 'package:uuid/uuid.dart'; + +import '../button/text_button.dart'; +import '../template.dart'; +import 'information_constants.dart'; +import 'information_item.dart'; + +/// A template object that displays and manages information items and text buttons. +/// https://developer.apple.com/documentation/carplay/cpinformationtemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPInformationTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The template’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The layout that the template uses to arrange its items. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPInformationTemplateLayout layout; + + /// The actions that the template displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List actions; + + /// An array of information items that the template displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List informationItems; + + /// Creates [CPInformationTemplate] + CPInformationTemplate({ + required this.title, + required this.layout, + required this.actions, + required this.informationItems, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'layout': layout.name, + 'title': title, + 'actions': actions.map((e) => e.toJson()).toList(), + 'informationItems': informationItems.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPInformationTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } + + void updateInformationItems(List newItems) { + final copy = List.from(newItems); + informationItems + ..clear() + ..addAll(copy); + } + + void updateActions(List newActions) { + final copy = List.from(newActions); + actions + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/list/all.dart b/third_party/flutter_carplay/lib/models/list/all.dart new file mode 100644 index 00000000..d2ccd781 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/all.dart @@ -0,0 +1,7 @@ +export 'list_constants.dart'; +export 'list_image_row_item.dart'; +export 'list_image_row_item/all.dart'; +export 'list_item.dart'; +export 'list_section.dart'; +export 'list_template.dart'; +export 'list_template_item.dart'; diff --git a/third_party/flutter_carplay/lib/models/list/list_constants.dart b/third_party/flutter_carplay/lib/models/list/list_constants.dart new file mode 100644 index 00000000..ef7429d6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_constants.dart @@ -0,0 +1,7 @@ +/// The locations where a list item can display the Now Playing indicator. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPListItemPlayingIndicatorLocation { trailing, leading } + +/// The accessory types that a list item can display. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPListItemAccessoryType { none, cloud, disclosureIndicator } diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart new file mode 100644 index 00000000..b116b1b6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; + +import '../../controllers/carplay_controller.dart'; +import '../common/image_tint.dart'; +import 'list_image_row_item/list_image_row_item_element.dart'; +import 'list_template_item.dart'; + +/// A List template row that displays a series of images. +/// https://developer.apple.com/documentation/carplay/cplistimagerowitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPListImageRowItem extends CPListTemplateItem { + /// Unique id of the object. + final String _elementId; + + /// The images that appear in the list item’s image row. + /// + /// Each entry accepts an asset path, an SVG Flutter asset (`.svg`, rasterized + /// to PNG before reaching the native side), a `file://` path, or a network + /// URL. Remote/`file://` SVGs are not supported. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0 + final List? gridImages; + + /// Optional tints for [gridImages], aligned by index. + final List? gridImageTints; + + /// The titles displayed for each image in this image row item. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0' + final List? imageTitles; + + /// The array of elements used to draw visible elements. + /// Can be one of the following types of elements: + /// - [CPListImageRowItemCardElement] + /// - [CPListImageRowItemCondensedElement] + /// - [CPListImageRowItemRowElement] + /// - [CPListImageRowItemGridElement] + /// - [CPListImageRowItemImageGridElement] + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + List? elements; + + /// A Boolean value indicating whether the elements should be visible in more than a single line. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final bool allowsMultipleLines; + + /// An optional closure that CarPlay invokes when the user selects the list item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + FutureOr Function(Function() complete, CPListImageRowItem self)? + onPress; + + /// An optional closure that CarPlay invokes when the user selects an image. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + FutureOr Function( + Function() complete, CPListImageRowItem self, int? index)? onItemPress; + + /// Creates [CPListImageRowItem] + CPListImageRowItem({ + super.text, + this.gridImages, + this.gridImageTints, + this.imageTitles, + this.elements, + this.allowsMultipleLines = false, + this.onPress, + this.onItemPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'text': text, + 'gridImages': gridImages, + 'gridImageTints': + gridImageTints?.map((tint) => tint?.toJson()).toList(), + 'imageTitles': imageTitles, + 'elements': elements?.map((e) => e.toJson()).toList(), + 'allowsMultipleLines': allowsMultipleLines, + 'onPress': onPress != null ? true : false, + 'onItemPress': onItemPress != null ? true : false, + 'runtimeType': 'FCPListImageRowItem', + }; + + void setText(String text) { + this.text = text; + FlutterCarPlayController.updateCPListImageRowItem(this); + } + + void setElements(List elements) { + this.elements = elements; + FlutterCarPlayController.updateCPListImageRowItem(this); + } + + /// The maximum number of images that an image row can contain. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ + static Future getMaximumNumberOfGridImages() => + FlutterCarPlayController.getMaximumNumberOfGridImages(); + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart new file mode 100644 index 00000000..544767a9 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart @@ -0,0 +1,7 @@ +export 'list_image_row_item_card_element.dart'; +export 'list_image_row_item_condensed_element.dart'; +export 'list_image_row_item_constants.dart'; +export 'list_image_row_item_element.dart'; +export 'list_image_row_item_grid_element.dart'; +export 'list_image_row_item_image_grid_element.dart'; +export 'list_image_row_item_row_element.dart'; diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart new file mode 100644 index 00000000..7d354399 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart @@ -0,0 +1,114 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import '../../common/ui_color.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemcardelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemCardElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// A UIColor used to tint the element. When @c showsImageFullHeight is true, + /// the tint color is applied behind the labels at the bottom of the card. + /// Otherwise, this color is part of the gradient color at the bottom of the card. + UIColor? tintColor; + + /// A Boolean value indicating whether the element should be fill with the image. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0 + final bool showsImageFullHeight; + + /// Creates [CPListImageRowItemCardElement] + CPListImageRowItemCardElement({ + required this.image, + this.imageTint, + this.title, + this.subtitle, + this.showsImageFullHeight = true, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'tintColor': tintColor?.toJson(), + 'showsImageFullHeight': showsImageFullHeight, + 'runtimeType': 'FCPListImageRowItemCardElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTintColor(UIColor tintColor) { + this.tintColor = tintColor; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + UIColor? tintColor, + bool? showsImageFullHeight, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + if (tintColor != null) this.tintColor = tintColor; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart new file mode 100644 index 00000000..b206a746 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart @@ -0,0 +1,115 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_constants.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemcondensedelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemCondensedElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// The name of the system symbol image to use as accessory. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? accessorySymbolName; + + /// Shape used to draw the image of the element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final CPListImageRowItemCondensedElementShape imageShape; + + /// Creates [CPListImageRowItemCondensedElement] + CPListImageRowItemCondensedElement({ + required this.image, + required this.title, + this.imageTint, + this.subtitle, + this.accessorySymbolName, + this.imageShape = CPListImageRowItemCondensedElementShape.roundedRectangle, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'accessorySymbolName': accessorySymbolName, + 'imageShape': imageShape.name, + 'runtimeType': 'FCPListImageRowItemCondensedElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setAccessorySymbolName(String accessorySymbolName) { + this.accessorySymbolName = accessorySymbolName; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + String? accessorySymbolName, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + if (accessorySymbolName != null) { + this.accessorySymbolName = accessorySymbolName; + } + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart new file mode 100644 index 00000000..28b80018 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart @@ -0,0 +1,19 @@ +/// Types of shape used to draw a condensed row element. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ +enum CPListImageRowItemCondensedElementShape { + /// The list item will render an element with a circular image. + circular, + + /// The list item will render an element with a rounded rectangle image. + roundedRectangle, +} + +/// Types of shape used to draw a condensed row element. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ +enum CPListImageRowItemImageGridElementShape { + /// The list item will render an element with a circular image. + circular, + + /// The list item will render an element with a rounded rectangle image. + roundedRectangle, +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart new file mode 100644 index 00000000..9dd6f035 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart @@ -0,0 +1,28 @@ +import '../../common/image_tint.dart'; + +/// Abstract superclass for a a row item element object. +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemelement +abstract interface class CPListImageRowItemElement { + /// The image associated with this element. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? get image; + + /// Optional tint applied to [image]. + AutoImageTint? get imageTint; + + Map toJson(); + + String get uniqueId; + + /// Updates the element's image. See [image] for supported formats (including + /// `.svg` Flutter assets). + void setImage(String image, {AutoImageTint? imageTint}); + + /// Updates the tint applied to [image]. Pass `null` to remove the tint. + void setImageTint(AutoImageTint? imageTint); +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart new file mode 100644 index 00000000..76701c90 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart @@ -0,0 +1,67 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemgridelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemGridElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// Creates [CPListImageRowItemGridElement] + CPListImageRowItemGridElement({ + required this.image, + this.imageTint, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'runtimeType': 'FCPListImageRowItemGridElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart new file mode 100644 index 00000000..c68754e0 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_constants.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemimagegridelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemImageGridElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String title; + + /// The name of the system symbol image to use as accessory. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? accessorySymbolName; + + /// Shape used to draw the image of the element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final CPListImageRowItemImageGridElementShape imageShape; + + /// Creates [CPListImageRowItemImageGridElement] + CPListImageRowItemImageGridElement({ + required this.image, + required this.title, + this.imageTint, + this.accessorySymbolName, + this.imageShape = CPListImageRowItemImageGridElementShape.circular, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'accessorySymbolName': accessorySymbolName, + 'imageShape': imageShape.name, + 'runtimeType': 'FCPListImageRowItemImageGridElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setAccessorySymbolName(String accessorySymbolName) { + this.accessorySymbolName = accessorySymbolName; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? accessorySymbolName, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (accessorySymbolName != null) { + this.accessorySymbolName = accessorySymbolName; + } + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart new file mode 100644 index 00000000..e1ee61cd --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart @@ -0,0 +1,93 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemrowelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemRowElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// Creates [CPListImageRowItemRowElement] + CPListImageRowItemRowElement({ + required this.image, + this.imageTint, + this.title, + this.subtitle, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'runtimeType': 'FCPListImageRowItemRowElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_item.dart b/third_party/flutter_carplay/lib/models/list/list_item.dart new file mode 100644 index 00000000..a2b3ef07 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_item.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter_carplay/controllers/carplay_controller.dart'; +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:flutter_carplay/models/list/list_constants.dart'; +import 'package:uuid/uuid.dart'; + +import 'list_template_item.dart'; + +/// A selectable row in a list template. +/// https://developer.apple.com/documentation/carplay/cplistitem +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListItem extends CPListTemplateItem { + /// Unique id of the object. + final String _elementId; + + /// The list item’s secondary text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + String? detailText; + + /// The image that the list item displays in its leading region. + /// + /// Supports these formats: + /// * Asset path: `images/flutter_logo.png` from pubspec.yaml assets + /// * SVG asset: `images/icon.svg` rasterized to PNG before native display + /// * File path: `file:///path/to/image.png` local file on device + /// * Network URL: `https://example.com/image.png` remote image + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + String? image; + + /// Optional tint applied to [image]. Use [AutoImageTint.platform] when the + /// host should choose a selected or focused row safe color. + AutoImageTint? imageTint; + + /// Backward compatible trailing accessory image. + /// + /// New code should prefer [trailingImage], which also supports SVG assets and + /// tint metadata. + String? accessoryImage; + + /// The image that the list item displays in its trailing region. + /// + /// This maps to CarPlay's `accessoryImage` and takes precedence over + /// [accessoryImage] and [accessoryType]. Use it for state indicators while + /// keeping [image] for the leading user selected icon. + String? trailingImage; + + /// Optional tint applied to [trailingImage]. + AutoImageTint? trailingImageTint; + + /// The playback progress status for the content that the list item represents. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double? playbackProgress; + + /// A Boolean value that determines whether the list item displays its Now Playing indicator. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + bool? isPlaying; + + /// The location where the list item displays its Now Playing indicator. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPListItemPlayingIndicatorLocation? playingIndicatorLocation; + + /// The accessory that the list item displays in its trailing region. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPListItemAccessoryType? accessoryType; + + /// An optional closure that CarPlay invokes when the user selects the list item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final FutureOr Function(Function() complete, CPListItem self)? onPress; + + /// Creates [CPListItem] that manages the content of a single row in a [CPListTemplate]. + /// CarPlay manages the layout of a list item and may adjust its layout to allow for + /// the display of auxiliary content, such as, an accessory or a Now Playing indicator. + /// A list item can display primary text, secondary text, now playing indicators as playback progress, + /// an accessory image and a trailing image. + CPListItem({ + super.text, + this.detailText, + this.onPress, + this.image, + this.imageTint, + this.accessoryImage, + this.trailingImage, + this.trailingImageTint, + this.playbackProgress, + this.isPlaying, + this.playingIndicatorLocation, + this.accessoryType, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'text': text, + 'detailText': detailText, + 'onPress': onPress != null ? true : false, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'accessoryImage': accessoryImage, + 'trailingImage': trailingImage, + 'trailingImageTint': trailingImageTint?.toJson(), + 'playbackProgress': playbackProgress, + 'isPlaying': isPlaying, + 'playingIndicatorLocation': playingIndicatorLocation?.name, + 'accessoryType': accessoryType?.name, + 'runtimeType': 'FCPListItem', + }; + + /// Updating the list item's primary text. + void setText(String text) { + this.text = text; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the list item's secondary text. + void setDetailText(String detailText) { + this.detailText = detailText; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image which will be displayed on the leading edge of the list item cell. + /// + /// Supports these formats: + /// * Asset path: `images/flutter_logo.png` from pubspec.yaml assets + /// * SVG asset: `images/icon.svg` rasterized to PNG before native display + /// * File path: `file:///path/to/image.png` local file on device + /// * Network URL: `https://example.com/image.png` remote image + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updates the tint applied to [image]. Pass `null` to remove the tint. + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image displayed on the trailing edge of the list item cell. + /// + /// See [trailingImage] for supported formats, including SVG Flutter assets. + void setTrailingImage(String trailingImage, {AutoImageTint? imageTint}) { + this.trailingImage = trailingImage; + if (imageTint != null) trailingImageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updates the tint applied to [trailingImage]. Pass `null` to remove it. + void setTrailingImageTint(AutoImageTint? imageTint) { + trailingImageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image displayed in the trailing region of the list item cell. + /// + /// Supports the same asset path, file path, and network URL formats as [image]. + void setAccessoryImage(String? accessoryImage) { + this.accessoryImage = accessoryImage; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for playbackProgress + /// When the given value is not between 0.0 and 1.0, throws [RangeError] + void setPlaybackProgress(double playbackProgress) { + if (playbackProgress >= 0.0 && playbackProgress <= 1.0) { + this.playbackProgress = playbackProgress; + FlutterCarPlayController.updateCPListItem(this); + } else { + throw RangeError('playbackProgress must be between 0.0 and 1.0'); + } + } + + /// Setter for isPlaying + void setIsPlaying(bool isPlaying) { + this.isPlaying = isPlaying; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for playingIndicatorLocation + void setPlayingIndicatorLocation( + CPListItemPlayingIndicatorLocation playingIndicatorLocation, + ) { + this.playingIndicatorLocation = playingIndicatorLocation; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for accessoryType + void setAccessoryType(CPListItemAccessoryType accessoryType) { + this.accessoryType = accessoryType; + FlutterCarPlayController.updateCPListItem(this); + } + + void update({ + String? text, + String? detailText, + String? image, + AutoImageTint? imageTint, + String? accessoryImage, + String? trailingImage, + AutoImageTint? trailingImageTint, + double? playbackProgress, + bool? isPlaying, + CPListItemPlayingIndicatorLocation? playingIndicatorLocation, + CPListItemAccessoryType? accessoryType, + }) { + if (text != null) this.text = text; + if (detailText != null) this.detailText = detailText; + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (accessoryImage != null) this.accessoryImage = accessoryImage; + if (trailingImage != null) this.trailingImage = trailingImage; + if (trailingImageTint != null) this.trailingImageTint = trailingImageTint; + if (playbackProgress != null) { + if (playbackProgress >= 0.0 && playbackProgress <= 1.0) { + this.playbackProgress = playbackProgress; + } else { + throw RangeError('playbackProgress must be between 0.0 and 1.0'); + } + } + if (isPlaying != null) this.isPlaying = isPlaying; + if (playingIndicatorLocation != null) { + this.playingIndicatorLocation = playingIndicatorLocation; + } + if (accessoryType != null) this.accessoryType = accessoryType; + + FlutterCarPlayController.updateCPListItem(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_section.dart b/third_party/flutter_carplay/lib/models/list/list_section.dart new file mode 100644 index 00000000..975ea97a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_section.dart @@ -0,0 +1,46 @@ +import 'package:uuid/uuid.dart'; + +import 'list_template_item.dart'; + +/// A container that groups your list items into sections. +/// https://developer.apple.com/documentation/carplay/cplistsection +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListSection { + /// Unique id of the object. + final String _elementId; + + /// The section’s header text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? header; + + /// The section’s index title. + /// Defaults to true. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final bool? sectionIndexEnabled; + + /// The list of items for the section. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List items; + + /// Creates [CPListSection] that contains zero or more list items. You can configure + /// a section to display a header, which CarPlay displays on the trailing edge of the screen. + CPListSection({ + this.header, + this.sectionIndexEnabled, + required List items, + String? id, + }) : items = List.from(items), + _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'header': header, + 'items': items.map((e) => e.toJson()).toList(), + 'sectionIndexEnabled': sectionIndexEnabled, + 'runtimeType': 'FCPListSection', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_template.dart b/third_party/flutter_carplay/lib/models/list/list_template.dart new file mode 100644 index 00000000..7bd96715 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_template.dart @@ -0,0 +1,89 @@ +import 'package:flutter_carplay/models/button/bar_button.dart'; +import 'package:flutter_carplay/models/list/list_section.dart'; +import 'package:uuid/uuid.dart'; + +import '../../controllers/carplay_controller.dart'; +import '../template.dart'; + +/// A template that displays and manages a list of items. +/// https://developer.apple.com/documentation/carplay/CPListTemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title that the navigation bar displays when the template is visible + /// Notes: + /// - [systemIcon] must be set in order for the title to be displayed in a tab bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? title; + + /// The sections that the list displays. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List sections; + + /// An array of title variants for the template’s empty view. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List? emptyViewTitleVariants; + + /// An array of subtitle variants for the template’s empty view. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List? emptyViewSubtitleVariants; + + /// A button to display as the Back button on the navigation bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final CPBarButton? backButton; + + /// Creates [CPListTemplate] to display a list of items, grouped into one or more sections. + /// Each section contains an array of list items — objects that is [CPListItem] + /// + /// **Consider that some vehicles limit the number of items that [CPListTemplate] displays.** + CPListTemplate({ + this.title, + required this.sections, + this.emptyViewTitleVariants, + this.emptyViewSubtitleVariants, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + this.backButton, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'sections': sections.map((e) => e.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'emptyViewSubtitleVariants': emptyViewSubtitleVariants, + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'backButton': backButton?.toJson(), + 'runtimeType': 'FCPListTemplate', + }; + + /// The maximum number of sections that the template can display. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + static Future getMaximumSectionCount() => + FlutterCarPlayController.getMaximumSectionCount(); + + /// The maximum number of items, across all sections, that the template can display. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + static Future getMaximumItemCount() => + FlutterCarPlayController.getMaximumItemCount(); + + @override + String get uniqueId { + return _elementId; + } + + void updateSections(List newSections) { + final copy = List.from(newSections); + sections + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_template_item.dart b/third_party/flutter_carplay/lib/models/list/list_template_item.dart new file mode 100644 index 00000000..ef0cd477 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_template_item.dart @@ -0,0 +1,16 @@ +/// A description of the common properties of all list item types. +/// https://developer.apple.com/documentation/carplay/cplisttemplateitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +abstract class CPListTemplateItem { + CPListTemplateItem({ + this.text, + }); + + /// The item’s primary text. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? text; + + Map toJson(); + + String get uniqueId; +} diff --git a/third_party/flutter_carplay/lib/models/poi/all.dart b/third_party/flutter_carplay/lib/models/poi/all.dart new file mode 100644 index 00000000..adf0fb6e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/all.dart @@ -0,0 +1,2 @@ +export 'poi.dart'; +export 'poi_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/poi/poi.dart b/third_party/flutter_carplay/lib/models/poi/poi.dart new file mode 100644 index 00000000..83956c8a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/poi.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +import '../common/image_tint.dart'; +import '../button/text_button.dart'; + +/// A section object of list items that appear in a list template. +/// https://developer.apple.com/documentation/carplay/cppointofinterest +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPPointOfInterest { + /// Unique id of the object. + final String _elementId; + + /// The latitude of the geographical coordinate. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double latitude; + + /// The longitude of the geographical coordinate. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double longitude; + + /// The title that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String title; + + /// The subtitle that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? subtitle; + + /// A brief summary that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? summary; + + /// The detail card’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailTitle; + + /// The detail card’s subtitle. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailSubtitle; + + /// A brief summary that the detail card displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailSummary; + + /// A custom image that the map annotation displays. + /// Supports these formats: + /// - **Asset path**: `images/marker.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/marker.svg` (rasterized to PNG before being sent + /// to the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + String? image; + + /// Optional tint applied to [image]. + AutoImageTint? imageTint; + + /// The detail card’s primary action button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPTextButton? primaryButton; + + /// The detail card’s secondary action button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPTextButton? secondaryButton; + + /// Creates [CPPointOfInterest] + CPPointOfInterest({ + required this.latitude, + required this.longitude, + required this.title, + this.subtitle, + this.summary, + this.detailTitle, + this.detailSubtitle, + this.detailSummary, + this.image, + this.imageTint, + this.primaryButton, + this.secondaryButton, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'latitude': latitude, + 'longitude': longitude, + 'title': title, + 'subtitle': subtitle, + 'summary': summary, + 'detailTitle': detailTitle, + 'detailSubtitle': detailSubtitle, + 'detailSummary': detailSummary, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'primaryButton': primaryButton?.toJson(), + 'secondaryButton': secondaryButton?.toJson(), + 'runtimeType': 'FCPPointOfInterest', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/poi/poi_template.dart b/third_party/flutter_carplay/lib/models/poi/poi_template.dart new file mode 100644 index 00000000..9cb6fceb --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/poi_template.dart @@ -0,0 +1,47 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'poi.dart'; + +/// A template that displays a map with selectable points of interest. +/// https://developer.apple.com/documentation/carplay/cppointofinteresttemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPPointOfInterestTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The scrollable picker’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The points of interest the template displays on the map and in the scrollable picker. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List poi; + + /// Creates [CPPointOfInterestTemplate] + CPPointOfInterestTemplate({ + required this.title, + required this.poi, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'poi': poi.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPPointOfInterestTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/search/all.dart b/third_party/flutter_carplay/lib/models/search/all.dart new file mode 100644 index 00000000..7806666b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/search/all.dart @@ -0,0 +1 @@ +export 'search_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/search/search_template.dart b/third_party/flutter_carplay/lib/models/search/search_template.dart new file mode 100644 index 00000000..d85b0c9b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/search/search_template.dart @@ -0,0 +1,59 @@ +import 'package:uuid/uuid.dart'; + +import '../list/list_item.dart'; +import '../template.dart'; + +/// A template that provides the ability to search for a destination and see a list of search results. +/// https://developer.apple.com/documentation/carplay/cpsearchtemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPSearchTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// Tells the delegate that the user updated the search criteria text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(String searchText, Function(List results) update)? + onUpdatedSearchText; + + /// Tells the delegate that the user selected an item from the search result. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(CPListItem selectedItem, Function() complete)? + onSelectedResult; + + /// Tells the delegate that the user tapped the keyboard's search button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function()? onSearchTemplateSearchButtonPressed; + + final List _currentResults = []; + + /// Creates [CPSearchTemplate] to display a CarPlay search interface. + CPSearchTemplate({ + String? id, + this.onUpdatedSearchText, + this.onSelectedResult, + this.onSearchTemplateSearchButtonPressed, + }) : _elementId = id ?? const Uuid().v4(), + super(); + + @override + Map toJson() => { + 'runtimeType': 'FCPSearchTemplate', + '_elementId': _elementId, + 'onUpdatedSearchText': onUpdatedSearchText != null, + 'onSelectedResult': onSelectedResult != null, + 'onSearchTemplateSearchButtonPressed': + onSearchTemplateSearchButtonPressed != null, + }; + + @override + String get uniqueId => _elementId; + + List get currentResults => _currentResults; + + void updateResults(List results) { + final copy = List.from(results); + _currentResults + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/tabbar/all.dart b/third_party/flutter_carplay/lib/models/tabbar/all.dart new file mode 100644 index 00000000..b9ce2e6a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/tabbar/all.dart @@ -0,0 +1 @@ +export 'tabbar_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart b/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart new file mode 100644 index 00000000..4a308985 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart @@ -0,0 +1,65 @@ +import 'package:flutter_carplay/models/action_sheet/action_sheet_template.dart'; +import 'package:flutter_carplay/models/alert/alert_template.dart'; +import 'package:flutter_carplay/models/grid/grid_template.dart'; +import 'package:flutter_carplay/models/information/information_template.dart'; +import 'package:flutter_carplay/models/list/list_template.dart'; +import 'package:flutter_carplay/models/poi/poi_template.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A container template that displays and manages other templates, presenting them as tabs. +/// Supported template types: [CPListTemplate], [CPPointOfInterestTemplate], +/// [CPGridTemplate], [CPInformationTemplate], [CPActionSheetTemplate], [CPAlertTemplate] +/// https://developer.apple.com/documentation/carplay/cptabbartemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPTabBarTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The tab bar’s templates. + /// Supported types: [CPListTemplate], [CPPointOfInterestTemplate], + /// [CPGridTemplate], [CPInformationTemplate] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List templates; + + /// When creating a [CPTabBarTemplate], provide an array of templates for the tab bar to display. + /// CarPlay treats the array’s templates as root templates, each with its own + /// navigation hierarchy. When a tab bar template is the rootTemplate of your + /// app’s interface controller and you use the controller to add and remove templates, + /// CarPlay applies those changes to the selected tab’s navigation hierarchy. + /// + /// [!] You can’t add a tab bar template to an existing navigation hierarchy, + /// or present one modally. + CPTabBarTemplate({ + required List templates, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : templates = List.from(templates), + _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'tabTitle': tabTitle, + 'templates': templates.map((e) => e.toJson()).toList(), + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPTabBarTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } + + void updateTemplates(List newTemplates) { + final copy = List.from(newTemplates); + templates + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/template.dart b/third_party/flutter_carplay/lib/models/template.dart new file mode 100644 index 00000000..9d7617f1 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/template.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +import 'alert/alert_action.dart'; + +/// https://developer.apple.com/documentation/carplay/cptemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +abstract class CPTemplate { + CPTemplate({ + this.tabTitle, + this.showsTabBadge = false, + this.systemIcon, + this.onPop, + }); + + /// An indicator you use to call attention to the tab. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final bool showsTabBadge; + + /// An image that represents the content of the tab. + /// Note: + /// - This property is given to tabImage + /// - If null, template title will not be display in the tab bar. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? systemIcon; + + /// A short title that describes the content of the tab. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? tabTitle; + + /// Called when this template is popped from the navigation stack. + /// + /// Fires for both user-initiated pops (CarPlay back button) and + /// programmatic pops via [FlutterCarplay.pop]. Useful for cleaning + /// up subscriptions, state listeners, or analytics events tied to + /// this template's lifetime. + /// + /// Not called for modal templates (alerts, action sheets) — those + /// have their own lifecycle hooks. + final VoidCallback? onPop; + + String get uniqueId; + + Map toJson(); +} + +abstract interface class CPActionsTemplate { + const CPActionsTemplate(); + + List get actions; +} diff --git a/third_party/flutter_carplay/pubspec.yaml b/third_party/flutter_carplay/pubspec.yaml new file mode 100644 index 00000000..53b5192d --- /dev/null +++ b/third_party/flutter_carplay/pubspec.yaml @@ -0,0 +1,35 @@ +name: flutter_carplay +description: Flutter Apps are now on Apple CarPlay and Android Auto. This package aims to make it safe to use apps made with Flutter in the car by integrating with CarPlay or Android Auto. +version: 1.6.5 +homepage: https://github.com/oguzhnatly/flutter_carplay#readme +repository: https://github.com/oguzhnatly/flutter_carplay +issue_tracker: https://github.com/oguzhnatly/flutter_carplay/issues + +environment: + sdk: ">=3.6.0 <4.0.0" + flutter: ">=1.10.0" + +dependencies: + flutter: + sdk: flutter + flutter_svg: ^2.0.10+1 + uuid: ^4.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + # DELTA A from upstream 1.6.5: the `ios:` entry and the whole ios/ + # directory are removed. MeshMapper uses this package for Android Auto + # only. Left in place, it would link SwiftFlutterCarplayPlugin into the + # App Store build — a CarPlay scene-delegate and entitlement surface we + # do not want and cannot ship without Apple's CarPlay entitlement. + # The Dart AA* classes are pure Dart plus a MethodChannel and still + # compile on iOS, where AndroidAutoService.isSupportedPlatform is false. + android: + package: com.oguzhnatly.flutter_android_auto + pluginClass: FlutterAndroidAutoPlugin